Match a host name against subjectAltName only

OpenSSL 4.1 no longer falls back to the subject Common Name when it
checks a host name (RFC 9525), so SSLClientServerTest.TlsVerifyHostname
began failing in the Windows CI job that installs the newest OpenSSL 4.x.

Make every backend agree instead of following whichever OpenSSL is
installed. OpenSSL passes X509_CHECK_FLAG_NEVER_CHECK_SUBJECT to both the
handshake check and verify_hostname(), and Mbed TLS and wolfSSL drop their
own CN fallback. A certificate that names its host only in the CN is now
rejected on all backends, as it already was on BoringSSL.
This commit is contained in:
yhirose committed 2026-10-25 18:58:43 -04:00
1 parent 7d349cbeed
commit d23d1b6029
6 files changed
+79 -45

No files matched your search

+4 -1
View File
@@ -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
+11 -27
View File
@@ -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<cert_t> &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<SSL *>(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<const char *>(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<const char *>(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<WOLF_STACK_OF(WOLFSSL_GENERAL_NAME) *>(
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() {
+8
View File
@@ -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
+5
View File
@@ -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.
+11
View File
@@ -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
+40 -17
View File
@@ -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
// <openssl/base.h>, 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.