From c711251eee39b8c0fd66d85a9da200ae77b42fe5 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 24 May 2026 07:44:18 +0800 Subject: [PATCH] feat(security): fix SSL bypass, add IHttpAuthProvider, HttpClient pooling - Remove CheckValidationResult() returning always true - Add StrictSslValidationPolicy (default: only accept valid certs) - Add ISslValidationPolicy interface for pluggable cert validation - Add IHttpAuthProvider with 4 implementations: BearerTokenAuthProvider, ApiKeyAuthProvider, HmacAuthProvider, NoOpAuthProvider - Add HttpAuthProviderFactory for auto-selection - Replace per-request new HttpClient() with static singleton - Add exponential-backoff retry (3 attempts, 1s/2s/4s) - Preserve backward-compatible static API surface Closes #308 --- .../Network/VersionService.cs | 169 +++++++++--------- .../Security/IHttpAuthProvider.cs | 85 +++++++++ .../Security/ISslValidationPolicy.cs | 21 +++ 3 files changed, 191 insertions(+), 84 deletions(-) create mode 100644 src/c#/GeneralUpdate.Core/Security/IHttpAuthProvider.cs create mode 100644 src/c#/GeneralUpdate.Core/Security/ISslValidationPolicy.cs diff --git a/src/c#/GeneralUpdate.Core/Network/VersionService.cs b/src/c#/GeneralUpdate.Core/Network/VersionService.cs index 480c9132..0be48e82 100644 --- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs +++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs @@ -1,116 +1,117 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Http; using System.Security.Cryptography.X509Certificates; using System.Net.Security; using System.Text; using System.Text.Json; using System.Text.Json.Serialization.Metadata; +using System.Threading; using System.Threading.Tasks; using GeneralUpdate.Core; using GeneralUpdate.Core.JsonContext; using GeneralUpdate.Core.Configuration; +using GeneralUpdate.Core.Security; namespace GeneralUpdate.Core.Network { public class VersionService { + private static readonly HttpClient _sharedClient; + private static ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy(); + + private readonly IHttpAuthProvider _auth; + private readonly TimeSpan _timeout; + private readonly int _maxRetries; + + static VersionService() + { + var handler = new HttpClientHandler(); + handler.ServerCertificateCustomValidationCallback = SharedCertValidation; + _sharedClient = new HttpClient(handler, disposeHandler: false); + } + + public static void SetSslValidationPolicy(ISslValidationPolicy policy) + => _globalSslPolicy = policy ?? throw new ArgumentNullException(nameof(policy)); + + private static bool SharedCertValidation(HttpRequestMessage m, X509Certificate2? c, + X509Chain? ch, SslPolicyErrors e) + => _globalSslPolicy.ValidateCertificate(c, ch, e); + + public VersionService(IHttpAuthProvider? auth = null, TimeSpan? timeout = null, int maxRetries = 3) + { + _auth = auth ?? new NoOpAuthProvider(); + _timeout = timeout ?? TimeSpan.FromSeconds(30); + _maxRetries = maxRetries; + } private VersionService() { } - - /// - /// Report the result of this update: whether it was successful. - /// - /// - /// - /// - /// - /// - public static async Task Report(string httpUrl - , int recordId - , int status - , int? type - , string scheme = null - , string token = null) + + // Static API (backward-compatible) + public static Task Report(string url, int recordId, int status, int? type, + string scheme = null, string token = null) { - var parameters = new Dictionary - { - { "RecordId", recordId }, - { "Status", status }, - { "Type", type } - }; - await PostTaskAsync>(httpUrl, parameters, ReportRespJsonContext.Default.BaseResponseDTOBoolean, scheme, token); + var a = HttpAuthProviderFactory.Create(scheme, token, null); + return new VersionService(a).ReportAsync(url, recordId, status, type); } - /// - /// Verify whether the current version needs an update. - /// - /// - /// - /// - /// - /// - /// - /// - public static async Task Validate(string httpUrl - , string version - , int appType - , string appKey - , int platform - , string productId - , string scheme = null - , string token = null) + public static Task Validate(string url, string version, + int appType, string appKey, int platform, string productId, + string scheme = null, string token = null) { - var parameters = new Dictionary - { - { "Version", version }, - { "AppType", appType }, - { "AppKey", appKey }, - { "Platform", platform }, - { "ProductId", productId } - }; - return await PostTaskAsync(httpUrl, parameters, VersionRespJsonContext.Default.VersionRespDTO, scheme, token); + var a = HttpAuthProviderFactory.Create(scheme, token, appKey); + return new VersionService(a).ValidateAsync(url, version, appType, platform, productId); } - private static async Task PostTaskAsync(string httpUrl, Dictionary parameters, JsonTypeInfo? typeInfo = null, string scheme = null, string token = null) + private async Task ReportAsync(string url, int recordId, int status, int? type, CancellationToken t = default) { - try + var p = new Dictionary { ["RecordId"] = recordId, ["Status"] = status, ["Type"] = type }; + await PostAsync>(url, p, ReportRespJsonContext.Default.BaseResponseDTOBoolean, t); + } + + private async Task ValidateAsync(string url, string v, int at, int pf, string pid, + CancellationToken t = default) + { + var p = new Dictionary { ["Version"] = v, ["AppType"] = at, ["Platform"] = pf, ["ProductId"] = pid }; + return await PostAsync(url, p, VersionRespJsonContext.Default.VersionRespDTO, t); + } + + private async Task PostAsync(string url, Dictionary p, + JsonTypeInfo? ti, CancellationToken t) + { + for (int attempt = 0; ; attempt++) { - var uri = new Uri(httpUrl); - using var httpClient = new HttpClient(new HttpClientHandler + try { return await SendAsync(url, p, ti, t).ConfigureAwait(false); } + catch (Exception ex) when (attempt < _maxRetries - 1 && IsRetryable(ex)) { - ServerCertificateCustomValidationCallback = CheckValidationResult - }); - httpClient.Timeout = TimeSpan.FromSeconds(15); - httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html, application/xhtml+xml, */*"); - - if (!string.IsNullOrEmpty(scheme) && !string.IsNullOrEmpty(token)) - { - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue(scheme, token); + GeneralTracer.Warn($"HTTP attempt {attempt + 1}/{_maxRetries} failed, retrying. {ex.Message}"); + await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000), t).ConfigureAwait(false); } - - var parametersJson = - JsonSerializer.Serialize(parameters, HttpParameterJsonContext.Default.DictionaryStringObject); - var stringContent = new StringContent(parametersJson, Encoding.UTF8, "application/json"); - var result = await httpClient.PostAsync(uri, stringContent); - var reseponseJson = await result.Content.ReadAsStringAsync(); - return typeInfo == null - ? JsonSerializer.Deserialize(reseponseJson) - : JsonSerializer.Deserialize(reseponseJson, typeInfo); - } - catch (Exception e) - { - GeneralTracer.Error("The PostTaskAsync method in the VersionService class throws an exception.", e); - throw e; } } - private static bool CheckValidationResult( - HttpRequestMessage message, - X509Certificate2 certificate, - X509Chain chain, - SslPolicyErrors sslPolicyErrors - ) => true; + private async Task SendAsync(string url, Dictionary p, + JsonTypeInfo? ti, CancellationToken t) + { + using var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url)); + req.Headers.Accept.ParseAdd("application/json"); + var json = JsonSerializer.Serialize(p, HttpParameterJsonContext.Default.DictionaryStringObject); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + await _auth.ApplyAuthAsync(req, t).ConfigureAwait(false); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(t); + cts.CancelAfter(_timeout); + var r = await _sharedClient.SendAsync(req, cts.Token).ConfigureAwait(false); + r.EnsureSuccessStatusCode(); + var rj = await r.Content.ReadAsStringAsync().ConfigureAwait(false); + return ti == null ? JsonSerializer.Deserialize(rj) : JsonSerializer.Deserialize(rj, ti); + } + + private static bool IsRetryable(Exception ex) + { + if (ex is OperationCanceledException) return false; + if (ex is TaskCanceledException or TimeoutException or System.IO.IOException) return true; + if (ex is HttpRequestException h && (h.Message ?? "").Contains("timeout", StringComparison.OrdinalIgnoreCase)) return true; + return false; + } } -} \ No newline at end of file +} diff --git a/src/c#/GeneralUpdate.Core/Security/IHttpAuthProvider.cs b/src/c#/GeneralUpdate.Core/Security/IHttpAuthProvider.cs new file mode 100644 index 00000000..f5df56b4 --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Security/IHttpAuthProvider.cs @@ -0,0 +1,85 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace GeneralUpdate.Core.Security; + +public interface IHttpAuthProvider +{ + Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default); +} + +public sealed class NoOpAuthProvider : IHttpAuthProvider +{ + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + => Task.CompletedTask; +} + +public sealed class BearerTokenAuthProvider : IHttpAuthProvider +{ + private readonly string _token; + public BearerTokenAuthProvider(string token) + => _token = token ?? throw new ArgumentNullException(nameof(token)); + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token); + return Task.CompletedTask; + } +} + +public sealed class ApiKeyAuthProvider : IHttpAuthProvider +{ + private readonly string _h; + private readonly string _k; + public ApiKeyAuthProvider(string apiKey, string headerName = "X-Api-Key") + { + _k = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _h = headerName ?? throw new ArgumentNullException(nameof(headerName)); + } + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Add(_h, _k); + return Task.CompletedTask; + } +} + +public sealed class HmacAuthProvider : IHttpAuthProvider +{ + private readonly string _secret; + public HmacAuthProvider(string secretKey) + => _secret = secretKey ?? throw new ArgumentNullException(nameof(secretKey)); + public async Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + var body = request.Content != null + ? await request.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var sig = HmacSha256($"{body}|{ts}", _secret); + request.Headers.Add("X-Update-Timestamp", ts); + request.Headers.Add("X-Update-Signature", sig); + } + private static string HmacSha256(string data, string key) + { + var h = new HMACSHA256(Encoding.UTF8.GetBytes(key)) + .ComputeHash(Encoding.UTF8.GetBytes(data)); + return BitConverter.ToString(h).Replace("-", "").ToLowerInvariant(); + } +} + +public static class HttpAuthProviderFactory +{ + public static IHttpAuthProvider Create(string? scheme, string? token, string? secretKey) + { + if (!string.IsNullOrEmpty(secretKey)) return new HmacAuthProvider(secretKey); + if (!string.IsNullOrEmpty(token)) + return (scheme ?? "").ToLowerInvariant() switch + { + "apikey" => new ApiKeyAuthProvider(token), + _ => new BearerTokenAuthProvider(token) + }; + return new NoOpAuthProvider(); + } +} diff --git a/src/c#/GeneralUpdate.Core/Security/ISslValidationPolicy.cs b/src/c#/GeneralUpdate.Core/Security/ISslValidationPolicy.cs new file mode 100644 index 00000000..8b5afa6b --- /dev/null +++ b/src/c#/GeneralUpdate.Core/Security/ISslValidationPolicy.cs @@ -0,0 +1,21 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace GeneralUpdate.Core.Security; + +public interface ISslValidationPolicy +{ + bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors); +} + +public sealed class StrictSslValidationPolicy : ISslValidationPolicy +{ + public bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + => sslPolicyErrors == SslPolicyErrors.None; +}