From 4de92cb72484b3ca61181cacc759388bee0152d9 Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Fri, 16 Oct 2026 22:47:26 +0200 Subject: [PATCH 1/2] Fix Android HTTP certificate validation tests Use a valid CA to issue expired server certificates, keep custom trust stores nonempty, and expect Android partial-chain errors. Respond to unexpectedly accepted connections instead of leaving client retries to time out. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53bf74e1-a3c7-4334-b7da-ecd0331cd4df --- ...ttpClientHandlerTest.ServerCertificates.cs | 56 ++++++++++++------- .../tests/System/Net/Http/TestHelper.cs | 21 +++++-- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs index e41661190166b8..0aac4e8b14c377 100644 --- a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs +++ b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs @@ -14,6 +14,7 @@ using System.Runtime.InteropServices; using System.Security.Authentication; using System.Security.Authentication.ExtendedProtection; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; using Microsoft.DotNet.RemoteExecutor; @@ -222,15 +223,18 @@ public async Task UseCallback_CallbackThrowsException_ExceptionPropagatesAsBaseE [InlineData(true, false)] [InlineData(false, false)] [InlineData(false, true)] + [InlineData(true, true)] public async Task NoCallback_BadCertificate_ThrowsException(bool expired, bool wrongHostName) { - using X509Certificate2 certificate = TestHelper.CreateServerSelfSignedCertificate( - name: wrongHostName ? "not-localhost" : "localhost", expired: expired); + using X509Certificate2 rootCertificate = CreateCertificateAuthority(); using (HttpClientHandler handler = CreateHttpClientHandler(allowTlsResume: false)) using (HttpClient client = CreateHttpClient(handler)) { - bool customChainPolicy = ConfigureCertificateChainPolicy(handler, certificate, trustCertificate: expired || wrongHostName); + bool customChainPolicy = ConfigureCertificateChainPolicy(handler, rootCertificate); + using X509Certificate2 certificate = TestHelper.CreateServerCertificate( + customChainPolicy && (expired || wrongHostName) ? rootCertificate : null, + name: wrongHostName ? "not-localhost" : "localhost", expired: expired); await LoopbackServer.CreateClientAndServerAsync(async uri => { @@ -250,15 +254,8 @@ await LoopbackServer.CreateClientAndServerAsync(async uri => { try { - if (customChainPolicy || IsWinHttpHandler) - { - await server.AcceptConnectionAsync(_ => Task.CompletedTask); - } - else - { - // Native handlers may report generic errors, so acceptance must produce a successful response. - await server.HandleRequestAsync(); - } + // Unexpected certificate acceptance must fail the assertion, not trigger a retry against an idle listener. + await server.HandleRequestAsync(); } catch (Exception e) when (e is AuthenticationException or IOException) { @@ -329,15 +326,18 @@ public async Task NoCallback_RevokedCertificate_RevocationChecking_Fails() [InlineData(true, false, SslPolicyErrors.RemoteCertificateChainErrors)] [InlineData(false, false, SslPolicyErrors.RemoteCertificateChainErrors)] [InlineData(false, true, SslPolicyErrors.RemoteCertificateNameMismatch)] + [InlineData(true, true, SslPolicyErrors.RemoteCertificateChainErrors | SslPolicyErrors.RemoteCertificateNameMismatch)] public async Task UseCallback_BadCertificate_ExpectedPolicyErrors(bool expired, bool wrongHostName, SslPolicyErrors expectedErrors) { - using X509Certificate2 certificate = TestHelper.CreateServerSelfSignedCertificate( - name: wrongHostName ? "not-localhost" : "localhost", expired: expired); + using X509Certificate2 rootCertificate = CreateCertificateAuthority(); using (HttpClientHandler handler = CreateHttpClientHandler(allowTlsResume: false)) using (HttpClient client = CreateHttpClient(handler)) { - bool customChainPolicy = ConfigureCertificateChainPolicy(handler, certificate, trustCertificate: expired || wrongHostName); + bool customChainPolicy = ConfigureCertificateChainPolicy(handler, rootCertificate); + using X509Certificate2 certificate = TestHelper.CreateServerCertificate( + customChainPolicy && (expired || wrongHostName) ? rootCertificate : null, + name: wrongHostName ? "not-localhost" : "localhost", expired: expired); X509ChainStatusFlags expectedChainStatus = expired ? X509ChainStatusFlags.NotTimeValid : wrongHostName ? X509ChainStatusFlags.NoError : X509ChainStatusFlags.UntrustedRoot; @@ -348,6 +348,12 @@ public async Task UseCallback_BadCertificate_ExpectedPolicyErrors(bool expired, expectedChainStatus |= X509ChainStatusFlags.UntrustedRoot; } + if (PlatformDetection.IsAndroid && expectedChainStatus != X509ChainStatusFlags.NoError) + { + // Android cannot build a path through an expired or untrusted certificate. + expectedChainStatus = X509ChainStatusFlags.PartialChain; + } + string expectedThumbprint = certificate.Thumbprint; bool callbackCalled = false; @@ -380,7 +386,18 @@ await LoopbackServerFactory.CreateClientAndServerAsync(async uri => } } - private static bool ConfigureCertificateChainPolicy(HttpClientHandler handler, X509Certificate2 certificate, bool trustCertificate) + private static X509Certificate2 CreateCertificateAuthority() + { + using RSA key = RSA.Create(); + var request = new CertificateRequest("CN=Test Certificate Authority", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true)); + + DateTimeOffset now = DateTimeOffset.UtcNow; + return request.CreateSelfSigned(now.AddYears(-1), now.AddYears(1)); + } + + private static bool ConfigureCertificateChainPolicy(HttpClientHandler handler, X509Certificate2 rootCertificate) { #if !WINHTTPHANDLER_TEST if (GetUnderlyingSocketsHttpHandler(handler) is SocketsHttpHandler socketsHandler) @@ -390,13 +407,10 @@ private static bool ConfigureCertificateChainPolicy(HttpClientHandler handler, X TrustMode = X509ChainTrustMode.CustomRootTrust, RevocationMode = X509RevocationMode.NoCheck, DisableCertificateDownloads = true, + // Android requires a nonempty trust store and does not check a trust anchor's expiration. + CustomTrustStore = { rootCertificate }, }; - if (trustCertificate) - { - policy.CustomTrustStore.Add(certificate); - } - socketsHandler.SslOptions.CertificateChainPolicy = policy; return true; } diff --git a/src/libraries/Common/tests/System/Net/Http/TestHelper.cs b/src/libraries/Common/tests/System/Net/Http/TestHelper.cs index 929b3b4db42715..18801693c1ddc8 100644 --- a/src/libraries/Common/tests/System/Net/Http/TestHelper.cs +++ b/src/libraries/Common/tests/System/Net/Http/TestHelper.cs @@ -110,16 +110,19 @@ public static byte[] GenerateRandomContent(int size) } public static X509Certificate2 CreateServerSelfSignedCertificate(string name = "localhost", bool expired = false) + => CreateServerCertificate(null, name, expired); + + public static X509Certificate2 CreateServerCertificate(X509Certificate2 issuerCertificate, string name = "localhost", bool expired = false) { - using (RSA root = RSA.Create()) + using (RSA key = RSA.Create()) { CertificateRequest req = new CertificateRequest( $"CN={name}", - root, + key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - req.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(issuerCertificate is null, false, 0, true)); req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, false)); req.CertificateExtensions.Add( @@ -139,7 +142,17 @@ public static X509Certificate2 CreateServerSelfSignedCertificate(string name = " DateTimeOffset start = expired ? DateTimeOffset.UtcNow.AddMonths(-2) : DateTimeOffset.UtcNow.AddMinutes(-5); DateTimeOffset end = start.AddMonths(1); - X509Certificate2 cert = req.CreateSelfSigned(start, end); + X509Certificate2 cert; + if (issuerCertificate is null) + { + cert = req.CreateSelfSigned(start, end); + } + else + { + using X509Certificate2 publicCertificate = req.Create(issuerCertificate, start, end, Guid.NewGuid().ToByteArray()); + cert = publicCertificate.CopyWithPrivateKey(key); + } + if (PlatformDetection.IsWindows) { using (cert) From 4b2bbbf7b341c299a1180d6efc014f403595a58c Mon Sep 17 00:00:00 2001 From: MihaZupan Date: Sat, 17 Oct 2026 22:42:26 +0200 Subject: [PATCH 2/2] Mark HTTP server certificates as end entities Avoid InvalidBasicConstraints on stricter OpenSSL versions by not advertising server certificates as CAs without KeyCertSign. Add regression coverage for both self-signed and CA-issued end-entity certificates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53bf74e1-a3c7-4334-b7da-ecd0331cd4df --- .../Http/HttpClientHandlerTest.ServerCertificates.cs | 11 +++++++++++ .../Common/tests/System/Net/Http/TestHelper.cs | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs index 0aac4e8b14c377..9a8922ed5ff4ca 100644 --- a/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs +++ b/src/libraries/Common/tests/System/Net/Http/HttpClientHandlerTest.ServerCertificates.cs @@ -219,6 +219,17 @@ public async Task UseCallback_CallbackThrowsException_ExceptionPropagatesAsBaseE } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateServerCertificate_IsEndEntity(bool selfSigned) + { + using X509Certificate2 rootCertificate = CreateCertificateAuthority(); + using X509Certificate2 certificate = TestHelper.CreateServerCertificate(selfSigned ? null : rootCertificate); + + Assert.False(Assert.Single(certificate.Extensions.OfType()).CertificateAuthority); + } + [Theory] [InlineData(true, false)] [InlineData(false, false)] diff --git a/src/libraries/Common/tests/System/Net/Http/TestHelper.cs b/src/libraries/Common/tests/System/Net/Http/TestHelper.cs index 18801693c1ddc8..ab42ead7ecf533 100644 --- a/src/libraries/Common/tests/System/Net/Http/TestHelper.cs +++ b/src/libraries/Common/tests/System/Net/Http/TestHelper.cs @@ -122,7 +122,7 @@ public static X509Certificate2 CreateServerCertificate(X509Certificate2 issuerCe HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - req.CertificateExtensions.Add(new X509BasicConstraintsExtension(issuerCertificate is null, false, 0, true)); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, false)); req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DataEncipherment, false)); req.CertificateExtensions.Add(