Skip to content

Refresh default TLS context on secret update#13342

Open
bneradt wants to merge 1 commit into
apache:masterfrom
bneradt:fix-cert-secret-default-refresh
Open

Refresh default TLS context on secret update#13342
bneradt wants to merge 1 commit into
apache:masterfrom
bneradt:fix-cert-secret-default-refresh

Conversation

@bneradt

@bneradt bneradt commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Default server certificate secret updates could rebuild the TLS
contexts for CN/SAN lookups while leaving the default/no-SNI context
pointing at the old SSL_CTX. Operators could update cert material on
disk and through the secret API, but new handshakes without a more
specific match could still serve the stale certificate.

This updates runtime context refresh to cover address/default lookup
entries owned by the same ssl_multicert policy and retains the
default context while callers create new TLS sessions. This also adds
an AuTest that updates a plugin-loaded default certificate and
verifies the next no-SNI handshake sees the new certificate.

Fixes: #9562

Default server certificate secret updates could rebuild the TLS
contexts for CN/SAN lookups while leaving the default/no-SNI context
pointing at the old SSL_CTX. Operators could update cert material on
disk and through the secret API, but new handshakes without a more
specific match could still serve the stale certificate.

This updates runtime context refresh to cover address/default lookup
entries owned by the same ssl_multicert policy and retains the
default context while callers create new TLS sessions. This also adds
an AuTest that updates a plugin-loaded default certificate and
verifies the next no-SNI handshake sees the new certificate.

Fixes: apache#9562
@bneradt bneradt added this to the 11.0.0 milestone Jun 26, 2026
Copilot AI review requested due to automatic review settings June 26, 2026 18:43
@bneradt bneradt self-assigned this Jun 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment on lines +69 to +70
'proxy.config.ssl.server.cert.path': f'{ts.Variables.SSLDir}/../',
'proxy.config.ssl.server.private_key.path': f'{ts.Variables.SSLDir}/../',

@moonchen moonchen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 to covering the default/no-SNI context on secret updates. A couple of issues with the new refresh loop, plus some smalls.

} else {
// Address and default-context lookups are owned by the same policy, but
// are not part of the certificate CN/SAN name sets below.
for (unsigned i = 0; i < lookup->count(loadingctx.ctx_type); ++i) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This predicate matches every entry the policy owns, including the unique-SAN entries — everything from one ssl_multicert line shares the same userconfig. Those get stamped with the common ctx here and only repaired by the unique_names loop below, and if that rebuild fails, init_server_ssl_ctx returns an empty vector (goto fail), so if (!unique_ctx) never runs and they permanently keep the wrong cert while update_ssl_ctx returns true. Since the goal is just the addr-keyed entry, find("*", ...) (or ats_ip_pton + find(ep) for a non-* addr) would update exactly that entry and keep the "old ctx or new ctx, never a wrong one" behavior. The comment above also doesn't match the code — these entries are the CN/SAN name sets below.

}
if ((*policy_iter)->addr && strcmp((*policy_iter)->addr, "*") == 0) {
lookup->setDefaultContext(ctx);
this->_set_handshake_callbacks(ctx.get());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the load path, this lookup is live, so the new ctx is reachable by net threads (cc->setCtx above, setDefaultContext here) before the callbacks are installed. SSL_new copies cert_cb, so a handshake landing in that window runs with no cert selection at all. Installing the callbacks right after the ctx is created, before any setCtx, closes the window.


QUICCertConfig::scoped_config server_cert;
SSL *ssl = SSL_new(server_cert->defaultContext());
auto default_ctx = server_cert->defaultContext();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for this PR, but worth a note in the description or a follow-up issue: QUICCertConfig builds its own SSLCertLookup that only a full reload rebuilds, so secret updates still leave QUIC serving the stale default cert.

defaultContext() const
{
return ssl_default.get();
std::lock_guard<std::mutex> lock(default_ctx_mutex);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs #include <mutex> — the header only includes <shared_mutex>, so std::mutex/std::lock_guard currently compile through transitive includes.


shared_SSL_CTX ssl_default;
bool is_valid = true;
mutable shared_SSL_CTX ssl_default;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this move to the private section next to default_ctx_mutex? Nothing accesses it directly anymore, and keeping it public leaves the door open for the kind of unlocked read this PR fixed in SSLStats.cc.

// Return the last-resort default TLS context if there is no name or address match.
SSL_CTX *
shared_SSL_CTX
defaultContext() const

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One optional suggestion: the default ctx is now stored twice (the "*" entry and ssl_default) under two different locks, and this one is exclusive on every no-SNI accept. Caching a pointer to the "*" entry's SSLCertContext and delegating to its getCtx()/setCtx() (shared_mutex) would drop both the duplicate state and the exclusive lock.

key_file = 'signed-bar.key'
plugin = 'ssl_secret_load_test.so'

def __init__(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type annotations on these methods, please — matching the other class-style tests in this directory (e.g. def __init__(self) -> None: in tls_origin_post_abort.test.py).


import os

Test.Summary = '''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: tls_check_cert_select_plugin.test.py already has the same plugin/wildcard-policy/CopyAs+touch scaffolding — a no-SNI run added there might cover this without a second copy of the update dance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

In what cases are updates by TSSslSecretSet() actually used?

3 participants