From 8f094fe2929d0ec9d57812dab5a965511adff2e2 Mon Sep 17 00:00:00 2001 From: metsw24-max Date: Thu, 8 Oct 2026 22:17:24 +0530 Subject: [PATCH] match a wildcard only in the leftmost label in match_hostname (#2619) * match a wildcard only in the leftmost label in match_hostname * Shorten the wildcard comment in match_hostname --------- Co-authored-by: yhirose --- httplib.h | 6 +++-- test/CMakeLists.txt | 9 ++++++++ test/gen-certs.sh | 5 ++++ test/meson.build | 13 ++++++++++- test/test.cc | 56 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 3 deletions(-) diff --git a/httplib.h b/httplib.h index 0a5fffe..2417970 100644 --- a/httplib.h +++ b/httplib.h @@ -10759,12 +10759,14 @@ inline bool match_hostname(const std::string &pattern, // Compare each component with wildcard support // Supports: "*" (full wildcard), "prefix*" (partial wildcard) // https://bugs.launchpad.net/ubuntu/+source/firefox-3.0/+bug/376484 + // Only the leftmost label may carry a wildcard (RFC 6125 6.4.3) auto itr = pattern_components.begin(); for (const auto &h : host_components) { auto &p = *itr; - if (!detail::case_ignore::equal(p, h) && p != "*") { + auto is_leftmost = itr == pattern_components.begin(); + if (!detail::case_ignore::equal(p, h) && !(is_leftmost && p == "*")) { bool partial_match = false; - if (!p.empty() && p[p.size() - 1] == '*') { + if (is_leftmost && !p.empty() && p[p.size() - 1] == '*') { const auto prefix_length = p.size() - 1; if (prefix_length == 0) { partial_match = true; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index aad94b8..63b96ab 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -153,6 +153,15 @@ if(HTTPLIB_IS_USING_OPENSSL) WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMAND_ERROR_IS_FATAL ANY ) + # cert_wildcard_san.pem: a leftmost wildcard next to one that is not + # leftmost. "*.leftmost.example.test" matches a single + # label; the wildcard in "www.*.example.test" must not be + # honoured. + execute_process( + COMMAND ${OPENSSL_COMMAND} req -x509 -key key.pem -sha256 -days 3650 -nodes -subj /CN=wildcard-san -addext subjectAltName=DNS:*.leftmost.example.test,DNS:www.*.example.test -out cert_wildcard_san.pem + WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + COMMAND_ERROR_IS_FATAL ANY + ) endif() add_subdirectory(fuzzing) diff --git a/test/gen-certs.sh b/test/gen-certs.sh index 0f7ccdf..631dd51 100755 --- a/test/gen-certs.sh +++ b/test/gen-certs.sh @@ -37,3 +37,8 @@ openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=::1" -addext # cert_san_types.pem: the bytes of each SAN read as the other type: # DNS:a.zz is 97.46.122.122, IP:42.46.122.122 is "*.zz". openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=san-types" -addext "subjectAltName=DNS:a.zz,IP:42.46.122.122" -out cert_san_types.pem + +# cert_wildcard_san.pem: a leftmost wildcard next to one that is not leftmost. +# "*.leftmost.example.test" matches a single label; the +# wildcard in "www.*.example.test" must not be honoured. +openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=wildcard-san" -addext "subjectAltName=DNS:*.leftmost.example.test,DNS:www.*.example.test" -out cert_wildcard_san.pem diff --git a/test/meson.build b/test/meson.build index 317ec6c..ce41940 100644 --- a/test/meson.build +++ b/test/meson.build @@ -146,6 +146,16 @@ cert_san_types_pem = custom_target( command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=san-types', '-addext', 'subjectAltName=DNS:a.zz,IP:42.46.122.122', '-out', '@OUTPUT@'] ) +# cert_wildcard_san.pem: a leftmost wildcard next to one that is not leftmost. +# "*.leftmost.example.test" matches a single label; the wildcard in +# "www.*.example.test" must not be honoured. +cert_wildcard_san_pem = custom_target( + 'cert_wildcard_san_pem', + input: key_pem, + output: 'cert_wildcard_san.pem', + command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=wildcard-san', '-addext', 'subjectAltName=DNS:*.leftmost.example.test,DNS:www.*.example.test', '-out', '@OUTPUT@'] +) + # Copy test files to the build directory configure_file(input: 'ca-bundle.crt', output: 'ca-bundle.crt', copy: true) configure_file(input: 'image.jpg', output: 'image.jpg', copy: true) @@ -188,7 +198,8 @@ test( client_encrypted_cert_pem, cert_ip_cn_pem, cert_ipv6_pem, - cert_san_types_pem + cert_san_types_pem, + cert_wildcard_san_pem ], workdir: meson.current_build_dir(), timeout: 300 diff --git a/test/test.cc b/test/test.cc index a3e0ada..b3af1fd 100644 --- a/test/test.cc +++ b/test/test.cc @@ -43,6 +43,7 @@ inline std::string u8_to_string(const char8_t *s) { #define SERVER_CERT_IP_CN_FILE "./cert_ip_cn.pem" #define SERVER_CERT_IPV6_FILE "./cert_ipv6.pem" #define SERVER_CERT_SAN_TYPES_FILE "./cert_san_types.pem" +#define SERVER_CERT_WILDCARD_SAN_FILE "./cert_wildcard_san.pem" #define SERVER_PRIVATE_KEY_FILE "./key.pem" #define CA_CERT_FILE "./ca-bundle.crt" #define CLIENT_CA_CERT_FILE "./rootCA.cert.pem" @@ -14940,6 +14941,61 @@ TEST(SSLClientServerTest, TlsVerifyHostnameSanType) { << "A DNS host must not be authenticated via an iPAddress SAN"; } +// RFC 6125 6.4.3: only the leftmost label of a dNSName may be a wildcard, so +// "www.*.example.test" names one host and not every host under example.test. +TEST(SSLClientServerTest, TlsVerifyHostnameWildcardLabel) { + using namespace httplib::tls; + + // SANs: DNS:*.leftmost.example.test, DNS:www.*.example.test + SSLServer svr(SERVER_CERT_WILDCARD_SAN_FILE, SERVER_PRIVATE_KEY_FILE); + ASSERT_TRUE(svr.is_valid()); + + svr.Get("/test", [](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + + auto port = svr.bind_to_any_port(HOST); + thread t([&]() { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + }); + svr.wait_until_ready(); + + bool verify_callback_called = false; + bool leftmost_wildcard_matched = false; + bool wildcard_spanned_labels = true; + bool inner_wildcard_matched = true; + + SSLClient cli(HOST, port); + cli.enable_server_certificate_verification(true); + cli.set_ca_cert_path(CA_CERT_FILE); + cli.set_connection_timeout(5); + + cli.set_server_certificate_verifier([&](const VerifyContext &ctx) -> bool { + verify_callback_called = true; + if (!ctx.cert) return false; + + leftmost_wildcard_matched = ctx.check_hostname("a.leftmost.example.test"); + + wildcard_spanned_labels = ctx.check_hostname("a.b.leftmost.example.test"); + inner_wildcard_matched = ctx.check_hostname("www.evil.example.test"); + + return true; // Accept for the purpose of this test + }); + + cli.Get("/test"); + + ASSERT_TRUE(verify_callback_called) + << "Verify callback should have been called"; + EXPECT_TRUE(leftmost_wildcard_matched) + << "A leftmost wildcard should match a single label"; + EXPECT_FALSE(wildcard_spanned_labels) + << "A wildcard label must not match more than one label"; + EXPECT_FALSE(inner_wildcard_matched) + << "A wildcard outside the leftmost label must not be honoured"; +} + // sans() must report each SAN entry under its own type. TEST(SSLClientServerTest, TlsCertSansEntryTypes) { using namespace httplib::tls;