diff --git a/README.md b/README.md index a03fa300..a771cc09 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,10 @@ cpp-httplib supports multiple TLS backends through an abstraction layer: > A wolfSSL server that verifies client certificates does not resume TLS sessions: wolfSSL shares its session cache between contexts, so a resumed session could carry a certificate checked against another server's client CA. > [!NOTE] -> **BoringSSL (best-effort):** BoringSSL builds under `CPPHTTPLIB_OPENSSL_SUPPORT` and is exercised by CI against current upstream. Because BoringSSL does not guarantee API stability, support is best-effort — breakage may occasionally land. Two known behavioral differences vs OpenSSL: (1) BoringSSL's public headers require C++14 or later, so consumers must compile accordingly; (2) hostname verification is SAN-only per RFC 6125 §6.4.4 (no CN fallback). +> **BoringSSL (best-effort):** BoringSSL builds under `CPPHTTPLIB_OPENSSL_SUPPORT` and is exercised by CI against current upstream. Because BoringSSL does not guarantee API stability, support is best-effort — breakage may occasionally land. One known difference vs OpenSSL: BoringSSL's public headers require C++14 or later, so consumers must compile accordingly. + +> [!NOTE] +> **Hostname verification is SAN-only on every backend** (RFC 9525). A host is matched against the certificate's subjectAltName entries, never its Common Name, so a certificate that names its host only in the CN is rejected. Reissue it with a subjectAltName. ```c++ // Use either OpenSSL, Mbed TLS, or wolfSSL diff --git a/httplib.h b/httplib.h index a89a969b..b2edc886 100644 --- a/httplib.h +++ b/httplib.h @@ -5344,6 +5344,8 @@ cert_t get_peer_cert(const_session_t session); // do not use them after free_session(), as with get_peer_cert(). size_t get_peer_certs(const_session_t session, std::vector &certs); void free_cert(cert_t cert); +// Matches the host against the subjectAltName entries only, never the Common +// Name (RFC 9525). bool verify_hostname(cert_t cert, const char *hostname); uint64_t hostname_mismatch_code(); long get_verify_result(const_session_t session); @@ -20197,10 +20199,12 @@ inline bool set_sni(session_t session, const char *hostname, auto ssl = static_cast(session); // The handshake's chain verification then checks the name as well - if (verify_hostname && - X509_VERIFY_PARAM_set1_host(SSL_get0_param(ssl), hostname, - strlen(hostname)) != 1) { - return false; + if (verify_hostname) { + auto param = SSL_get0_param(ssl); + X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NEVER_CHECK_SUBJECT); + if (X509_VERIFY_PARAM_set1_host(param, hostname, strlen(hostname)) != 1) { + return false; + } } #if defined(OPENSSL_IS_BORINGSSL) @@ -20449,7 +20453,8 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { if (detail::is_ip_address(hostname)) { return X509_check_ip_asc(x509, hostname, 0) == 1; } - return X509_check_host(x509, hostname, strlen(hostname), 0, nullptr) == 1; + return X509_check_host(x509, hostname, strlen(hostname), + X509_CHECK_FLAG_NEVER_CHECK_SUBJECT, nullptr) == 1; } inline uint64_t hostname_mismatch_code() { @@ -21835,7 +21840,6 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { // Check Subject Alternative Names (SAN). Mbed TLS keeps the GeneralName type // in buf.tag and the raw value in buf.p / buf.len. - auto has_dns_san = false; for (auto san = &mcert->subject_alt_names; san; san = san->next) { if (!san->buf.p || san->buf.len == 0) { continue; } const unsigned char *p = san->buf.p; @@ -21850,24 +21854,11 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { return true; } } else if (san_type == MBEDTLS_X509_SAN_DNS_NAME) { - has_dns_san = true; std::string san_name(reinterpret_cast(p), len); if (detail::match_hostname(san_name, host_str)) { return true; } } } - // Fallback: the Common Names (CN) in the subject, as X509_check_host does it: - // never for an IP host, which only an iPAddress SAN names, and never next to - // a DNS SAN (RFC 6125 6.4.4). - if (is_ip || has_dns_san) { return false; } - for (auto name = &mcert->subject; name; name = name->next) { - if (MBEDTLS_OID_CMP(MBEDTLS_OID_AT_CN, &name->oid) == 0) { - std::string cn(reinterpret_cast(name->val.p), - name->val.len); - if (detail::match_hostname(cn, host_str)) { return true; } - } - } - return false; } @@ -23015,7 +23006,6 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { auto is_ip = ip_len > 0; // Check Subject Alternative Names - auto has_dns_san = false; auto *san_names = static_cast( wolfSSL_X509_get_ext_d2i(x509, NID_subject_alt_name, nullptr, nullptr)); @@ -23028,7 +23018,6 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { if (!is_ip && names->type == WOLFSSL_GEN_DNS) { // DNS name - has_dns_san = true; unsigned char *dns_name = nullptr; int dns_len = wolfSSL_ASN1_STRING_to_UTF8(&dns_name, names->d.dNSName); if (dns_name && dns_len > 0) { @@ -23055,12 +23044,7 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { wolfSSL_sk_free(san_names); } - // Fallback: the Common Name (CN) in the subject, as X509_check_host does it: - // never for an IP host, which only an iPAddress SAN names, and never next to - // a DNS SAN (RFC 6125 6.4.4). - if (is_ip || has_dns_san) { return false; } - auto cn = get_cert_subject_cn(cert); - return !cn.empty() && detail::match_hostname(cn, host_str); + return false; } inline uint64_t hostname_mismatch_code() { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 63b96ab3..ab9fe750 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -153,6 +153,14 @@ if(HTTPLIB_IS_USING_OPENSSL) WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMAND_ERROR_IS_FATAL ANY ) + # cert_dns_cn.pem: CN is the DNS name the tests connect to, with NO + # subjectAltName. A host is named by a SAN only (RFC 9525), + # so verifying it against this cert must fail. + execute_process( + COMMAND ${OPENSSL_COMMAND} req -x509 -key key.pem -sha256 -days 3650 -nodes -subj /CN=localhost -out cert_dns_cn.pem + 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 diff --git a/test/gen-certs.sh b/test/gen-certs.sh index 631dd518..cd3a1209 100755 --- a/test/gen-certs.sh +++ b/test/gen-certs.sh @@ -38,6 +38,11 @@ openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=::1" -addext # 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_dns_cn.pem: CN is the DNS name the tests connect to, with NO +# subjectAltName. A host is named by a SAN only (RFC 9525), so +# verifying it against this cert must fail. +openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=localhost" -out cert_dns_cn.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. diff --git a/test/meson.build b/test/meson.build index ce41940c..8d027ece 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_dns_cn.pem: CN is the DNS name the tests connect to, with NO +# subjectAltName. A host is named by a SAN only (RFC 9525), so verifying it +# against this cert must fail. +cert_dns_cn_pem = custom_target( + 'cert_dns_cn_pem', + input: key_pem, + output: 'cert_dns_cn.pem', + command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=localhost', '-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. @@ -197,6 +207,7 @@ test( client_encrypted_pbes1_key_pem, client_encrypted_cert_pem, cert_ip_cn_pem, + cert_dns_cn_pem, cert_ipv6_pem, cert_san_types_pem, cert_wildcard_san_pem diff --git a/test/test.cc b/test/test.cc index c11c15f8..04cd17fe 100644 --- a/test/test.cc +++ b/test/test.cc @@ -41,6 +41,7 @@ inline std::string u8_to_string(const char8_t *s) { #define SERVER_CERT_FILE "./cert.pem" #define SERVER_CERT2_FILE "./cert2.pem" #define SERVER_CERT_IP_CN_FILE "./cert_ip_cn.pem" +#define SERVER_CERT_DNS_CN_FILE "./cert_dns_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" @@ -16689,8 +16690,7 @@ TEST(SSLClientServerTest, CustomizeServerSSLCtxGeneric) { ASSERT_EQ(StatusCode::OK_200, res->status); } -// Test verify_hostname for both OpenSSL and MbedTLS backends -// Verifies that wildcard matching and exact matching work consistently +// Test verify_hostname on every backend: a host is named by a SAN only TEST(SSLClientServerTest, TlsVerifyHostname) { using namespace httplib::tls; @@ -16724,7 +16724,7 @@ TEST(SSLClientServerTest, TlsVerifyHostname) { if (!ctx.cert) return false; - // Test 1: "Common Name" should match (our test server cert CN) + // Test 1: "Common Name" is the CN of our test server cert, which has no SAN verify_result_cn = ctx.check_hostname("Common Name"); // Test 2: wrong hostname should not match @@ -16740,27 +16740,50 @@ TEST(SSLClientServerTest, TlsVerifyHostname) { ASSERT_TRUE(verify_callback_called) << "Verify callback should have been called"; - // CN="Common Name" should match our test certificate - // - // BoringSSL intentionally drops CN-based hostname matching per RFC 6125 - // §6.4.4 — only SubjectAltName is consulted. Other backends (OpenSSL, - // MbedTLS, wolfSSL) still honor the CN fallback, so flip the expectation - // for BoringSSL builds. OPENSSL_IS_BORINGSSL is defined by BoringSSL's - // , which is included transitively when - // CPPHTTPLIB_OPENSSL_SUPPORT is set against a BoringSSL install. -#if defined(OPENSSL_IS_BORINGSSL) EXPECT_FALSE(verify_result_cn) - << "BoringSSL should reject CN-based hostname matching (SAN-only)"; -#else - EXPECT_TRUE(verify_result_cn) - << "verify_hostname should match 'Common Name' (certificate CN)"; -#endif + << "verify_hostname must not match the certificate CN (no SAN)"; // Wrong hostname should not match EXPECT_FALSE(verify_result_wrong) << "verify_hostname should not match 'wronghost.example.com'"; } +// A DNS name in the CN must not authenticate a host (RFC 9525), whichever +// backend verifies it. +TEST(SSLClientServerTest, TlsVerifyHostnameDnsNotMatchedByCommonName) { + // Certificate CN is "localhost" and it carries no SAN. + SSLServer svr(SERVER_CERT_DNS_CN_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(); + + SSLClient cli("localhost", port); + cli.set_ca_cert_path(SERVER_CERT_DNS_CN_FILE); + cli.enable_server_certificate_verification(true); + cli.set_connection_timeout(5); + + auto res = cli.Get("/test"); + ASSERT_FALSE(res); + EXPECT_EQ(Error::SSLServerHostnameVerification, res.error()); + + // The same certificate is accepted once host name verification is off, so the + // failure above is the host name and not the chain. + cli.enable_server_hostname_verification(false); + res = cli.Get("/test"); + ASSERT_TRUE(res) << to_string(res.error()); + EXPECT_EQ(StatusCode::OK_200, res->status); +} + // An IP-literal host must only be authenticated via an iPAddress SAN, never via // the certificate's Common Name (RFC 9110). This mirrors the OpenSSL backend's // X509_check_ip behavior and must hold for every backend.