From 61e9b87841e8a9af3388aeb106b83e8ed5607393 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Sun, 24 May 2026 07:12:16 +0800 Subject: [PATCH] feat(security): fix SSL bypass, add IHttpAuthProvider, HttpClient pooling - Remove CheckValidationResult() returning always true; replace with StrictSslValidationPolicy - Add ISslValidationPolicy interface for pluggable cert validation - Add IHttpAuthProvider abstraction with 4 built-in implementations: BearerTokenAuthProvider, ApiKeyAuthProvider, HmacAuthProvider, NoOpAuthProvider - Add HttpAuthProviderFactory for auto-selection based on scheme/token/secretKey - Replace per-request new HttpClient() with static singleton (shared handler) - Add exponential-backoff retry logic for transient HTTP failures - Preserve backward-compatible static API surface Closes #308 --- .../GeneralUpdate.ClientCore.csproj | 2 + .../Shared/Security/IHttpAuthProvider.cs | 153 +++++++++++ .../Shared/Security/ISslValidationPolicy.cs | 40 +++ .../Shared/Service/VersionService.cs | 253 ++++++++++++++---- .../GeneralUpdate.Core.csproj | 2 + 5 files changed, 392 insertions(+), 58 deletions(-) create mode 100644 src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs create mode 100644 src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs diff --git a/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj b/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj index 0242d33d..5ac09967 100644 --- a/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj +++ b/src/c#/GeneralUpdate.ClientCore/GeneralUpdate.ClientCore.csproj @@ -76,6 +76,8 @@ + + diff --git a/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs b/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs new file mode 100644 index 00000000..34184d70 --- /dev/null +++ b/src/c#/GeneralUpdate.Common/Shared/Security/IHttpAuthProvider.cs @@ -0,0 +1,153 @@ +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.Common.Shared.Security; + +/// +/// HTTP request authentication provider. +/// Implement this interface to attach authentication credentials to outgoing HTTP requests. +/// The provider is invoked before every HTTP request made by the update framework +/// (version validation, download, status reporting). +/// +public interface IHttpAuthProvider +{ + /// + /// Apply authentication to the given HTTP request message. + /// Called immediately before the request is sent. + /// + /// The outgoing HTTP request to attach auth to. + /// Cancellation token. + Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default); +} + +// ════════════════════════════════════════════════════════════════ +// Built-in implementations +// ════════════════════════════════════════════════════════════════ + +/// +/// No-op auth provider. Used when no authentication is configured (the default). +/// +public sealed class NoOpAuthProvider : IHttpAuthProvider +{ + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + => Task.CompletedTask; +} + +/// +/// Bearer token authentication (JWT / OAuth2). +/// Adds Authorization: Bearer {token} header. +/// +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; + } +} + +/// +/// API key authentication. +/// Adds a custom header (default X-Api-Key) with the provided API key. +/// +public sealed class ApiKeyAuthProvider : IHttpAuthProvider +{ + private readonly string _headerName; + private readonly string _apiKey; + + public ApiKeyAuthProvider(string apiKey, string headerName = "X-Api-Key") + { + _apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + _headerName = headerName ?? throw new ArgumentNullException(nameof(headerName)); + } + + public Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default) + { + request.Headers.Add(_headerName, _apiKey); + return Task.CompletedTask; + } +} + +/// +/// HMAC-SHA256 signature authentication. +/// Adds X-Update-Timestamp and X-Update-Signature headers. +/// The signature is computed over {request_body}|{unix_timestamp}. +/// +public sealed class HmacAuthProvider : IHttpAuthProvider +{ + private readonly string _secretKey; + + public HmacAuthProvider(string secretKey) + { + _secretKey = 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 timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + var payload = $"{body}|{timestamp}"; + var signature = ComputeHmacSha256(payload, _secretKey); + + request.Headers.Add("X-Update-Timestamp", timestamp); + request.Headers.Add("X-Update-Signature", signature); + } + + private static string ComputeHmacSha256(string data, string key) + { + var keyBytes = Encoding.UTF8.GetBytes(key); + var dataBytes = Encoding.UTF8.GetBytes(data); + + using var hmac = new HMACSHA256(keyBytes); + var hash = hmac.ComputeHash(dataBytes); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } +} + +/// +/// Factory methods for creating auth providers from UpdateOptions-style parameters. +/// Used internally by VersionService to auto-select the correct provider. +/// +public static class HttpAuthProviderFactory +{ + /// + /// Auto-select an auth provider based on the provided parameters. + /// + /// Auth scheme: "Bearer", "ApiKey", or null. + /// Auth token / API key. + /// HMAC secret key (takes priority over Token/Scheme). + /// The appropriate IHttpAuthProvider. + public static IHttpAuthProvider Create(string? scheme, string? token, string? appSecretKey) + { + // HMAC takes priority (used for signed requests) + if (!string.IsNullOrEmpty(appSecretKey)) + return new HmacAuthProvider(appSecretKey); + + // Bearer / API Key based on scheme + if (!string.IsNullOrEmpty(token)) + { + return (scheme ?? string.Empty).ToLowerInvariant() switch + { + "apikey" => new ApiKeyAuthProvider(token), + _ => new BearerTokenAuthProvider(token) // Bearer is the default + }; + } + + return new NoOpAuthProvider(); + } +} diff --git a/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs b/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs new file mode 100644 index 00000000..a28026ba --- /dev/null +++ b/src/c#/GeneralUpdate.Common/Shared/Security/ISslValidationPolicy.cs @@ -0,0 +1,40 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace GeneralUpdate.Common.Shared.Security; + +/// +/// SSL/TLS certificate validation policy. +/// Implement this interface to customize certificate validation behavior +/// (e.g., certificate pinning, custom CA trust, or bypass for testing environments). +/// +public interface ISslValidationPolicy +{ + /// + /// Validate the server certificate presented during TLS handshake. + /// + /// The server certificate, or null if not presented. + /// The certificate chain built by the system. + /// Errors detected by the system's default validation. + /// true to accept the certificate; false to reject and abort the connection. + bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors); +} + +/// +/// Default strict SSL validation policy. +/// Accepts only certificates that pass all standard validation checks +/// (trusted root CA, correct hostname, not expired, not revoked). +/// This is the safe default and should be used in production. +/// +public sealed class StrictSslValidationPolicy : ISslValidationPolicy +{ + /// + public bool ValidateCertificate( + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + => sslPolicyErrors == SslPolicyErrors.None; +} diff --git a/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs b/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs index 3bcfd10b..2eee7dea 100644 --- a/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs +++ b/src/c#/GeneralUpdate.Common/Shared/Service/VersionService.cs @@ -1,58 +1,102 @@ -using System; +using System; using System.Collections.Generic; -using System.Diagnostics; using System.Net.Http; +using System.Net.Http.Headers; 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.Common.Internal; using GeneralUpdate.Common.Internal.JsonContext; using GeneralUpdate.Common.Shared.Object; +using GeneralUpdate.Common.Shared.Security; namespace GeneralUpdate.Common.Shared.Service { public class VersionService { + private static readonly HttpClientHandler _sharedHandler; + private static readonly HttpClient _sharedClient; + + private readonly IHttpAuthProvider _authProvider; + private readonly ISslValidationPolicy _sslPolicy; + private readonly TimeSpan _timeout; + + /// + /// Default SSL validation policy used across all HTTP requests. + /// Can be overridden globally via . + /// + private static ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy(); + + private static volatile bool _handlerInitialized; + + static VersionService() + { + _sharedHandler = new HttpClientHandler(); + _sharedHandler.ServerCertificateCustomValidationCallback = SharedCertificateValidation; + _sharedClient = new HttpClient(_sharedHandler, disposeHandler: false); + } + + /// + /// Set a global SSL validation policy for all VersionService HTTP requests. + /// Must be called before the first HTTP request is made. + /// + public static void SetSslValidationPolicy(ISslValidationPolicy policy) + { + _globalSslPolicy = policy ?? throw new ArgumentNullException(nameof(policy)); + _handlerInitialized = true; + } + + private static bool SharedCertificateValidation( + HttpRequestMessage message, + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors sslPolicyErrors) + { + return _globalSslPolicy.ValidateCertificate(certificate, chain, sslPolicyErrors); + } + + // ════════════════════════════════════════════════════════════ + // Instance (with custom auth/timeout) + // ════════════════════════════════════════════════════════════ + public VersionService( + IHttpAuthProvider? authProvider = null, + ISslValidationPolicy? sslPolicy = null, + TimeSpan? timeout = null) + { + _authProvider = authProvider ?? new NoOpAuthProvider(); + _sslPolicy = sslPolicy ?? _globalSslPolicy; + _timeout = timeout ?? TimeSpan.FromSeconds(30); + } + private VersionService() { } - + + // ════════════════════════════════════════════════════════════ + // Static convenience methods (backward-compatible API surface) + // ════════════════════════════════════════════════════════════ + /// /// Report the result of this update: whether it was successful. /// - /// - /// - /// - /// - /// - public static async Task Report(string httpUrl + public static Task Report(string httpUrl , 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 auth = HttpAuthProviderFactory.Create(scheme, token, null); + var svc = new VersionService(auth); + return svc.ReportAsync(httpUrl, recordId, status, type); } /// /// Verify whether the current version needs an update. /// - /// - /// - /// - /// - /// - /// - /// - public static async Task Validate(string httpUrl + public static Task Validate(string httpUrl , string version , int appType , string appKey @@ -60,57 +104,150 @@ public static async Task Validate(string httpUrl , string productId , string scheme = null , string token = null) + { + var auth = HttpAuthProviderFactory.Create(scheme, token, appKey); + var svc = new VersionService(auth); + return svc.ValidateAsync(httpUrl, version, appType, platform, productId); + } + + // ════════════════════════════════════════════════════════════ + // Instance methods (with retry support) + // ════════════════════════════════════════════════════════════ + + private async Task ReportAsync(string httpUrl + , int recordId + , int status + , int? type + , CancellationToken token = default) + { + var parameters = new Dictionary + { + { "RecordId", recordId }, + { "Status", status }, + { "Type", type } + }; + await PostTaskAsync>( + httpUrl, parameters, ReportRespJsonContext.Default.BaseResponseDTOBoolean, token) + .ConfigureAwait(false); + } + + private async Task ValidateAsync(string httpUrl + , string version + , int appType + , int platform + , string productId + , CancellationToken token = default) { var parameters = new Dictionary { { "Version", version }, { "AppType", appType }, - { "AppKey", appKey }, { "Platform", platform }, { "ProductId", productId } }; - return await PostTaskAsync(httpUrl, parameters, VersionRespJsonContext.Default.VersionRespDTO, scheme, token); + return await PostTaskAsync( + httpUrl, parameters, VersionRespJsonContext.Default.VersionRespDTO, token) + .ConfigureAwait(false); } - private static async Task PostTaskAsync(string httpUrl, Dictionary parameters, JsonTypeInfo? typeInfo = null, string scheme = null, string token = null) + private async Task PostTaskAsync( + string httpUrl, + Dictionary parameters, + JsonTypeInfo? typeInfo = null, + CancellationToken token = default, + int maxRetries = 3) { - try + for (int attempt = 0; ; attempt++) { - var uri = new Uri(httpUrl); - using var httpClient = new HttpClient(new HttpClientHandler + try { - ServerCertificateCustomValidationCallback = CheckValidationResult - }); - httpClient.Timeout = TimeSpan.FromSeconds(15); - httpClient.DefaultRequestHeaders.Accept.ParseAdd("text/html, application/xhtml+xml, */*"); - - if (!string.IsNullOrEmpty(scheme) && !string.IsNullOrEmpty(token)) + return await SendRequestAsync(httpUrl, parameters, typeInfo, token) + .ConfigureAwait(false); + } + catch (Exception ex) when (attempt < maxRetries - 1 && IsRetryable(ex)) { - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue(scheme, token); + GeneralTracer.Warn( + $"HTTP request attempt {attempt + 1}/{maxRetries} failed, retrying... Details: {ex.Message}"); + + var delay = TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000); + await Task.Delay(delay, token).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) + } + + private async Task SendRequestAsync( + string httpUrl, + Dictionary parameters, + JsonTypeInfo? typeInfo, + CancellationToken token) + { + var uri = new Uri(httpUrl); + + using var request = new HttpRequestMessage(HttpMethod.Post, uri); + + // Apply auth + await _authProvider.ApplyAuthAsync(request, token).ConfigureAwait(false); + + // Build body + var parametersJson = JsonSerializer.Serialize( + parameters, HttpParameterJsonContext.Default.DictionaryStringObject); + request.Content = new StringContent(parametersJson, Encoding.UTF8, "application/json"); + request.Headers.Accept.ParseAdd("application/json"); + + // Re-apply auth after setting content (HMAC needs the body) + await _authProvider.ApplyAuthAsync(request, token).ConfigureAwait(false); + + // Send via shared HttpClient with custom timeout + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); + timeoutCts.CancelAfter(_timeout); + + var response = await _sharedClient.SendAsync(request, timeoutCts.Token) + .ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var responseJson = await response.Content.ReadAsStringAsync() + .ConfigureAwait(false); + + return typeInfo == null + ? JsonSerializer.Deserialize(responseJson) + : JsonSerializer.Deserialize(responseJson, typeInfo); + } + + /// + /// Determine if an exception is worth retrying. + /// Only retry on transient failures (network, timeout, server errors). + /// Do NOT retry on SSL/authentication failures — those are permanent. + /// + private static bool IsRetryable(Exception ex) + { + if (ex is OperationCanceledException) + return false; + + if (ex is HttpRequestException hre) { - GeneralTracer.Error("The PostTaskAsync method in the VersionService class throws an exception.", e); - throw e; + // Retry on timeout and server errors, not on client/SSL errors + var msg = hre.Message ?? string.Empty; + return msg.Contains("timeout", StringComparison.OrdinalIgnoreCase) + || msg.Contains("timed out", StringComparison.OrdinalIgnoreCase) + || msg.Contains("server", StringComparison.OrdinalIgnoreCase) + || msg.Contains("500") + || msg.Contains("502") + || msg.Contains("503") + || msg.Contains("504"); } - } - private static bool CheckValidationResult( - HttpRequestMessage message, - X509Certificate2 certificate, - X509Chain chain, - SslPolicyErrors sslPolicyErrors - ) => true; + if (ex is TaskCanceledException) + return true; // Timeout + + if (ex is TimeoutException) + return true; + + // IOException = network interruption + if (ex is System.IO.IOException) + return true; + + return false; + } } -} \ No newline at end of file +} diff --git a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj index 4df180fb..bbed19c1 100644 --- a/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj +++ b/src/c#/GeneralUpdate.Core/GeneralUpdate.Core.csproj @@ -84,6 +84,8 @@ + +