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
This commit is contained in:
yhirose
2026-06-18 12:37:16 -04:00
parent 7307c41aa8
commit ba390f2399
3 changed files with 167 additions and 129 deletions
+52 -129
View File
@@ -2878,13 +2878,6 @@ private:
#endif #endif
friend class ClientImpl; 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 #endif // CPPHTTPLIB_SSL_ENABLED
@@ -16526,6 +16519,21 @@ inline bool parse_ipv4(const std::string &str, unsigned char *out) {
return *p == '\0'; 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 #ifdef _WIN32
// Enumerate Windows system certificates and call callback with DER data // Enumerate Windows system certificates and call callback with DER data
template <typename Callback> template <typename Callback>
@@ -17725,99 +17733,6 @@ inline std::string verify_error_string(long error_code) {
} // namespace tls } // 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<const struct stack_st_GENERAL_NAME *>(
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<const char *>(ASN1_STRING_get0_data(val->d.ia5));
if (name == nullptr) { continue; }
auto name_len = static_cast<size_t>(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<STACK_OF(GENERAL_NAME) *>(
reinterpret_cast<const STACK_OF(GENERAL_NAME) *>(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<tls::cert_t>(server_cert));
if (cn.empty()) { return false; }
return detail::match_hostname(cn, host_);
}
#endif // CPPHTTPLIB_OPENSSL_SUPPORT #endif // CPPHTTPLIB_OPENSSL_SUPPORT
/* /*
@@ -18620,10 +18535,10 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
auto mcert = static_cast<const mbedtls_x509_crt *>(cert); auto mcert = static_cast<const mbedtls_x509_crt *>(cert);
std::string host_str(hostname); std::string host_str(hostname);
// Check if hostname is an IP address // Check if hostname is an IP address (IPv4 or IPv6)
bool is_ip = impl::is_ipv4_address(host_str); unsigned char ip_bytes[16];
unsigned char ip_bytes[4]; auto ip_len = impl::parse_ip_address(host_str, ip_bytes);
if (is_ip) { impl::parse_ipv4(host_str, ip_bytes); } auto is_ip = ip_len > 0;
// Check Subject Alternative Names (SAN) // Check Subject Alternative Names (SAN)
// In Mbed TLS 3.x, subject_alt_names contains raw values without ASN.1 tags // 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; size_t len = san->buf.len;
if (is_ip) { if (is_ip) {
// Check if this SAN is an IPv4 address (4 bytes) // For an IP host, only a matching iPAddress SAN of the same family
if (len == 4 && memcmp(p, ip_bytes, 4) == 0) { return true; } // (4 bytes for IPv4, 16 bytes for IPv6) may authenticate it.
// Check if this SAN is an IPv6 address (16 bytes) - skip for now if (len == ip_len && memcmp(p, ip_bytes, ip_len) == 0) { return true; }
} else { } else {
// Check if this SAN is a DNS name (printable ASCII string) // Check if this SAN is a DNS name (printable ASCII string)
bool is_dns = len > 0; bool is_dns = len > 0;
@@ -18652,21 +18567,25 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
san = san->next; san = san->next;
} }
// Fallback: Check Common Name (CN) in subject // Fallback: Check Common Name (CN) in subject. Skipped for IP-literal hosts:
char cn[256]; // an IP identity is only valid via an iPAddress SAN, never the CN (RFC 9110;
int ret = mbedtls_x509_dn_gets(cn, sizeof(cn), &mcert->subject); // the OpenSSL backend's X509_check_ip behaves the same way).
if (ret > 0) { if (!is_ip) {
std::string cn_str(cn); 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 // Look for "CN=" in the DN string
size_t cn_pos = cn_str.find("CN="); size_t cn_pos = cn_str.find("CN=");
if (cn_pos != std::string::npos) { if (cn_pos != std::string::npos) {
size_t start = cn_pos + 3; size_t start = cn_pos + 3;
size_t end = cn_str.find(',', start); size_t end = cn_str.find(',', start);
std::string cn_value = std::string cn_value =
cn_str.substr(start, end == std::string::npos ? end : end - start); 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<WOLFSSL_X509 *>(cert); auto x509 = static_cast<WOLFSSL_X509 *>(cert);
std::string host_str(hostname); std::string host_str(hostname);
// Check if hostname is an IP address // Check if hostname is an IP address (IPv4 or IPv6)
bool is_ip = impl::is_ipv4_address(host_str); unsigned char ip_bytes[16];
unsigned char ip_bytes[4]; auto ip_len = impl::parse_ip_address(host_str, ip_bytes);
if (is_ip) { impl::parse_ipv4(host_str, ip_bytes); } auto is_ip = ip_len > 0;
// Check Subject Alternative Names // Check Subject Alternative Names
auto *san_names = static_cast<WOLF_STACK_OF(WOLFSSL_GENERAL_NAME) *>( auto *san_names = static_cast<WOLF_STACK_OF(WOLFSSL_GENERAL_NAME) *>(
@@ -19802,10 +19721,12 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
} }
} }
} else if (is_ip && names->type == WOLFSSL_GEN_IPADD) { } 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); unsigned char *ip_data = wolfSSL_ASN1_STRING_data(names->d.iPAddress);
int ip_len = wolfSSL_ASN1_STRING_length(names->d.iPAddress); auto san_ip_len = wolfSSL_ASN1_STRING_length(names->d.iPAddress);
if (ip_data && ip_len == 4 && memcmp(ip_data, ip_bytes, 4) == 0) { if (ip_data && san_ip_len == static_cast<int>(ip_len) &&
memcmp(ip_data, ip_bytes, ip_len) == 0) {
wolfSSL_sk_free(san_names); wolfSSL_sk_free(san_names);
return true; return true;
} }
@@ -19814,8 +19735,10 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
wolfSSL_sk_free(san_names); wolfSSL_sk_free(san_names);
} }
// Fallback: Check Common Name (CN) in subject // Fallback: Check Common Name (CN) in subject. Skipped for IP-literal hosts:
WOLFSSL_X509_NAME *subject = wolfSSL_X509_get_subject_name(x509); // 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) { if (subject) {
char cn[256] = {}; char cn[256] = {};
int cn_len = wolfSSL_X509_NAME_get_text_by_NID(subject, NID_commonName, cn, int cn_len = wolfSSL_X509_NAME_get_text_by_NID(subject, NID_commonName, cn,
+11
View File
@@ -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 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 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 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
+104
View File
@@ -38,6 +38,8 @@ inline std::string u8_to_string(const char8_t *s) {
#define SERVER_CERT_FILE "./cert.pem" #define SERVER_CERT_FILE "./cert.pem"
#define SERVER_CERT2_FILE "./cert2.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 SERVER_PRIVATE_KEY_FILE "./key.pem"
#define CA_CERT_FILE "./ca-bundle.crt" #define CA_CERT_FILE "./ca-bundle.crt"
#define CLIENT_CA_CERT_FILE "./rootCA.cert.pem" #define CLIENT_CA_CERT_FILE "./rootCA.cert.pem"
@@ -10939,6 +10941,108 @@ TEST(SSLClientServerTest, TlsVerifyHostname) {
EXPECT_FALSE(verify_result_wrong) EXPECT_FALSE(verify_result_wrong)
<< "verify_hostname should not match 'wronghost.example.com'"; << "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 #endif
// mbedTLS-specific callback constructor test // mbedTLS-specific callback constructor test