From 12a8a9ec62e7a940c6173161d0930d484642686f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Mon, 12 Oct 2026 23:19:25 +0100 Subject: [PATCH] httpclient: a certificate for ww*.example.com fails verification for www.example.com instead of matching it SSL_set1_host ran with OpenSSL's default host flags, which let a partial wildcard match: a certificate for 'ww*.example.com' authenticated 'www.example.com'. The client now sets X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS on each SSL after SSL_set1_host, as curl does; SslStream and Python refuse partial wildcards outright. A full wildcard ('*.example.com') still matches one label, and still not two. The pending test "tls client: a partial wildcard must not match (ww*.example.com is not www.example.com)" is now a Test. --- src/clients/ioxide.httpclient/Tls/OpenSsl.cs | 4 ++++ .../ioxide.httpclient/Tls/TlsClientStream.cs | 3 +++ .../TlsClientVerificationTests.cs | 14 ++++++-------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/clients/ioxide.httpclient/Tls/OpenSsl.cs b/src/clients/ioxide.httpclient/Tls/OpenSsl.cs index 6a03a66b..653e0d0e 100644 --- a/src/clients/ioxide.httpclient/Tls/OpenSsl.cs +++ b/src/clients/ioxide.httpclient/Tls/OpenSsl.cs @@ -31,6 +31,7 @@ internal static unsafe partial class OpenSsl public const int SSL_VERIFY_NONE = 0x00; public const int SSL_VERIFY_PEER = 0x01; public const long X509_V_OK = 0; + public const uint X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS = 0x4; // --- context --------------------------------------------------------------------------------- @@ -131,6 +132,9 @@ public static unsafe int Write(nint ssl, byte* buf, int num, out int error) [LibraryImport(Ssl, StringMarshalling = StringMarshalling.Utf8)] public static partial int SSL_set1_host(nint ssl, string hostname); + /// How 's name is matched: X509_CHECK_FLAG_* bits. + [LibraryImport(Ssl)] public static partial void SSL_set_hostflags(nint ssl, uint flags); + [LibraryImport(Ssl)] public static partial long SSL_get_verify_result(nint ssl); [LibraryImport(Ssl)] public static partial void SSL_get0_alpn_selected(nint ssl, byte** data, uint* length); diff --git a/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs b/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs index e9278cd3..b5da6943 100644 --- a/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs +++ b/src/clients/ioxide.httpclient/Tls/TlsClientStream.cs @@ -138,6 +138,9 @@ private static void ConfigureNames(nint ssl, TlsClientOptions options, string se { throw new IOException($"SSL_set1_host('{serverName}'): {OpenSsl.LastError()}"); } + + // 'ww*.example.com' must not match 'www.example.com': a partial wildcard names a prefix, not a host. + OpenSsl.SSL_set_hostflags(ssl, OpenSsl.X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); } private async ValueTask RunHandshakeAsync(TlsClientOptions options, string serverName) diff --git a/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs b/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs index 2a7d8646..de53b482 100644 --- a/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs +++ b/tests/Ioxide.Tests.Http/TlsClientVerificationTests.cs @@ -34,12 +34,12 @@ public static void Register(Runner runner) } /// - /// Wildcard matching, which the client never spells out: it calls SSL_set1_host and inherits - /// whatever OpenSSL's default hostflags mean this month. + /// Wildcard matching, which the client spells out rather than inheriting whatever OpenSSL's + /// default hostflags mean this month: SSL_set1_host with X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS. /// private static void RegisterWildcards(Runner runner) { - runner.Pending("tls client: a partial wildcard must not match (ww*.example.com is not www.example.com)", () => + runner.Test("tls client: a partial wildcard must not match (ww*.example.com is not www.example.com)", () => { // A certificate issued for 'ww*.example.com' is a certificate for a PREFIX, and the // holder of one for 'ww*' can speak for www, wwx and every other name that starts the @@ -55,15 +55,13 @@ private static void RegisterWildcards(Runner runner) $"a partial wildcard should not have authenticated www.example.com, got: {body}"); Assert.True(body.Contains("certificate verify failed"), $"should be refused as a verification failure, got: {body}"); - }, "SSL_set1_host runs with OpenSSL's default hostflags: X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS " - + "is never set, so 'ww*' matches 'www'. curl sets it, and SslStream and Python refuse " - + "partial wildcards outright"); + }); runner.Test("tls client: control - a full wildcard matches one label below it", () => { // The control for the case above, and the reason it is a control: the ONLY difference // is the two characters in front of the star. A '*.example.com' certificate is - // ordinary and has to keep working, so the pending test above cannot be satisfied by + // ordinary and has to keep working, so the test above cannot be satisfied by // refusing wildcards altogether. using Authority ca = Authority.Mint(); using X509Certificate2 leaf = ca.Leaf("CN=wildcard origin", dns: ["*.example.com"]); @@ -75,7 +73,7 @@ private static void RegisterWildcards(Runner runner) runner.Test("tls client: a wildcard does not match across a label", () => { // '*.example.com' covers one label, not a subtree - so it must not authenticate - // a.b.example.com. Pinned because the obvious wrong fix for the pending case above is + // a.b.example.com. Pinned because the obvious wrong fix for the partial case above is // to reach for the hostflags argument and pass the wrong constant: // X509_CHECK_FLAG_MULTI_LABEL_WILDCARDS is right next to the one that is wanted and // widens matching instead of narrowing it.