From 9463d7587fd20feeda20f592dbbd349d69524d45 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 6 May 2025 15:42:41 +0300 Subject: [PATCH] Add support for unstrusted certificates (#128) --- .../Configuration/General/HttpConfig.cs | 6 +- .../Common/Enums/CertificateValidationType.cs | 8 ++ code/Executable/DependencyInjection/MainDI.cs | 11 +++ .../DependencyInjection/ServicesDI.cs | 2 + code/Executable/appsettings.Development.json | 3 +- code/Executable/appsettings.json | 1 + .../Extensions/IpAddressExtensions.cs | 55 ++++++++++++ .../Services/CertificateValidationService.cs | 86 +++++++++++++++++++ .../configuration/GeneralSettings.tsx | 11 +++ 9 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 code/Common/Enums/CertificateValidationType.cs create mode 100644 code/Infrastructure/Extensions/IpAddressExtensions.cs create mode 100644 code/Infrastructure/Services/CertificateValidationService.cs diff --git a/code/Common/Configuration/General/HttpConfig.cs b/code/Common/Configuration/General/HttpConfig.cs index a6029e03..60c3f05b 100644 --- a/code/Common/Configuration/General/HttpConfig.cs +++ b/code/Common/Configuration/General/HttpConfig.cs @@ -1,4 +1,5 @@ -using Common.Exceptions; +using Common.Enums; +using Common.Exceptions; using Microsoft.Extensions.Configuration; namespace Common.Configuration.General; @@ -10,6 +11,9 @@ public sealed record HttpConfig : IConfig [ConfigurationKeyName("HTTP_TIMEOUT")] public ushort Timeout { get; init; } = 100; + + [ConfigurationKeyName("HTTP_VALIDATE_CERT")] + public CertificateValidationType CertificateValidation { get; init; } = CertificateValidationType.Enabled; public void Validate() { diff --git a/code/Common/Enums/CertificateValidationType.cs b/code/Common/Enums/CertificateValidationType.cs new file mode 100644 index 00000000..bc935987 --- /dev/null +++ b/code/Common/Enums/CertificateValidationType.cs @@ -0,0 +1,8 @@ +namespace Common.Enums; + +public enum CertificateValidationType +{ + Enabled = 0, + DisabledForLocalAddresses = 1, + Disabled = 2 +} \ No newline at end of file diff --git a/code/Executable/DependencyInjection/MainDI.cs b/code/Executable/DependencyInjection/MainDI.cs index d9f53db5..f542b792 100644 --- a/code/Executable/DependencyInjection/MainDI.cs +++ b/code/Executable/DependencyInjection/MainDI.cs @@ -1,10 +1,12 @@ using System.Net; using Common.Configuration.General; using Common.Helpers; +using Infrastructure.Services; using Infrastructure.Verticals.DownloadClient.Deluge; using Infrastructure.Verticals.Notifications.Consumers; using Infrastructure.Verticals.Notifications.Models; using MassTransit; +using Microsoft.Extensions.Options; using Polly; using Polly.Extensions.Http; @@ -62,6 +64,15 @@ public static class MainDI { x.Timeout = TimeSpan.FromSeconds(config.Timeout); }) + .ConfigurePrimaryHttpMessageHandler(provider => + { + CertificateValidationService service = provider.GetRequiredService(); + + return new HttpClientHandler + { + ServerCertificateCustomValidationCallback = service.ShouldByPassValidationError + }; + }) .AddRetryPolicyHandler(config); // add Deluge HttpClient diff --git a/code/Executable/DependencyInjection/ServicesDI.cs b/code/Executable/DependencyInjection/ServicesDI.cs index fcc56b39..498e6c48 100644 --- a/code/Executable/DependencyInjection/ServicesDI.cs +++ b/code/Executable/DependencyInjection/ServicesDI.cs @@ -3,6 +3,7 @@ using Common.Configuration.DownloadCleaner; using Common.Configuration.QueueCleaner; using Infrastructure.Interceptors; using Infrastructure.Providers; +using Infrastructure.Services; using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.DownloadCleaner; @@ -21,6 +22,7 @@ public static class ServicesDI public static IServiceCollection AddServices(this IServiceCollection services) => services .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() diff --git a/code/Executable/appsettings.Development.json b/code/Executable/appsettings.Development.json index ec956d22..e8f17b5d 100644 --- a/code/Executable/appsettings.Development.json +++ b/code/Executable/appsettings.Development.json @@ -1,7 +1,8 @@ { "DRY_RUN": true, "HTTP_MAX_RETRIES": 0, - "HTTP_TIMEOUT": 10, + "HTTP_TIMEOUT": 100, + "HTTP_VALIDATE_CERT": "enabled", "Logging": { "LogLevel": "Verbose", "Enhanced": true, diff --git a/code/Executable/appsettings.json b/code/Executable/appsettings.json index 88e004c7..39b1f0b4 100644 --- a/code/Executable/appsettings.json +++ b/code/Executable/appsettings.json @@ -2,6 +2,7 @@ "DRY_RUN": false, "HTTP_MAX_RETRIES": 0, "HTTP_TIMEOUT": 100, + "HTTP_VALIDATE_CERT": "enabled", "Logging": { "LogLevel": "Information", "Enhanced": true, diff --git a/code/Infrastructure/Extensions/IpAddressExtensions.cs b/code/Infrastructure/Extensions/IpAddressExtensions.cs new file mode 100644 index 00000000..f7758803 --- /dev/null +++ b/code/Infrastructure/Extensions/IpAddressExtensions.cs @@ -0,0 +1,55 @@ +using System.Net; +using System.Net.Sockets; + +namespace Infrastructure.Extensions; + +public static class IpAddressExtensions +{ + public static bool IsLocalAddress(this IPAddress ipAddress) + { + // Map back to IPv4 if mapped to IPv6, for example "::ffff:1.2.3.4" to "1.2.3.4". + if (ipAddress.IsIPv4MappedToIPv6) + { + ipAddress = ipAddress.MapToIPv4(); + } + + // Checks loopback ranges for both IPv4 and IPv6. + if (IPAddress.IsLoopback(ipAddress)) + { + return true; + } + + // IPv4 + if (ipAddress.AddressFamily == AddressFamily.InterNetwork) + { + return IsLocalIPv4(ipAddress.GetAddressBytes()); + } + + // IPv6 + if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) + { + return ipAddress.IsIPv6LinkLocal || + ipAddress.IsIPv6UniqueLocal || + ipAddress.IsIPv6SiteLocal; + } + + return false; + } + + private static bool IsLocalIPv4(byte[] ipv4Bytes) + { + // Link local (no IP assigned by DHCP): 169.254.0.0 to 169.254.255.255 (169.254.0.0/16) + bool IsLinkLocal() => ipv4Bytes[0] == 169 && ipv4Bytes[1] == 254; + + // Class A private range: 10.0.0.0 – 10.255.255.255 (10.0.0.0/8) + bool IsClassA() => ipv4Bytes[0] == 10; + + // Class B private range: 172.16.0.0 – 172.31.255.255 (172.16.0.0/12) + bool IsClassB() => ipv4Bytes[0] == 172 && ipv4Bytes[1] >= 16 && ipv4Bytes[1] <= 31; + + // Class C private range: 192.168.0.0 – 192.168.255.255 (192.168.0.0/16) + bool IsClassC() => ipv4Bytes[0] == 192 && ipv4Bytes[1] == 168; + + return IsLinkLocal() || IsClassA() || IsClassC() || IsClassB(); + } +} \ No newline at end of file diff --git a/code/Infrastructure/Services/CertificateValidationService.cs b/code/Infrastructure/Services/CertificateValidationService.cs new file mode 100644 index 00000000..2dea88d9 --- /dev/null +++ b/code/Infrastructure/Services/CertificateValidationService.cs @@ -0,0 +1,86 @@ +using System.Net; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Common.Configuration.General; +using Common.Enums; +using Infrastructure.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Infrastructure.Services; + +public class CertificateValidationService +{ + private readonly ILogger _logger; + private readonly HttpConfig _config; + + public CertificateValidationService(ILogger logger, IOptions config) + { + _logger = logger; + _config = config.Value; + } + + public bool ShouldByPassValidationError(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) + { + var targetHostName = string.Empty; + + if (sender is not SslStream && sender is not string) + { + return true; + } + + if (sender is SslStream request) + { + targetHostName = request.TargetHostName; + } + + // Mailkit passes host in sender as string + if (sender is string stringHost) + { + targetHostName = stringHost; + } + + if (certificate is X509Certificate2 cert2 && cert2.SignatureAlgorithm.FriendlyName == "md5RSA") + { + _logger.LogError( + $"https://{targetHostName} uses the obsolete md5 hash in its https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible."); + } + + if (sslPolicyErrors == SslPolicyErrors.None) + { + return true; + } + + if (targetHostName is "localhost" or "127.0.0.1") + { + return true; + } + + var ipAddresses = GetIpAddresses(targetHostName); + + if (_config.CertificateValidation == CertificateValidationType.Disabled) + { + return true; + } + + if (_config.CertificateValidation == CertificateValidationType.DisabledForLocalAddresses && + ipAddresses.All(i => i.IsLocalAddress())) + { + return true; + } + + _logger.LogError($"certificate validation for {targetHostName} failed. {sslPolicyErrors}"); + + return false; + } + + private static IPAddress[] GetIpAddresses(string host) + { + if (IPAddress.TryParse(host, out var ipAddress)) + { + return [ipAddress]; + } + + return Dns.GetHostEntry(host).AddressList; + } +} \ No newline at end of file diff --git a/docs/src/components/configuration/GeneralSettings.tsx b/docs/src/components/configuration/GeneralSettings.tsx index 8a341add..7cf10676 100644 --- a/docs/src/components/configuration/GeneralSettings.tsx +++ b/docs/src/components/configuration/GeneralSettings.tsx @@ -81,6 +81,17 @@ const settings: EnvVarProps[] = [ type: "positive integer number", defaultValue: "100", required: false, + }, + { + name: "HTTP_VALIDATE_CERT", + description: [ + "Controls whether to validate SSL certificates for HTTPS connections.", + "Set to `Disabled` to ignore SSL certificate errors." + ], + type: "text", + defaultValue: "Enabled", + required: false, + acceptedValues: ["Enabled", "DisabledForLocalAddresses", "Disabled"], } ];