diff --git a/httplib.h b/httplib.h index a9b12c7..35bd320 100644 --- a/httplib.h +++ b/httplib.h @@ -21056,29 +21056,24 @@ inline bool verify_hostname(cert_t cert, const char *hostname) { 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 - // - DNS names: raw string bytes - // - IP addresses: raw IP bytes (4 for IPv4, 16 for IPv6) + // Check Subject Alternative Names (SAN). Mbed TLS keeps the GeneralName type + // in buf.tag and the raw value in buf.p / buf.len. const mbedtls_x509_sequence *san = &mcert->subject_alt_names; while (san != nullptr && san->buf.p != nullptr && san->buf.len > 0) { const unsigned char *p = san->buf.p; size_t len = san->buf.len; + auto san_type = san->buf.tag & MBEDTLS_ASN1_TAG_VALUE_MASK; if (is_ip) { // 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; - for (size_t i = 0; i < len && is_dns; i++) { - if (p[i] < 32 || p[i] > 126) { is_dns = false; } - } - if (is_dns) { - std::string san_name(reinterpret_cast(p), len); - if (detail::match_hostname(san_name, host_str)) { return true; } + if (san_type == MBEDTLS_X509_SAN_IP_ADDRESS && len == ip_len && + memcmp(p, ip_bytes, ip_len) == 0) { + return true; } + } else if (san_type == MBEDTLS_X509_SAN_DNS_NAME) { + std::string san_name(reinterpret_cast(p), len); + if (detail::match_hostname(san_name, host_str)) { return true; } } san = san->next; } @@ -21157,65 +21152,45 @@ inline bool get_cert_sans(cert_t cert, std::vector &sans) { const mbedtls_x509_sequence *cur = &x509->subject_alt_names; while (cur != nullptr) { if (cur->buf.len > 0) { - // Mbed TLS stores SAN as ASN.1 sequences - // The tag byte indicates the type const unsigned char *p = cur->buf.p; - size_t len = cur->buf.len; + size_t value_len = cur->buf.len; - // First byte is the tag - unsigned char tag = *p; - p++; - len--; - - // Parse length (simple single-byte length assumed) - if (len > 0 && *p < 0x80) { - size_t value_len = *p; - p++; - len--; - - if (value_len <= len) { - SanEntry entry; - // ASN.1 context tags for GeneralName - switch (tag & 0x1F) { - case 2: // dNSName - entry.type = SanType::DNS; - entry.value = - std::string(reinterpret_cast(p), value_len); - break; - case 7: // iPAddress - entry.type = SanType::IP; - if (value_len == 4) { - // IPv4 - char buf[16]; - snprintf(buf, sizeof(buf), "%d.%d.%d.%d", p[0], p[1], p[2], p[3]); - entry.value = buf; - } else if (value_len == 16) { - // IPv6 - char buf[64]; - snprintf(buf, sizeof(buf), - "%02x%02x:%02x%02x:%02x%02x:%02x%02x:" - "%02x%02x:%02x%02x:%02x%02x:%02x%02x", - p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8], - p[9], p[10], p[11], p[12], p[13], p[14], p[15]); - entry.value = buf; - } - break; - case 1: // rfc822Name (email) - entry.type = SanType::EMAIL; - entry.value = - std::string(reinterpret_cast(p), value_len); - break; - case 6: // uniformResourceIdentifier - entry.type = SanType::URI; - entry.value = - std::string(reinterpret_cast(p), value_len); - break; - default: entry.type = SanType::OTHER; break; - } - - if (!entry.value.empty()) { sans.push_back(std::move(entry)); } + SanEntry entry; + switch (cur->buf.tag & MBEDTLS_ASN1_TAG_VALUE_MASK) { + case MBEDTLS_X509_SAN_DNS_NAME: + entry.type = SanType::DNS; + entry.value = std::string(reinterpret_cast(p), value_len); + break; + case MBEDTLS_X509_SAN_IP_ADDRESS: + entry.type = SanType::IP; + if (value_len == 4) { + // IPv4 + char buf[16]; + snprintf(buf, sizeof(buf), "%d.%d.%d.%d", p[0], p[1], p[2], p[3]); + entry.value = buf; + } else if (value_len == 16) { + // IPv6 + char buf[64]; + snprintf(buf, sizeof(buf), + "%02x%02x:%02x%02x:%02x%02x:%02x%02x:" + "%02x%02x:%02x%02x:%02x%02x:%02x%02x", + p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7], p[8], p[9], + p[10], p[11], p[12], p[13], p[14], p[15]); + entry.value = buf; } + break; + case MBEDTLS_X509_SAN_RFC822_NAME: + entry.type = SanType::EMAIL; + entry.value = std::string(reinterpret_cast(p), value_len); + break; + case MBEDTLS_X509_SAN_UNIFORM_RESOURCE_IDENTIFIER: + entry.type = SanType::URI; + entry.value = std::string(reinterpret_cast(p), value_len); + break; + default: entry.type = SanType::OTHER; break; } + + if (!entry.value.empty()) { sans.push_back(std::move(entry)); } } cur = cur->next; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 06cb024..aad94b8 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -146,6 +146,13 @@ if(HTTPLIB_IS_USING_OPENSSL) WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMAND_ERROR_IS_FATAL ANY ) + # 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". + execute_process( + COMMAND ${OPENSSL_COMMAND} 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 + 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 c228d62..0f7ccdf 100755 --- a/test/gen-certs.sh +++ b/test/gen-certs.sh @@ -33,3 +33,7 @@ openssl req -x509 -key key.pem -sha256 -days 3650 -nodes -subj "/CN=127.0.0.1" - # 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 + +# 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 diff --git a/test/meson.build b/test/meson.build index c573994..317ec6c 100644 --- a/test/meson.build +++ b/test/meson.build @@ -137,6 +137,15 @@ cert_ipv6_pem = custom_target( command: [openssl, 'req', '-x509', '-key', '@INPUT@', '-sha256', '-days', '3650', '-nodes', '-subj', '/CN=::1', '-addext', 'subjectAltName=IP:2001:db8::1', '-out', '@OUTPUT@'] ) +# 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". +cert_san_types_pem = custom_target( + 'cert_san_types_pem', + input: key_pem, + output: 'cert_san_types.pem', + 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@'] +) + # 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) @@ -178,7 +187,8 @@ test( client_encrypted_pbes1_key_pem, client_encrypted_cert_pem, cert_ip_cn_pem, - cert_ipv6_pem + cert_ipv6_pem, + cert_san_types_pem ], workdir: meson.current_build_dir(), timeout: 300 diff --git a/test/test.cc b/test/test.cc index f187b09..f1decea 100644 --- a/test/test.cc +++ b/test/test.cc @@ -42,6 +42,7 @@ inline std::string u8_to_string(const char8_t *s) { #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_CERT_SAN_TYPES_FILE "./cert_san_types.pem" #define SERVER_PRIVATE_KEY_FILE "./key.pem" #define CA_CERT_FILE "./ca-bundle.crt" #define CLIENT_CA_CERT_FILE "./rootCA.cert.pem" @@ -14869,6 +14870,123 @@ TEST(SSLClientServerTest, TlsVerifyHostnameIpv6San) { EXPECT_FALSE(cn_ipv6_matched) << "An IPv6 host must not be authenticated via the certificate CN"; } + +// A SAN entry must only match a host of its own type: the bytes of the dNSName +// "a.zz" are also 97.46.122.122, and 42.46.122.122 reads as "*.zz". +TEST(SSLClientServerTest, TlsVerifyHostnameSanType) { + using namespace httplib::tls; + + // SANs: DNS:a.zz, IP:42.46.122.122 + SSLServer svr(SERVER_CERT_SAN_TYPES_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 dns_san_matched = false; + bool ip_san_matched = false; + bool ip_matched_via_dns_san = true; + bool dns_matched_via_ip_san = 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; + + dns_san_matched = ctx.check_hostname("a.zz"); + ip_san_matched = ctx.check_hostname("42.46.122.122"); + + ip_matched_via_dns_san = ctx.check_hostname("97.46.122.122"); + dns_matched_via_ip_san = ctx.check_hostname("b.zz"); + + 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(dns_san_matched) << "verify_hostname should match a dNSName SAN"; + EXPECT_TRUE(ip_san_matched) + << "verify_hostname should match an iPAddress SAN"; + EXPECT_FALSE(ip_matched_via_dns_san) + << "An IP host must not be authenticated via a dNSName SAN"; + EXPECT_FALSE(dns_matched_via_ip_san) + << "A DNS host must not be authenticated via an iPAddress SAN"; +} + +// sans() must report each SAN entry under its own type. +TEST(SSLClientServerTest, TlsCertSansEntryTypes) { + using namespace httplib::tls; + + // SANs: DNS:a.zz, IP:42.46.122.122 + SSLServer svr(SERVER_CERT_SAN_TYPES_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; + std::vector sans; + + 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; + + sans = ctx.sans(); + + return true; // Accept for the purpose of this test + }); + + cli.Get("/test"); + + ASSERT_TRUE(verify_callback_called) + << "Verify callback should have been called"; + + auto has_san = [&](SanType type, const std::string &value) { + return std::any_of(sans.begin(), sans.end(), [&](const SanEntry &san) { + return san.type == type && san.value == value; + }); + }; + + EXPECT_TRUE(has_san(SanType::DNS, "a.zz")) + << "sans() should report the dNSName SAN"; + EXPECT_TRUE(has_san(SanType::IP, "42.46.122.122")) + << "sans() should report the iPAddress SAN"; + + EXPECT_FALSE(has_san(SanType::IP, "97.46.122.122")) + << "sans() must not report the dNSName SAN as an address"; + EXPECT_FALSE(has_san(SanType::DNS, "*.zz")) + << "sans() must not report the iPAddress SAN as a DNS name"; +} #endif // mbedTLS-specific callback constructor test