diff --git a/httplib.h b/httplib.h index 996d730..db2c5c9 100644 --- a/httplib.h +++ b/httplib.h @@ -3233,8 +3233,6 @@ private: // Used to keep custom CA configuration exclusive with system CA loading. bool ca_cert_store_set_ = false; - long verify_result_ = 0; - std::function session_verifier_; #ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE @@ -4775,7 +4773,6 @@ void set_verify_client(ctx_t ctx, bool require); session_t create_session(ctx_t ctx, socket_t sock); void free_session(session_t session); bool set_sni(session_t session, const char *hostname); -bool set_hostname(session_t session, const char *hostname); // Handshake (non-blocking capable) TlsError connect(session_t session); @@ -10026,50 +10023,143 @@ inline bool load_client_ca_config(tls::ctx_t ctx, return ret; } -inline bool setup_client_tls_session(const std::string &host, tls::ctx_t ctx, - tls::session_t &session, socket_t sock, - bool server_certificate_verification, - time_t timeout_sec, time_t timeout_usec) { +// The parts of session setup that only SSLClient needs. WebSocketClient takes +// the defaults, which is what keeps the two clients on one implementation. +struct ClientTlsSessionOptions { + // SSLClient exposes this independently of certificate verification; + // WebSocketClient always checks the identity when it verifies the chain. + bool server_hostname_verification = true; + std::function session_verifier; + // When non-null, guards session creation against concurrent use of the + // context. A WebSocketClient is not safe to use from several threads to + // begin with, so it passes nothing. + std::mutex *ctx_mutex = nullptr; +#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE + // The caller decides whether Schannel has anything to say about this + // connection; see SSLClient::initialize_ssl(). + bool windows_cert_verification = false; +#endif +}; + +// Filled in on failure for callers that report error details. +struct ClientTlsSessionError { + Error error = Error::Success; + int ssl_error = 0; + uint64_t backend_error = 0; +}; + +// Establishes a client TLS session on an already connected socket. On failure +// the session is left for the caller to free: SSLClient frees it right away, +// WebSocketClient keeps it in a member that shutdown_and_close() cleans up. +inline bool setup_client_tls_session( + const std::string &host, tls::ctx_t ctx, tls::session_t &session, + socket_t sock, bool server_certificate_verification, time_t timeout_sec, + time_t timeout_usec, ClientTlsSessionError *out_error = nullptr, + const ClientTlsSessionOptions &options = ClientTlsSessionOptions()) { using namespace tls; - if (!ctx) { return false; } + auto fail = [&](Error error, int ssl_error, uint64_t backend_error) { + if (out_error) { + out_error->error = error; + out_error->ssl_error = ssl_error; + out_error->backend_error = backend_error; + } + return false; + }; - bool is_ip = is_ip_address(host); + if (!ctx) { + session = nullptr; + return fail(Error::SSLConnection, 0, 0); + } #if defined(CPPHTTPLIB_MBEDTLS_SUPPORT) || defined(CPPHTTPLIB_WOLFSSL_SUPPORT) - // Chain verification happens during the handshake even for IP hosts; the - // certificate identity is verified post-handshake via verify_hostname() + // Mbed TLS and wolfSSL need the verification mode set explicitly; OpenSSL + // uses SSL_VERIFY_NONE and does all verification post-handshake. Chain + // verification happens during the handshake even for IP hosts; the + // certificate identity is verified post-handshake via verify_hostname(). set_verify_client(ctx, server_certificate_verification); #endif - session = create_session(ctx, sock); - if (!session) { return false; } + { + std::unique_lock guard; + if (options.ctx_mutex) { + guard = std::unique_lock(*options.ctx_mutex); + } + session = create_session(ctx, sock); + } + if (!session) { return fail(Error::SSLConnection, 0, get_error()); } // RFC 6066: SNI must not be set for IP addresses. On Mbed TLS and wolfSSL - // set_hostname also sets SNI, so it must be skipped for IP hosts as well; - // their identity is checked post-handshake below instead. - if (!is_ip) { - if (server_certificate_verification) { - set_hostname(session, host.c_str()); - } else { - set_sni(session, host.c_str()); + // set_sni also turns on hostname verification during the handshake, so it + // must be skipped for IP hosts as well; their identity is checked + // post-handshake below instead. + if (!is_ip_address(host)) { + if (!set_sni(session, host.c_str())) { + return fail(Error::SSLConnection, 0, get_error()); } } - if (!connect_nonblocking(session, sock, timeout_sec, timeout_usec, nullptr)) { - return false; + TlsError tls_err; + if (!connect_nonblocking(session, sock, timeout_sec, timeout_usec, + &tls_err)) { + auto error = Error::SSLConnection; + if (tls_err.code == ErrorCode::CertVerifyFailed) { + error = Error::SSLServerVerification; + } else if (tls_err.code == ErrorCode::HostnameMismatch) { + error = Error::SSLServerHostnameVerification; + } + return fail(error, static_cast(tls_err.code), tls_err.backend_code); } - if (server_certificate_verification) { - if (get_verify_result(session) != 0) { return false; } + auto verification_status = SSLVerifierResponse::NoDecisionMade; + if (options.session_verifier) { + verification_status = options.session_verifier(session); + } + + if (verification_status == SSLVerifierResponse::CertificateRejected) { + return fail(Error::SSLServerVerification, 0, get_error()); + } + + if (verification_status == SSLVerifierResponse::NoDecisionMade && + server_certificate_verification) { + auto verify_result = get_verify_result(session); + if (verify_result != 0) { + return fail(Error::SSLServerVerification, 0, + static_cast(verify_result)); + } + + auto server_cert = get_peer_cert(session); + if (!server_cert) { + return fail(Error::SSLServerVerification, 0, get_error()); + } + auto cert_guard = detail::scope_exit([&] { free_cert(server_cert); }); // Identity check against the peer certificate, post-handshake for all - // backends (same as SSLClient). For IP hosts this is the only identity - // verification since no hostname is bound during the handshake. - auto server_cert = get_peer_cert(session); - if (!server_cert) { return false; } - auto cert_guard = detail::scope_exit([&] { free_cert(server_cert); }); - if (!verify_hostname(server_cert, host.c_str())) { return false; } + // backends. For IP hosts this is the only identity verification, since no + // hostname is bound during the handshake. + if (options.server_hostname_verification) { + if (!verify_hostname(server_cert, host.c_str())) { + return fail(Error::SSLServerHostnameVerification, 0, + hostname_mismatch_code()); + } + } + +#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE + // Additional Windows Schannel verification. + // This provides real-time certificate validation with Windows Update + // integration, working with both OpenSSL and MbedTLS backends. + if (options.windows_cert_verification) { + std::vector der; + if (get_cert_der(server_cert, der)) { + uint64_t wincrypt_error = 0; + if (!verify_cert_with_windows_schannel( + der, host, options.server_hostname_verification, + wincrypt_error)) { + return fail(Error::SSLServerVerification, 0, wincrypt_error); + } + } + } +#endif } return true; @@ -17106,8 +17196,6 @@ inline bool SSLClient::load_certs() { } inline bool SSLClient::initialize_ssl(Socket &socket, Error &error) { - using namespace tls; - // Load CA certificates if server verification is enabled if (server_certificate_verification_) { if (!load_certs()) { @@ -17117,134 +17205,40 @@ inline bool SSLClient::initialize_ssl(Socket &socket, Error &error) { } } - bool is_ip = detail::is_ip_address(host_); - -#if defined(CPPHTTPLIB_MBEDTLS_SUPPORT) || defined(CPPHTTPLIB_WOLFSSL_SUPPORT) - // MbedTLS/wolfSSL need explicit verification mode (OpenSSL uses - // SSL_VERIFY_NONE by default and performs all verification post-handshake). - // Chain verification happens during the handshake even for IP hosts; the - // certificate identity is verified post-handshake via verify_hostname(). - set_verify_client(ctx_, server_certificate_verification_); + detail::ClientTlsSessionOptions options; + options.server_hostname_verification = server_hostname_verification_; + options.session_verifier = session_verifier_; + options.ctx_mutex = &ctx_mutex_; +#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE + // Skip Schannel when a custom CA cert is specified, as the Windows + // certificate store would not know about user-provided CA certificates. + // Also skip when system CA trust is explicitly disabled. + options.windows_cert_verification = + enable_windows_cert_verification_ && + system_ca_mode_ != SystemCAMode::Disabled && ca_cert_file_path_.empty() && + ca_cert_dir_path_.empty() && ca_cert_pem_.empty() && !ca_cert_store_set_; #endif - // Create TLS session - session_t session = nullptr; - { - std::lock_guard guard(ctx_mutex_); - session = create_session(ctx_, socket.sock); - } - - if (!session) { - error = Error::SSLConnection; - last_backend_error_ = get_error(); - return false; - } + tls::session_t session = nullptr; // Use scope_exit to ensure session is freed on error paths bool success = false; auto session_guard = detail::scope_exit([&] { - if (!success) { free_session(session); } + if (!success) { tls::free_session(session); } }); - // Set SNI extension (skip for IP addresses per RFC 6066). - // On MbedTLS, set_sni also enables hostname verification internally. - // On OpenSSL, set_sni only sets SNI; verification is done post-handshake. - if (!is_ip) { - if (!set_sni(session, host_.c_str())) { - error = Error::SSLConnection; - last_backend_error_ = get_error(); - return false; - } - } - - // Perform non-blocking TLS handshake with timeout - TlsError tls_err; - if (!connect_nonblocking(session, socket.sock, connection_timeout_sec_, - connection_timeout_usec_, &tls_err)) { - last_ssl_error_ = static_cast(tls_err.code); - last_backend_error_ = tls_err.backend_code; - if (tls_err.code == ErrorCode::CertVerifyFailed) { - error = Error::SSLServerVerification; - } else if (tls_err.code == ErrorCode::HostnameMismatch) { - error = Error::SSLServerHostnameVerification; - } else { - error = Error::SSLConnection; - } + detail::ClientTlsSessionError tls_error; + if (!detail::setup_client_tls_session( + host_, ctx_, session, socket.sock, server_certificate_verification_, + connection_timeout_sec_, connection_timeout_usec_, &tls_error, + options)) { + error = tls_error.error; + last_ssl_error_ = tls_error.ssl_error; + last_backend_error_ = tls_error.backend_error; output_error_log(error, nullptr); return false; } - // Post-handshake session verifier callback - auto verification_status = SSLVerifierResponse::NoDecisionMade; - if (session_verifier_) { verification_status = session_verifier_(session); } - - if (verification_status == SSLVerifierResponse::CertificateRejected) { - last_backend_error_ = get_error(); - error = Error::SSLServerVerification; - output_error_log(error, nullptr); - return false; - } - - // Default server certificate verification - if (verification_status == SSLVerifierResponse::NoDecisionMade && - server_certificate_verification_) { - verify_result_ = tls::get_verify_result(session); - if (verify_result_ != 0) { - last_backend_error_ = static_cast(verify_result_); - error = Error::SSLServerVerification; - output_error_log(error, nullptr); - return false; - } - - auto server_cert = get_peer_cert(session); - if (!server_cert) { - last_backend_error_ = get_error(); - error = Error::SSLServerVerification; - output_error_log(error, nullptr); - return false; - } - auto cert_guard = detail::scope_exit([&] { free_cert(server_cert); }); - - // Hostname verification (post-handshake for all cases). - // On OpenSSL, verification is always post-handshake (SSL_VERIFY_NONE). - // On MbedTLS, set_sni already enabled hostname verification during - // handshake for non-IP hosts, but this check is still needed for IP - // addresses where SNI is not set. - if (server_hostname_verification_) { - if (!verify_hostname(server_cert, host_.c_str())) { - last_backend_error_ = hostname_mismatch_code(); - error = Error::SSLServerHostnameVerification; - output_error_log(error, nullptr); - return false; - } - } - -#ifdef CPPHTTPLIB_WINDOWS_AUTOMATIC_ROOT_CERTIFICATES_UPDATE - // Additional Windows Schannel verification. - // This provides real-time certificate validation with Windows Update - // integration, working with both OpenSSL and MbedTLS backends. - // Skip when a custom CA cert is specified, as the Windows certificate - // store would not know about user-provided CA certificates. Also skip - // when system CA trust is explicitly disabled. - if (enable_windows_cert_verification_ && - system_ca_mode_ != SystemCAMode::Disabled && - ca_cert_file_path_.empty() && ca_cert_dir_path_.empty() && - ca_cert_pem_.empty() && !ca_cert_store_set_) { - std::vector der; - if (get_cert_der(server_cert, der)) { - uint64_t wincrypt_error = 0; - if (!detail::verify_cert_with_windows_schannel( - der, host_, server_hostname_verification_, wincrypt_error)) { - last_backend_error_ = wincrypt_error; - error = Error::SSLServerVerification; - output_error_log(error, nullptr); - return false; - } - } - } -#endif - } - success = true; socket.ssl = session; return true; @@ -17974,32 +17968,6 @@ inline bool set_sni(session_t session, const char *hostname) { #endif } -inline bool set_hostname(session_t session, const char *hostname) { - if (!session || !hostname) return false; - - auto ssl = static_cast(session); - - // Enable hostname verification - auto param = SSL_get0_param(ssl); - if (!param) return false; - - if (detail::is_ip_address(hostname)) { - // RFC 6066: SNI must not be set for IP addresses; verify against the - // certificate's IP SANs instead of its DNS names - if (X509_VERIFY_PARAM_set1_ip_asc(param, hostname) != 1) { return false; } - } else { - // Set SNI (Server Name Indication) - if (!set_sni(session, hostname)) { return false; } - - X509_VERIFY_PARAM_set_hostflags(param, - X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); - if (X509_VERIFY_PARAM_set1_host(param, hostname, 0) != 1) { return false; } - } - - SSL_set_verify(ssl, SSL_VERIFY_PEER, nullptr); - return true; -} - inline TlsError connect(session_t session) { if (!session) { return TlsError(); } @@ -19224,11 +19192,6 @@ inline bool set_sni(session_t session, const char *hostname) { return true; } -inline bool set_hostname(session_t session, const char *hostname) { - // In Mbed TLS, set_hostname also sets up hostname verification - return set_sni(session, hostname); -} - inline TlsError connect(session_t session) { TlsError err; if (!session) { @@ -20381,11 +20344,6 @@ inline bool set_sni(session_t session, const char *hostname) { return true; } -inline bool set_hostname(session_t session, const char *hostname) { - // In wolfSSL, set_hostname also sets up hostname verification - return set_sni(session, hostname); -} - inline TlsError connect(session_t session) { TlsError err; if (!session) { diff --git a/test/test.cc b/test/test.cc index 4db19ec..e50b12d 100644 --- a/test/test.cc +++ b/test/test.cc @@ -21030,6 +21030,94 @@ TEST(WebSocketSSLVerifyTest, TrustedChainWrongIdentityFails) { ASSERT_FALSE(client.connect()); } + +// The tests above all connect to an IP literal, for which RFC 6066 forbids +// SNI, so nothing binds the host name to the TLS session. These bind the +// server to "localhost" instead, the only shape that exercises the SNI and +// hostname-verification path with certificate verification left enabled. +class WebSocketSSLDnsHostTest : public ::testing::Test { +protected: + void Start(const char *cert_file) { + server_ = httplib::detail::make_unique(cert_file, + SERVER_PRIVATE_KEY_FILE); + ASSERT_TRUE(server_->is_valid()); + server_->WebSocket("/echo", [](const Request &, ws::WebSocket &ws) { + std::string msg; + while (ws.read(msg)) { + ws.send(msg); + } + }); + port_ = server_->bind_to_any_port("localhost"); + server_thread_ = std::thread([this]() { server_->listen_after_bind(); }); + server_->wait_until_ready(); + } + + void TearDown() override { + if (server_) { server_->stop(); } + if (server_thread_.joinable()) { server_thread_.join(); } + } + + std::string url() const { + return "wss://localhost:" + std::to_string(port_) + "/echo"; + } + + std::unique_ptr server_; + std::thread server_thread_; + int port_ = 0; +}; + +// cert2 carries a DNS:localhost SAN, so both the chain and the identity check +// out and the connection is usable +TEST_F(WebSocketSSLDnsHostTest, TrustedChainMatchingNameVerifies) { + Start(SERVER_CERT2_FILE); + + ws::WebSocketClient client(url()); + client.set_ca_cert_path(SERVER_CERT2_FILE); + + ASSERT_TRUE(client.connect()); + ASSERT_TRUE(client.send("hello")); + std::string msg; + EXPECT_EQ(ws::Text, client.read(msg)); + EXPECT_EQ("hello", msg); + client.close(); +} + +// cert.pem has a CN but no SAN, so trusting it as a CA satisfies the chain +// while the identity check must still reject "localhost" +TEST_F(WebSocketSSLDnsHostTest, TrustedChainWrongNameFails) { + Start(SERVER_CERT_FILE); + + ws::WebSocketClient client(url()); + client.set_ca_cert_path(SERVER_CERT_FILE); + + ASSERT_FALSE(client.connect()); +} + +// A CA that did not sign the server certificate fails the chain, even though +// the name would match +TEST_F(WebSocketSSLDnsHostTest, UntrustedChainFails) { + Start(SERVER_CERT2_FILE); + + ws::WebSocketClient client(url()); + client.set_ca_cert_path(CLIENT_CA_CERT_FILE); + + ASSERT_FALSE(client.connect()); +} + +// With verification disabled, neither the chain nor the name is checked +TEST_F(WebSocketSSLDnsHostTest, VerificationDisabledAcceptsAnyName) { + Start(SERVER_CERT_FILE); + + ws::WebSocketClient client(url()); + client.enable_server_certificate_verification(false); + + ASSERT_TRUE(client.connect()); + ASSERT_TRUE(client.send("hello")); + std::string msg; + EXPECT_EQ(ws::Text, client.read(msg)); + EXPECT_EQ("hello", msg); + client.close(); +} #endif #if !defined(_WIN32) diff --git a/test/test.conf b/test/test.conf index 1cf7d63..57b1fc0 100644 --- a/test/test.conf +++ b/test/test.conf @@ -18,4 +18,4 @@ emailAddress = test@email.address challengePassword = 1234 [SAN] -subjectAltName=IP:127.0.0.1 +subjectAltName=IP:127.0.0.1,DNS:localhost