From ba390f23994cad46ebcd1497414d22a2ebf736de Mon Sep 17 00:00:00 2001 From: yhirose Date: Thu, 18 Jun 2026 12:37:16 -0400 Subject: [PATCH] Restrict IP-host hostname verification to iPAddress SANs on Mbed TLS and wolfSSL An IP-literal host must only be authenticated via a matching iPAddress SAN, never via the certificate's Common Name (RFC 9110), as the OpenSSL backend already does through X509_check_ip. The Mbed TLS and wolfSSL backends instead fell back to the CN when no IP SAN matched, and recognized IPv4 only. This is a more complete solution for #2476, which gated the CN fallback for IPv4 hosts only; here the same gap is closed for IPv6 as well, and IPv6 iPAddress SANs are actually matched. - Add impl::parse_ip_address() to parse IPv4/IPv6 literals into raw bytes - Match IPv6 (16-byte) iPAddress SANs, not just IPv4 - Skip the CN fallback for IP-literal hosts (both IPv4 and IPv6) - Remove the unused SSLClient::verify_host* dead code - Add regression tests and test certificates for the IP-host cases --- httplib.h | 181 +++++++++++++--------------------------------- test/gen-certs.sh | 11 +++ test/test.cc | 104 ++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 129 deletions(-) diff --git a/httplib.h b/httplib.h index 8676668..a08b0f1 100644 --- a/httplib.h +++ b/httplib.h @@ -2878,13 +2878,6 @@ private: #endif friend class ClientImpl; - -#ifdef CPPHTTPLIB_OPENSSL_SUPPORT -private: - bool verify_host(X509 *server_cert) const; - bool verify_host_with_subject_alt_name(X509 *server_cert) const; - bool verify_host_with_common_name(X509 *server_cert) const; -#endif }; #endif // CPPHTTPLIB_SSL_ENABLED @@ -16526,6 +16519,21 @@ inline bool parse_ipv4(const std::string &str, unsigned char *out) { return *p == '\0'; } +// Parse an IP literal (IPv4 or IPv6) into raw network-order bytes. +// `out` must have room for at least 16 bytes. Returns the address length +// (4 for IPv4, 16 for IPv6) on success, or 0 if the string is not an IP +// literal. Used to match a host against iPAddress SANs the same way the +// OpenSSL backend does via X509_check_ip. +inline size_t parse_ip_address(const std::string &str, unsigned char *out) { + if (is_ipv4_address(str)) { return parse_ipv4(str, out) ? 4 : 0; } + struct in6_addr addr6 = {}; + if (inet_pton(AF_INET6, str.c_str(), &addr6) == 1) { + memcpy(out, &addr6, 16); + return 16; + } + return 0; +} + #ifdef _WIN32 // Enumerate Windows system certificates and call callback with DER data template @@ -17725,99 +17733,6 @@ inline std::string verify_error_string(long error_code) { } // namespace tls -inline bool SSLClient::verify_host(X509 *server_cert) const { - /* Quote from RFC2818 section 3.1 "Server Identity" - - If a subjectAltName extension of type dNSName is present, that MUST - be used as the identity. Otherwise, the (most specific) Common Name - field in the Subject field of the certificate MUST be used. Although - the use of the Common Name is existing practice, it is deprecated and - Certification Authorities are encouraged to use the dNSName instead. - - Matching is performed using the matching rules specified by - [RFC2459]. If more than one identity of a given type is present in - the certificate (e.g., more than one dNSName name, a match in any one - of the set is considered acceptable.) Names may contain the wildcard - character * which is considered to match any single domain name - component or component fragment. E.g., *.a.com matches foo.a.com but - not bar.foo.a.com. f*.com matches foo.com but not bar.com. - - In some cases, the URI is specified as an IP address rather than a - hostname. In this case, the iPAddress subjectAltName must be present - in the certificate and must exactly match the IP in the URI. - - */ - return verify_host_with_subject_alt_name(server_cert) || - verify_host_with_common_name(server_cert); -} - -inline bool -SSLClient::verify_host_with_subject_alt_name(X509 *server_cert) const { - auto ret = false; - - auto type = GEN_DNS; - - struct in6_addr addr6 = {}; - struct in_addr addr = {}; - size_t addr_len = 0; - -#ifndef __MINGW32__ - if (inet_pton(AF_INET6, host_.c_str(), &addr6)) { - type = GEN_IPADD; - addr_len = sizeof(struct in6_addr); - } else if (inet_pton(AF_INET, host_.c_str(), &addr)) { - type = GEN_IPADD; - addr_len = sizeof(struct in_addr); - } -#endif - - auto alt_names = static_cast( - X509_get_ext_d2i(server_cert, NID_subject_alt_name, nullptr, nullptr)); - - if (alt_names) { - auto dsn_matched = false; - auto ip_matched = false; - - auto count = sk_GENERAL_NAME_num(alt_names); - - for (decltype(count) i = 0; i < count && !dsn_matched; i++) { - auto val = sk_GENERAL_NAME_value(alt_names, i); - if (!val || val->type != type) { continue; } - - auto name = - reinterpret_cast(ASN1_STRING_get0_data(val->d.ia5)); - if (name == nullptr) { continue; } - - auto name_len = static_cast(ASN1_STRING_length(val->d.ia5)); - - switch (type) { - case GEN_DNS: - dsn_matched = - detail::match_hostname(std::string(name, name_len), host_); - break; - - case GEN_IPADD: - if (!memcmp(&addr6, name, addr_len) || !memcmp(&addr, name, addr_len)) { - ip_matched = true; - } - break; - } - } - - if (dsn_matched || ip_matched) { ret = true; } - } - - GENERAL_NAMES_free(const_cast( - reinterpret_cast(alt_names))); - return ret; -} - -inline bool SSLClient::verify_host_with_common_name(X509 *server_cert) const { - auto cn = tls::get_cert_subject_cn(static_cast(server_cert)); - if (cn.empty()) { return false; } - return detail::match_hostname(cn, host_); -} - #endif // CPPHTTPLIB_OPENSSL_SUPPORT /* @@ -18620,10 +18535,10 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { auto mcert = static_cast(cert); std::string host_str(hostname); - // Check if hostname is an IP address - bool is_ip = impl::is_ipv4_address(host_str); - unsigned char ip_bytes[4]; - if (is_ip) { impl::parse_ipv4(host_str, ip_bytes); } + // Check if hostname is an IP address (IPv4 or IPv6) + unsigned char ip_bytes[16]; + auto ip_len = impl::parse_ip_address(host_str, ip_bytes); + auto is_ip = ip_len > 0; // Check Subject Alternative Names (SAN) // In Mbed TLS 3.x, subject_alt_names contains raw values without ASN.1 tags @@ -18635,9 +18550,9 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { size_t len = san->buf.len; if (is_ip) { - // Check if this SAN is an IPv4 address (4 bytes) - if (len == 4 && memcmp(p, ip_bytes, 4) == 0) { return true; } - // Check if this SAN is an IPv6 address (16 bytes) - skip for now + // For an IP host, only a matching iPAddress SAN of the same family + // (4 bytes for IPv4, 16 bytes for IPv6) may authenticate it. + if (len == ip_len && memcmp(p, ip_bytes, ip_len) == 0) { return true; } } else { // Check if this SAN is a DNS name (printable ASCII string) bool is_dns = len > 0; @@ -18652,21 +18567,25 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { san = san->next; } - // Fallback: Check Common Name (CN) in subject - char cn[256]; - int ret = mbedtls_x509_dn_gets(cn, sizeof(cn), &mcert->subject); - if (ret > 0) { - std::string cn_str(cn); + // Fallback: Check Common Name (CN) in subject. Skipped for IP-literal hosts: + // an IP identity is only valid via an iPAddress SAN, never the CN (RFC 9110; + // the OpenSSL backend's X509_check_ip behaves the same way). + if (!is_ip) { + char cn[256]; + int ret = mbedtls_x509_dn_gets(cn, sizeof(cn), &mcert->subject); + if (ret > 0) { + std::string cn_str(cn); - // Look for "CN=" in the DN string - size_t cn_pos = cn_str.find("CN="); - if (cn_pos != std::string::npos) { - size_t start = cn_pos + 3; - size_t end = cn_str.find(',', start); - std::string cn_value = - cn_str.substr(start, end == std::string::npos ? end : end - start); + // Look for "CN=" in the DN string + size_t cn_pos = cn_str.find("CN="); + if (cn_pos != std::string::npos) { + size_t start = cn_pos + 3; + size_t end = cn_str.find(',', start); + std::string cn_value = + cn_str.substr(start, end == std::string::npos ? end : end - start); - if (detail::match_hostname(cn_value, host_str)) { return true; } + if (detail::match_hostname(cn_value, host_str)) { return true; } + } } } @@ -19772,10 +19691,10 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { auto x509 = static_cast(cert); std::string host_str(hostname); - // Check if hostname is an IP address - bool is_ip = impl::is_ipv4_address(host_str); - unsigned char ip_bytes[4]; - if (is_ip) { impl::parse_ipv4(host_str, ip_bytes); } + // Check if hostname is an IP address (IPv4 or IPv6) + unsigned char ip_bytes[16]; + auto ip_len = impl::parse_ip_address(host_str, ip_bytes); + auto is_ip = ip_len > 0; // Check Subject Alternative Names auto *san_names = static_cast( @@ -19802,10 +19721,12 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { } } } else if (is_ip && names->type == WOLFSSL_GEN_IPADD) { - // IP address + // IP address: only an iPAddress SAN of the same family (4 bytes for + // IPv4, 16 bytes for IPv6) may authenticate the host. unsigned char *ip_data = wolfSSL_ASN1_STRING_data(names->d.iPAddress); - int ip_len = wolfSSL_ASN1_STRING_length(names->d.iPAddress); - if (ip_data && ip_len == 4 && memcmp(ip_data, ip_bytes, 4) == 0) { + auto san_ip_len = wolfSSL_ASN1_STRING_length(names->d.iPAddress); + if (ip_data && san_ip_len == static_cast(ip_len) && + memcmp(ip_data, ip_bytes, ip_len) == 0) { wolfSSL_sk_free(san_names); return true; } @@ -19814,8 +19735,10 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { wolfSSL_sk_free(san_names); } - // Fallback: Check Common Name (CN) in subject - WOLFSSL_X509_NAME *subject = wolfSSL_X509_get_subject_name(x509); + // Fallback: Check Common Name (CN) in subject. Skipped for IP-literal hosts: + // an IP identity is only valid via an iPAddress SAN, never the CN (RFC 9110; + // the OpenSSL backend's X509_check_ip behaves the same way). + auto subject = is_ip ? nullptr : wolfSSL_X509_get_subject_name(x509); if (subject) { char cn[256] = {}; int cn_len = wolfSSL_X509_NAME_get_text_by_NID(subject, NID_commonName, cn, diff --git a/test/gen-certs.sh b/test/gen-certs.sh index bc91f84..b25c2f0 100755 --- a/test/gen-certs.sh +++ b/test/gen-certs.sh @@ -16,3 +16,14 @@ openssl genrsa -passout pass:test123! 2048 > key_encrypted.pem openssl req -new -batch -config test.conf -key key_encrypted.pem | openssl x509 -days 3650 -req -signkey key_encrypted.pem > cert_encrypted.pem openssl genrsa 2048 | openssl pkcs8 -topk8 -v1 PBE-SHA1-3DES -passout pass:test012! -out client_encrypted.key.pem openssl req -new -batch -config test.conf -key client_encrypted.key.pem -passin pass:test012! | openssl x509 -days 370 -req -CA rootCA.cert.pem -CAkey rootCA.key.pem -CAcreateserial > client_encrypted.cert.pem + +# Certificates for IP-host hostname verification regression tests. +# cert_ip_cn.pem: CN is an IPv4 literal with NO subjectAltName. An IP host must +# NOT be authenticated via the CN, so verifying it against this +# cert must fail. +openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=127.0.0.1" -out cert_ip_cn.pem + +# cert_ipv6.pem: CN is an IPv6 literal plus an IPv6 iPAddress SAN for a +# different address. The SAN address must match; the CN address +# must be ignored. +openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=::1" -addext "subjectAltName=IP:2001:db8::1" -out cert_ipv6.pem diff --git a/test/test.cc b/test/test.cc index 28e84b4..3444e85 100644 --- a/test/test.cc +++ b/test/test.cc @@ -38,6 +38,8 @@ 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_IPV6_FILE "./cert_ipv6.pem" #define SERVER_PRIVATE_KEY_FILE "./key.pem" #define CA_CERT_FILE "./ca-bundle.crt" #define CLIENT_CA_CERT_FILE "./rootCA.cert.pem" @@ -10939,6 +10941,108 @@ TEST(SSLClientServerTest, TlsVerifyHostname) { EXPECT_FALSE(verify_result_wrong) << "verify_hostname should not match 'wronghost.example.com'"; } + +// 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. +TEST(SSLClientServerTest, TlsVerifyHostnameIpNotMatchedByCommonName) { + using namespace httplib::tls; + + // Certificate CN is the IPv4 literal "127.0.0.1" and it carries no SAN. + SSLServer svr(SERVER_CERT_IP_CN_FILE, SERVER_PRIVATE_KEY_FILE); + ASSERT_TRUE(svr.is_valid()); + + svr.Get("/test", [](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + + thread t([&]() { svr.listen(HOST, PORT); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + }); + svr.wait_until_ready(); + + bool verify_callback_called = false; + bool ip_matched_via_cn = 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; + + // The IP appears only in the CN, so it must NOT be accepted. + ip_matched_via_cn = ctx.check_hostname("127.0.0.1"); + + return true; // Accept for the purpose of this test + }); + + cli.Get("/test"); + + ASSERT_TRUE(verify_callback_called) + << "Verify callback should have been called"; + EXPECT_FALSE(ip_matched_via_cn) + << "An IP host must not be authenticated via the certificate CN"; +} + +// IPv6 hosts must be matched against IPv6 iPAddress SANs (and only those). +TEST(SSLClientServerTest, TlsVerifyHostnameIpv6San) { + using namespace httplib::tls; + + // Certificate CN is "::1" and it carries an IPv6 SAN for "2001:db8::1". + SSLServer svr(SERVER_CERT_IPV6_FILE, SERVER_PRIVATE_KEY_FILE); + ASSERT_TRUE(svr.is_valid()); + + svr.Get("/test", [](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + + thread t([&]() { svr.listen(HOST, PORT); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + }); + svr.wait_until_ready(); + + bool verify_callback_called = false; + bool san_matched = false; + bool wrong_ipv6_matched = true; + bool cn_ipv6_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; + + // Matches the IPv6 iPAddress SAN. + san_matched = ctx.check_hostname("2001:db8::1"); + // A different IPv6 address must not match. + wrong_ipv6_matched = ctx.check_hostname("2001:db8::2"); + // "::1" lives only in the CN, so it must not be accepted. + cn_ipv6_matched = ctx.check_hostname("::1"); + + 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(san_matched) + << "verify_hostname should match an IPv6 iPAddress SAN"; + EXPECT_FALSE(wrong_ipv6_matched) + << "verify_hostname should not match a non-matching IPv6 address"; + EXPECT_FALSE(cn_ipv6_matched) + << "An IPv6 host must not be authenticated via the certificate CN"; +} #endif // mbedTLS-specific callback constructor test