mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-10-26 12:24:46 +00:00
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:
1 parent
7d349cbeed
commit
d23d1b6029
6 files changed
+79
-45
No files matched your search
@@ -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.
|
> 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]
|
> [!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++
|
```c++
|
||||||
// Use either OpenSSL, Mbed TLS, or wolfSSL
|
// Use either OpenSSL, Mbed TLS, or wolfSSL
|
||||||
|
|||||||
@@ -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().
|
// 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);
|
size_t get_peer_certs(const_session_t session, std::vector<cert_t> &certs);
|
||||||
void free_cert(cert_t cert);
|
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);
|
bool verify_hostname(cert_t cert, const char *hostname);
|
||||||
uint64_t hostname_mismatch_code();
|
uint64_t hostname_mismatch_code();
|
||||||
long get_verify_result(const_session_t session);
|
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);
|
auto ssl = static_cast<SSL *>(session);
|
||||||
|
|
||||||
// The handshake's chain verification then checks the name as well
|
// The handshake's chain verification then checks the name as well
|
||||||
if (verify_hostname &&
|
if (verify_hostname) {
|
||||||
X509_VERIFY_PARAM_set1_host(SSL_get0_param(ssl), hostname,
|
auto param = SSL_get0_param(ssl);
|
||||||
strlen(hostname)) != 1) {
|
X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NEVER_CHECK_SUBJECT);
|
||||||
return false;
|
if (X509_VERIFY_PARAM_set1_host(param, hostname, strlen(hostname)) != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if defined(OPENSSL_IS_BORINGSSL)
|
#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)) {
|
if (detail::is_ip_address(hostname)) {
|
||||||
return X509_check_ip_asc(x509, hostname, 0) == 1;
|
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() {
|
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
|
// Check Subject Alternative Names (SAN). Mbed TLS keeps the GeneralName type
|
||||||
// in buf.tag and the raw value in buf.p / buf.len.
|
// 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) {
|
for (auto san = &mcert->subject_alt_names; san; san = san->next) {
|
||||||
if (!san->buf.p || san->buf.len == 0) { continue; }
|
if (!san->buf.p || san->buf.len == 0) { continue; }
|
||||||
const unsigned char *p = san->buf.p;
|
const unsigned char *p = san->buf.p;
|
||||||
@@ -21850,24 +21854,11 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else if (san_type == MBEDTLS_X509_SAN_DNS_NAME) {
|
} else if (san_type == MBEDTLS_X509_SAN_DNS_NAME) {
|
||||||
has_dns_san = true;
|
|
||||||
std::string san_name(reinterpret_cast<const char *>(p), len);
|
std::string san_name(reinterpret_cast<const char *>(p), len);
|
||||||
if (detail::match_hostname(san_name, host_str)) { return true; }
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23015,7 +23006,6 @@ inline bool verify_hostname(cert_t cert, const char *hostname) {
|
|||||||
auto is_ip = ip_len > 0;
|
auto is_ip = ip_len > 0;
|
||||||
|
|
||||||
// Check Subject Alternative Names
|
// Check Subject Alternative Names
|
||||||
auto has_dns_san = false;
|
|
||||||
auto *san_names = static_cast<WOLF_STACK_OF(WOLFSSL_GENERAL_NAME) *>(
|
auto *san_names = static_cast<WOLF_STACK_OF(WOLFSSL_GENERAL_NAME) *>(
|
||||||
wolfSSL_X509_get_ext_d2i(x509, NID_subject_alt_name, nullptr, nullptr));
|
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) {
|
if (!is_ip && names->type == WOLFSSL_GEN_DNS) {
|
||||||
// DNS name
|
// DNS name
|
||||||
has_dns_san = true;
|
|
||||||
unsigned char *dns_name = nullptr;
|
unsigned char *dns_name = nullptr;
|
||||||
int dns_len = wolfSSL_ASN1_STRING_to_UTF8(&dns_name, names->d.dNSName);
|
int dns_len = wolfSSL_ASN1_STRING_to_UTF8(&dns_name, names->d.dNSName);
|
||||||
if (dns_name && dns_len > 0) {
|
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);
|
wolfSSL_sk_free(san_names);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: the Common Name (CN) in the subject, as X509_check_host does it:
|
return false;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inline uint64_t hostname_mismatch_code() {
|
inline uint64_t hostname_mismatch_code() {
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ if(HTTPLIB_IS_USING_OPENSSL)
|
|||||||
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
|
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
|
||||||
COMMAND_ERROR_IS_FATAL ANY
|
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
|
# cert_wildcard_san.pem: a leftmost wildcard next to one that is not
|
||||||
# leftmost. "*.leftmost.example.test" matches a single
|
# leftmost. "*.leftmost.example.test" matches a single
|
||||||
# label; the wildcard in "www.*.example.test" must not be
|
# label; the wildcard in "www.*.example.test" must not be
|
||||||
|
|||||||
@@ -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".
|
# 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
|
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.
|
# cert_wildcard_san.pem: a leftmost wildcard next to one that is not leftmost.
|
||||||
# "*.leftmost.example.test" matches a single label; the
|
# "*.leftmost.example.test" matches a single label; the
|
||||||
# wildcard in "www.*.example.test" must not be honoured.
|
# wildcard in "www.*.example.test" must not be honoured.
|
||||||
|
|||||||
@@ -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@']
|
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.
|
# cert_wildcard_san.pem: a leftmost wildcard next to one that is not leftmost.
|
||||||
# "*.leftmost.example.test" matches a single label; the wildcard in
|
# "*.leftmost.example.test" matches a single label; the wildcard in
|
||||||
# "www.*.example.test" must not be honoured.
|
# "www.*.example.test" must not be honoured.
|
||||||
@@ -197,6 +207,7 @@ test(
|
|||||||
client_encrypted_pbes1_key_pem,
|
client_encrypted_pbes1_key_pem,
|
||||||
client_encrypted_cert_pem,
|
client_encrypted_cert_pem,
|
||||||
cert_ip_cn_pem,
|
cert_ip_cn_pem,
|
||||||
|
cert_dns_cn_pem,
|
||||||
cert_ipv6_pem,
|
cert_ipv6_pem,
|
||||||
cert_san_types_pem,
|
cert_san_types_pem,
|
||||||
cert_wildcard_san_pem
|
cert_wildcard_san_pem
|
||||||
|
|||||||
+40
-17
@@ -41,6 +41,7 @@ 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_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_IPV6_FILE "./cert_ipv6.pem"
|
||||||
#define SERVER_CERT_SAN_TYPES_FILE "./cert_san_types.pem"
|
#define SERVER_CERT_SAN_TYPES_FILE "./cert_san_types.pem"
|
||||||
#define SERVER_CERT_WILDCARD_SAN_FILE "./cert_wildcard_san.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);
|
ASSERT_EQ(StatusCode::OK_200, res->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test verify_hostname for both OpenSSL and MbedTLS backends
|
// Test verify_hostname on every backend: a host is named by a SAN only
|
||||||
// Verifies that wildcard matching and exact matching work consistently
|
|
||||||
TEST(SSLClientServerTest, TlsVerifyHostname) {
|
TEST(SSLClientServerTest, TlsVerifyHostname) {
|
||||||
using namespace httplib::tls;
|
using namespace httplib::tls;
|
||||||
|
|
||||||
@@ -16724,7 +16724,7 @@ TEST(SSLClientServerTest, TlsVerifyHostname) {
|
|||||||
|
|
||||||
if (!ctx.cert) return false;
|
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");
|
verify_result_cn = ctx.check_hostname("Common Name");
|
||||||
|
|
||||||
// Test 2: wrong hostname should not match
|
// Test 2: wrong hostname should not match
|
||||||
@@ -16740,27 +16740,50 @@ TEST(SSLClientServerTest, TlsVerifyHostname) {
|
|||||||
ASSERT_TRUE(verify_callback_called)
|
ASSERT_TRUE(verify_callback_called)
|
||||||
<< "Verify callback should have been 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)
|
EXPECT_FALSE(verify_result_cn)
|
||||||
<< "BoringSSL should reject CN-based hostname matching (SAN-only)";
|
<< "verify_hostname must not match the certificate CN (no SAN)";
|
||||||
#else
|
|
||||||
EXPECT_TRUE(verify_result_cn)
|
|
||||||
<< "verify_hostname should match 'Common Name' (certificate CN)";
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Wrong hostname should not match
|
// Wrong hostname should not match
|
||||||
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'";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// the certificate's Common Name (RFC 9110). This mirrors the OpenSSL backend's
|
||||||
// X509_check_ip behavior and must hold for every backend.
|
// X509_check_ip behavior and must hold for every backend.
|
||||||
|
|||||||
Reference in new issue
Block a user