Files
thrift/lib/cpp/test/TSSLSocketMatchNameTest.cpp
Jens GeyerandClaude Opus 5.5 8bc32dc84f Require a label after the wildcard in the C++ TLS name match
Client: cpp

matchName(), which backs DefaultClientAccessManager, honoured a wildcard in
the leftmost label of a certificate name even when no label followed it, so
a dNSName or common name of "*", "f*" or "*." matched every single-label
host name. A wildcard stands in for the leftmost label of a longer name
(RFC 6125 6.4.3); a pattern whose wildcard label has no label after it no
longer matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:17:23 +01:00

339 lines
12 KiB
C++

/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
#define BOOST_TEST_MODULE TSSLSocketMatchNameTest
#include <boost/test/unit_test.hpp>
#include <boost/thread.hpp>
#include <boost/version.hpp>
#include <thrift/transport/TSSLSocket.h>
#include <thrift/transport/TSSLServerSocket.h>
#include <thrift/transport/TTransportException.h>
#include <openssl/pem.h>
#include <openssl/x509v3.h>
#include <string>
#ifdef HAVE_SIGNAL_H
#include <signal.h>
#endif
using apache::thrift::transport::AccessManager;
using apache::thrift::transport::DefaultClientAccessManager;
using apache::thrift::transport::TSSLSocket;
using apache::thrift::transport::TSSLSocketFactory;
using apache::thrift::transport::TSSLServerSocket;
using apache::thrift::transport::TTransport;
using apache::thrift::transport::TTransportException;
// Several of the cases below have the client reject the server's identity on
// purpose, which tears the connection down mid-handshake. OpenSSL calls send()
// without MSG_NOSIGPIPE, so the peer still writing to that socket takes a
// SIGPIPE and the whole test binary dies with status 141. SecurityTest.cpp
// ignores the signal for the same reason.
struct GlobalFixture
{
GlobalFixture()
{
#ifdef __linux__
signal(SIGPIPE, SIG_IGN);
#endif
}
virtual ~GlobalFixture()
{
#ifdef __linux__
signal(SIGPIPE, SIG_DFL);
#endif
}
};
#if (BOOST_VERSION >= 105900)
BOOST_GLOBAL_FIXTURE(GlobalFixture);
#else
BOOST_GLOBAL_FIXTURE(GlobalFixture)
#endif
BOOST_AUTO_TEST_SUITE(TSSLSocketMatchNameTest)
// Helper: ALLOW means match, SKIP means no match.
static bool allows(const std::string& host, const std::string& pattern) {
DefaultClientAccessManager mgr;
return mgr.verify(host, pattern.c_str(), static_cast<int>(pattern.size()))
== AccessManager::ALLOW;
}
BOOST_AUTO_TEST_CASE(standard_wildcard_matches) {
BOOST_CHECK(allows("foo.example.com", "*.example.com"));
BOOST_CHECK(allows("FOO.EXAMPLE.COM", "*.example.com")); // case-insensitive
BOOST_CHECK(allows("a.b.c.example.com", "*.b.c.example.com")); // leftmost wildcard
}
BOOST_AUTO_TEST_CASE(exact_match) {
BOOST_CHECK(allows("example.com", "example.com"));
BOOST_CHECK(allows("foo.example.com", "foo.example.com"));
}
BOOST_AUTO_TEST_CASE(wildcard_must_not_span_labels) {
BOOST_CHECK(!allows("foo.bar.example.com", "*.example.com"));
}
BOOST_AUTO_TEST_CASE(wildcard_must_be_in_leftmost_label) {
// RFC 6125 §6.4.3: wildcard must not appear outside the leftmost label.
BOOST_CHECK(!allows("example.foo.com", "example.*.com"));
BOOST_CHECK(!allows("a.evil.com", "a.ev*.com"));
}
BOOST_AUTO_TEST_CASE(no_suffix_bypass) {
BOOST_CHECK(!allows("evil.com.attacker.com", "evil.com"));
}
BOOST_AUTO_TEST_CASE(wildcard_needs_a_label_after_it) {
// A wildcard stands in for the leftmost label of a longer name. On its own,
// or with nothing after its dot, it would cover every single-label host.
BOOST_CHECK(!allows("localhost", "*"));
BOOST_CHECK(!allows("redis", "*"));
BOOST_CHECK(!allows("foo", "f*"));
BOOST_CHECK(!allows("localhost.", "*."));
BOOST_CHECK(allows("localhost", "localhost"));
BOOST_CHECK(allows("foo.example.com", "*.example.com"));
}
// ---------------------------------------------------------------------------
// Which identity in the certificate is consulted, and in what order.
//
// The matcher above answers "does this name match this pattern". Which name is
// offered to it is decided in TSSLSocket::authorize(), and that is a separate
// question: RFC 6125 6.4.4 and RFC 9525 6.3 make a dNSName in the
// subjectAltName the identity, so commonName may only be reached by a
// certificate that carries no dNSName at all. Testing that needs a real
// handshake, so these cases run one, against certificates built here rather
// than against fixtures, since the point is the relationship between the two
// fields.
// ---------------------------------------------------------------------------
namespace {
struct Pem {
std::string certificate;
std::string privateKey;
};
std::string readBio(BIO* bio) {
char* data = nullptr;
const long length = BIO_get_mem_data(bio, &data);
return std::string(data, static_cast<size_t>(length));
}
// A self-signed certificate for `commonName`, carrying `subjectAltName` when
// that is not empty (in OpenSSL's config syntax, e.g. "DNS:other.example").
// When `appendUnconvertibleCommonName` is set, a second commonName is added
// whose ASN.1 string type ASN1_STRING_to_UTF8() cannot convert.
Pem selfSigned(const std::string& commonName,
const std::string& subjectAltName,
bool appendUnconvertibleCommonName = false) {
EVP_PKEY* key = nullptr;
EVP_PKEY_CTX* keyCtx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr);
BOOST_REQUIRE(keyCtx != nullptr);
BOOST_REQUIRE(EVP_PKEY_keygen_init(keyCtx) > 0);
BOOST_REQUIRE(EVP_PKEY_CTX_set_rsa_keygen_bits(keyCtx, 2048) > 0);
BOOST_REQUIRE(EVP_PKEY_keygen(keyCtx, &key) > 0);
EVP_PKEY_CTX_free(keyCtx);
X509* cert = X509_new();
BOOST_REQUIRE(cert != nullptr);
X509_set_version(cert, 2);
ASN1_INTEGER_set(X509_get_serialNumber(cert), 1);
#if OPENSSL_VERSION_NUMBER >= 0x10100000L
X509_gmtime_adj(X509_getm_notBefore(cert), 0);
X509_gmtime_adj(X509_getm_notAfter(cert), 3600);
#else
X509_gmtime_adj(X509_get_notBefore(cert), 0);
X509_gmtime_adj(X509_get_notAfter(cert), 3600);
#endif
X509_set_pubkey(cert, key);
// Built up separately rather than through X509_get_subject_name(), which
// returns a const pointer as of OpenSSL 4.0. Both setters copy the name, so
// the local one is released again right away.
X509_NAME* subject = X509_NAME_new();
BOOST_REQUIRE(subject != nullptr);
X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC,
reinterpret_cast<const unsigned char*>(commonName.c_str()),
-1, -1, 0);
if (appendUnconvertibleCommonName) {
// A second commonName whose ASN.1 string type is not one that
// ASN1_STRING_to_UTF8() can convert (ObjectDescriptor): the conversion then
// reports a negative length and leaves its output pointer untouched. A
// BMPString of odd byte length is the same case, but X509_NAME_add_entry
// rejects that length outright, so this type carries it through the
// certificate's DER round trip instead.
static const unsigned char descriptor[] = {0x01, 0x02, 0x03, 0x04};
X509_NAME_add_entry_by_NID(subject, NID_commonName, V_ASN1_OBJECT_DESCRIPTOR, descriptor,
sizeof(descriptor), -1, 0);
}
X509_set_subject_name(cert, subject);
X509_set_issuer_name(cert, subject);
X509_NAME_free(subject);
if (!subjectAltName.empty()) {
X509V3_CTX extCtx;
X509V3_set_ctx_nodb(&extCtx);
X509V3_set_ctx(&extCtx, cert, cert, nullptr, nullptr, 0);
X509_EXTENSION* ext = X509V3_EXT_conf_nid(nullptr, &extCtx, NID_subject_alt_name,
const_cast<char*>(subjectAltName.c_str()));
BOOST_REQUIRE(ext != nullptr);
X509_add_ext(cert, ext, -1);
X509_EXTENSION_free(ext);
}
BOOST_REQUIRE(X509_sign(cert, key, EVP_sha256()) > 0);
Pem pem;
BIO* bio = BIO_new(BIO_s_mem());
PEM_write_bio_X509(bio, cert);
pem.certificate = readBio(bio);
BIO_free(bio);
bio = BIO_new(BIO_s_mem());
PEM_write_bio_PrivateKey(bio, key, nullptr, nullptr, 0, nullptr, nullptr);
pem.privateKey = readBio(bio);
BIO_free(bio);
X509_free(cert);
EVP_PKEY_free(key);
return pem;
}
// Serves exactly one connection with the given identity, and reports whether
// the client was accepted by the *client's* own checks -- which is what the
// return value of connectTo() below says.
class OneShotServer {
public:
explicit OneShotServer(const Pem& pem) {
auto factory = std::make_shared<TSSLSocketFactory>();
factory->server(true);
factory->loadCertificateFromBuffer(pem.certificate.c_str());
factory->loadPrivateKeyFromBuffer(pem.privateKey.c_str());
socket_ = std::make_shared<TSSLServerSocket>("127.0.0.1", 0, factory);
socket_->listen();
port_ = socket_->getPort();
thread_ = boost::thread(&OneShotServer::run, this);
}
~OneShotServer() {
// interrupt() before close(): a client that never arrives, or that walks
// away mid-handshake, leaves accept() blocked, and close() alone does not
// wake it.
socket_->interrupt();
socket_->close();
thread_.join();
}
int port() const { return port_; }
private:
void run() {
try {
std::shared_ptr<TTransport> client = socket_->accept();
if (client) {
// The client has to read something, or it never handshakes at all --
// see connectAccepts().
uint8_t ok[2] = {'O', 'K'};
client->write(ok, sizeof(ok));
client->flush();
client->close();
}
} catch (...) {
// A client that refuses the certificate leaves the handshake unfinished;
// that is the case under test, not a failure of the server.
}
}
std::shared_ptr<TSSLServerSocket> socket_;
boost::thread thread_;
int port_{0};
};
// true when the client accepted the server's identity.
//
// It has to read, not just open: TSSLSocket::open() performs no handshake at
// all -- it is deferred to the first I/O through checkHandshake() -- so a test
// that only opens and closes passes whatever the certificate says, including
// against unfixed code. That mistake was made here once already.
bool connectAccepts(const Pem& pem, int port) {
auto factory = std::make_shared<TSSLSocketFactory>();
// Self-signed, so trusting the certificate itself puts the chain beyond
// question and leaves only the name check under test.
factory->loadTrustedCertificatesFromBuffer(pem.certificate.c_str());
std::shared_ptr<TSSLSocket> client = factory->createSocket("127.0.0.1", port);
try {
client->open();
uint8_t buffer[2];
client->readAll(buffer, sizeof(buffer));
client->close();
return true;
} catch (const TTransportException&) {
try {
client->close();
} catch (...) {
}
return false;
}
}
} // namespace
BOOST_AUTO_TEST_CASE(subject_alt_name_matching_is_accepted) {
// Control: without this, the assertion below is satisfied by a handshake
// that fails for some unrelated reason.
Pem pem = selfSigned("no.match.example", "IP:127.0.0.1");
OneShotServer server(pem);
BOOST_CHECK(connectAccepts(pem, server.port()));
}
BOOST_AUTO_TEST_CASE(common_name_is_not_consulted_when_a_dns_san_is_present) {
// The commonName is exactly what is being connected to, and would be
// accepted on its own; the certificate says otherwise in its subjectAltName,
// and the subjectAltName is the one that counts.
Pem pem = selfSigned("127.0.0.1", "DNS:other.example");
OneShotServer server(pem);
BOOST_CHECK(!connectAccepts(pem, server.port()));
}
BOOST_AUTO_TEST_CASE(common_name_still_answers_when_no_san_is_present) {
// Certificates predating subjectAltName must keep working.
Pem pem = selfSigned("127.0.0.1", "");
OneShotServer server(pem);
BOOST_CHECK(connectAccepts(pem, server.port()));
}
BOOST_AUTO_TEST_CASE(common_name_that_cannot_be_converted_is_refused) {
// No subjectAltName, so the commonName fallback runs. The subject carries two
// commonNames: a valid one that does not match the connected host, followed by
// one whose ASN.1 string ASN1_STRING_to_UTF8() cannot convert. The fallback
// must consult the conversion result before the name is used or released, walk
// past the entry, and refuse the peer rather than act on an unwritten pointer.
Pem pem = selfSigned("first.example", "", true);
OneShotServer server(pem);
BOOST_CHECK(!connectAccepts(pem, server.port()));
}
BOOST_AUTO_TEST_SUITE_END()