From 9d16ae92220752ccdc5aee793ead269b96d2e2f2 Mon Sep 17 00:00:00 2001 From: Noah Watkins Date: Fri, 14 Aug 2026 10:39:15 -0700 Subject: [PATCH] net/tls: verify peer name in OpenSSL client sessions The OpenSSL backend consumed tls_options::server_name only as the SNI extension value, and certificate verification stopped at chain validation. A client session therefore accepted any certificate that chains to a trusted root, no matter what name it was issued for, so any holder of a trusted-issuer certificate could impersonate any server. The GnuTLS backend has always passed server_name to gnutls_certificate_verify_peers3(), which additionally matches the peer certificate against that name, so the two providers verified different things and switching to OpenSSL silently dropped server authentication. Install the requested name as a reference identifier on the client session's verification parameters, making the existing SSL_VERIFY_PEER machinery perform the name check as part of chain verification. A mismatch now fails the handshake and surfaces through SSL_get_verify_result() as X509_V_ERR_HOSTNAME_MISMATCH or X509_V_ERR_IP_ADDRESS_MISMATCH, which verify() already converts into the same tls::verification_error the GnuTLS backend throws. Semantics match GnuTLS: * client sessions only; server-side handling of client certificates is unchanged (neither backend has ever name-checked those) * skipped when certificate verification is disabled * an IP-literal name is matched against iPAddress SANs and a DNS name against dNSName SANs, with subject CN fallback. OpenSSL 3.0's SSL_set1_host() does not recognize IP literals, so the name is installed with X509_VERIFY_PARAM_set1_ip_asc() first, falling back to X509_VERIFY_PARAM_set1_host() * partial-wildcard matching (w*.example.com) is disabled The existing test_x509_client_server_cert_validation_fail_name test did not catch the gap: it loaded tls-ca-bundle.pem as the trust store, which does not sign test.crt, so it failed on chain validation regardless of the name on both backends. Point it at catest.pem so that the name check is the only thing standing between the client and a mismatched certificate, and add positive and negative coverage for IP-literal server names against test.crt's iPAddress SAN. Update the tls_options::server_name documentation, which described the field as the SNI value only, to state that the name is also asserted against the peer certificate. Co-Authored-By: Claude Fable 5.1 --- include/seastar/net/tls.hh | 8 +++++++- src/net/tls_openssl.cc | 23 +++++++++++++++++++++++ tests/unit/tls_test.cc | 29 +++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/include/seastar/net/tls.hh b/include/seastar/net/tls.hh index 41fa109e8a9..69fbbd9b31d 100644 --- a/include/seastar/net/tls.hh +++ b/include/seastar/net/tls.hh @@ -489,7 +489,13 @@ namespace tls { public: /// \brief whether to wait for EOF from server on session termination deprecated_wait_for_eof_on_shutdown wait_for_eof_on_shutdown; - /// \brief server name to be used for the SNI TLS extension + /// \brief expected name of the server. Sent in the SNI TLS extension + /// and, unless certificate verification has been disabled on the + /// credentials, matched against the peer certificate (an IP literal + /// against its iPAddress SANs, a DNS name against its dNSName SANs, + /// with the subject CN as fallback). A mismatch fails the handshake + /// with a verification_error. When empty, no SNI is sent and no name + /// check is performed. sstring server_name = {}; /// \brief whether server certificate should be verified. May be set to false diff --git a/src/net/tls_openssl.cc b/src/net/tls_openssl.cc index 4ac9a9ed66d..f4af76be400 100644 --- a/src/net/tls_openssl.cc +++ b/src/net/tls_openssl.cc @@ -895,6 +895,29 @@ class openssl_session : public enable_shared_from_this, public if (!_options.server_name.empty()) { SSL_set_tlsext_host_name( _ssl.get(), _options.server_name.c_str()); + if (_creds->_enable_certificate_verification) { + // OpenSSL matches the peer certificate against the name + // we intended to connect to only when that name is + // installed as a reference identifier on the + // verification parameters; chain validation alone + // accepts a certificate issued to anyone, as long as + // the issuer is trusted. A mismatch is reported through + // SSL_get_verify_result(), which verify() converts into + // a verification_error. Matching GnuTLS semantics: an + // IP literal is matched against iPAddress SANs, a DNS + // name against dNSName SANs (subject CN as fallback), + // and partial-wildcard matching is disabled. + auto* param = SSL_get0_param(_ssl.get()); + X509_VERIFY_PARAM_set_hostflags( + param, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + const auto& name = _options.server_name; + if (1 != X509_VERIFY_PARAM_set1_ip_asc(param, name.c_str()) + && 1 != X509_VERIFY_PARAM_set1_host( + param, name.c_str(), name.size())) { + throw make_openssl_error( + "Failed to set expected server name"); + } + } } SSL_set_connect_state(_ssl.get()); } diff --git a/tests/unit/tls_test.cc b/tests/unit/tls_test.cc index 834ebde818c..7d7427583e3 100644 --- a/tests/unit/tls_test.cc +++ b/tests/unit/tls_test.cc @@ -782,8 +782,33 @@ SEASTAR_TEST_CASE(test_x509_client_server_cert_validation_fail) { } SEASTAR_TEST_CASE(test_x509_client_server_cert_validation_fail_name) { - // Use trust store with our signer, but wrong host name - return run_echo_test(message, 1, certfile("tls-ca-bundle.pem"), "nils.holgersson.gov").then([] { + // Use trust store with our signer, but wrong host name. The certificate + // chain is fully trusted, so the only thing standing between the client + // and an impostor is the peer name check. + return run_echo_test(message, 1, certfile("catest.pem"), "nils.holgersson.gov").then([] { + BOOST_FAIL("Should have gotten validation error"); + }).handle_exception([](auto ep) { + try { + std::rethrow_exception(ep); + } catch (tls::verification_error&) { + // ok. + } catch (...) { + BOOST_FAIL("Unexpected exception"); + } + }); +} + +SEASTAR_TEST_CASE(test_x509_client_server_cert_validation_ip_match) { + // test.crt carries an iPAddress SAN for 127.0.0.1. An IP-literal + // server_name must be matched against that SAN as an address, not as a + // DNS name. + return run_echo_test(message, 1, certfile("catest.pem"), "127.0.0.1"); +} + +SEASTAR_TEST_CASE(test_x509_client_server_cert_validation_fail_ip) { + // Trusted signer, but an IP that does not match the certificate's + // iPAddress SAN (127.0.0.1). + return run_echo_test(message, 1, certfile("catest.pem"), "127.0.0.2").then([] { BOOST_FAIL("Should have gotten validation error"); }).handle_exception([](auto ep) { try {