Skip to content

tls: rotate the live certificate on setSecureContext() and Bun.serve reload({tls}) - #33365

Open
robobun wants to merge 11 commits into
mainfrom
farm/72dcb218/tls-set-secure-context-rebuild
Open

tls: rotate the live certificate on setSecureContext() and Bun.serve reload({tls})#33365
robobun wants to merge 11 commits into
mainfrom
farm/72dcb218/tls-set-secure-context-rebuild

Conversation

@robobun

@robobun robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Live certificate rotation was a silent no-op on both TLS server surfaces:

  • tls.Server.prototype.setSecureContext() succeeded, emitted no error, and the server kept serving its original certificate until the process restarted. Since live rotation (certbot/ACME renew hooks, k8s secret reloads) is the only reason the method exists, the first symptom was an expired-certificate outage weeks later.
  • Bun.serve().reload({tls}) swapped the handler but ignored tls entirely, and a mismatched key/cert pair or garbage PEM was accepted without error, so a bad rotation failed silently while the server kept serving the retired certificate.

Reproduction

// node:tls (CN=localhost then CN=server-bun)
const server = tls.createServer({ key: keyA, cert: certA }, s => s.end());
server.listen(0, "127.0.0.1", async () => {
  const { port } = server.address();
  console.log("before:", await cn(port));                    // localhost
  server.setSecureContext({ key: keyB, cert: certB });
  console.log("after:", await cn(port));                     // still localhost on bun 1.4.0
});

// Bun.serve
const s = Bun.serve({ port: 0, tls: A, fetch: () => new Response("ok") });
s.reload({ tls: B, fetch: () => new Response("ok") });       // new connections still serve A
s.reload({ tls: { key: "xxx", cert: "yyy" }, fetch });       // no throw on garbage
s.reload({ tls: { key: A.key, cert: B.cert }, fetch });      // no throw on key/cert mismatch

The tls.Server path also happens through http2.createSecureServer() (Http2SecureServer extends tls.Server), which is how @grpc/grpc-js rotates credentials.

Cause

The native SSL_CTX is built once at listen time. setSecureContext() only reassigned the JS-side fields (this.key, this.cert, ...) that [buntls] reads at listen, so nothing rebuilt the context the listen socket hands to each accepted socket. on_reload_from_zig parsed the replacement tls into new_config.ssl_config and then dropped it without touching the listener.

Fix

  • us_listen_socket_set_ssl_ctx() (usockets) swaps the listen socket's default SSL_CTX, re-arming the SNI callbacks on the new context and moving the SNI-tree entry the bind hostname was registered under. An addContext() entry on that same name is left alone, since it is not the listen-time hint.
  • Listener::set_secure_context() parses the fresh options, builds the context, performs the swap and releases the retiring reference. tls.ts calls it from setSecureContext() whenever the server is already listening.
  • on_reload_from_zig builds the replacement context first, throws ERR_OSSL_PEM_NO_START_LINE / ERR_OSSL_X509_KEY_VALUES_MISMATCH (matching Bun.serve() startup) before any handler or route is swapped, then applies the rotation via us_listen_socket_set_ssl_ctx. The --hot reload path goes through the same function, so it picks up the rotation too.

Sockets already accepted keep the certificate they handshook with (their SSL_new holds its own reference); only subsequent handshakes see the new one, matching Node's documented semantics. addContext() entries, SNICallback, and the server's ALPNProtocols all survive the swap.

A rotation whose context fails to build (bad PEM, key/cert mismatch, unusable cipher list) throws on both surfaces and leaves the live context untouched.

setSecureContext() also stops clearing ALPNProtocols: Node replaces the default context only and never touches the ALPN list; clearing it meant a cert rotation on an HTTP/2 server dropped the server's ALPN state. And it now preserves requestCert/rejectUnauthorized when omitted (Node only sets them in the constructor) and applies net.ts's listen-time !requestCert => rejectUnauthorized = false clamp before rebuilding, so a server with ca but no requestCert does not start rejecting every certless client after a rotation.

The Bun.serve rotation covers the default certificate and the serverName entry on a single-object tls config. Two sibling forms are intentionally out of scope here (neither is a regression; reload({tls}) was a no-op for every form before):

  • A server created with the experimental http3: true option keeps its QUIC context's SSL_CTX (there is no us_quic_socket_context_set_ssl_ctx primitive yet).
  • Array-form tls: [primary, ...secondary]: only primary is validated and rotated; secondary entries (new_config.sni) are not touched, so their SNI-tree entries keep serving their original certificate. Rotating those requires iterating the per-domain contexts and routers, which is its own change.

How did you verify your code works?

test/js/node/tls/node-tls-server.test.ts gains five cases and test/js/bun/http/bun-serve-ssl.test.ts gains two, each of which fails on main and passes here:

  • the replacement certificate is served on subsequent handshakes, including when the client sends the bind hostname as SNI
  • addContext() entries and ALPNProtocols survive the swap
  • an unusable certificate throws and the server keeps serving the previous one
  • requestCert/rejectUnauthorized are preserved across a rotation
  • Bun.serve().reload({tls}) rotates the default certificate
  • Bun.serve().reload({tls}) rejects a mismatched pair with ERR_OSSL_X509_KEY_VALUES_MISMATCH and garbage PEM with ERR_OSSL_PEM_NO_START_LINE, and keeps serving the previous certificate

Differentially checked against Node for the default context, the SNI-tree entry, addContext, SNICallback, ALPN, the failure path, and setSecureContext() before listen(). 50 back-to-back rotations leave sslCtxLiveCount() unchanged, so no SSL_CTX leaks.

Unrelated to the fix, one commit makes SNICallback runs even when the requested servername matches the bind hostname dial the address listen() reported instead of resolving localhost a second time; the name has both an A and an AAAA record on a dual-stack host and the two resolutions need not agree.


[review] gate passed · iteration 8 · 11 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-ssl.test.ts test/js/node/tls/node-tls-server.test.ts
bun test v1.4.0 (bb4392c21)

test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [7.71ms]
(pass) Bun.serve SSL validations > invalid key #2 development [2.47ms]
(pass) Bun.serve SSL validations > invalid cert development [1.78ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [98.88ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [3.42ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [2.31ms]
(pass) Bun.serve SSL validations > invalid key production [2.05ms]
(pass) Bun.serve SSL validations > invalid key #2 production [2.37ms]
(pass) Bun.serve SSL validations > invalid cert production [2.06ms]
(pass) Bun.serve SSL validations > invalid cert #2 production [5.85ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName production [2.05ms]
(pass) Bun.serve SSL validations > invalid serverName: empty se
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (5a6ac3bb6)

test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [0.27ms]
(pass) Bun.serve SSL validations > invalid key #2 development [0.07ms]
(pass) Bun.serve SSL validations > invalid cert development [0.02ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [8.53ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [0.06ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [0.02ms]
(pass) Bun.serve SSL validations > invalid key production [0.06ms]
(pass) Bun.serve SSL validations > invalid key #2 production [0.04ms]
(pass) Bun.serve SSL validations > invalid cert production [0.02ms]
(pass) Bun.serve SSL validations > invalid cert #2 production [0.29ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName production [0.01ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName production
(pass) Bun.serve SSL validations > valid development [5.45ms]
(pass) Bun.serve SSL validations > valid 2 development [5.53ms]
(pass) Bun.serve SSL validations > valid production [4.10ms
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/bun-serve-ssl.test.ts test/js/node/tls/node-tls-server.test.ts
bun test v1.4.0 (bb4392c21)

test/js/bun/http/bun-serve-ssl.test.ts:
(pass) Bun.serve SSL validations > invalid key development [12.04ms]
(pass) Bun.serve SSL validations > invalid key #2 development [3.94ms]
(pass) Bun.serve SSL validations > invalid cert development [3.25ms]
(pass) Bun.serve SSL validations > invalid cert #2 development [170.81ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName development [5.64ms]
(pass) Bun.serve SSL validations > invalid serverName: empty serverName development [3.21ms]
(pass) Bun.serve SSL validations > invalid key production [3.38ms]
(pass) Bun.serve SSL validations > invalid key #2 production [3.36ms]
(pass) Bun.serve SSL validations > invalid cert production [2.96ms]
(pass) Bun.serve SSL validations > invalid cert #2 production [9.91ms]
(pass) Bun.serve SSL validations > invalid serverName: missing serverName production [3.39ms]
(pass) Bun.serve SSL validations > invalid serverName: empty 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 854ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/38] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/38] gen JS modules (bundle-modules)
Preprocess modules (9622ms)
Bundle modules (52ms)
Postprocesss modules (103ms)
Bundle Functions (950ms)
Generate Code (24ms)

[10.76s] Bundled "src/js" for production
  2571 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[2/29] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_
... (truncated)
diff hotspot
packages/bun-usockets/src/crypto/openssl.c |  33 ++++++
 packages/bun-usockets/src/libusockets.h    |  10 ++
 src/js/node/tls.ts                         |  86 +++++++++++---
 src/runtime/api/BunObject.rs               |   2 +-
 src/runtime/server/mod.rs                  |  10 ++
 src/runtime/server/server_body.rs          |  54 ++++++++-
 src/runtime/socket/Listener.rs             | 129 ++++++++++++++++++++-
 src/uws_sys/App.rs                         |  15 +++
 src/uws_sys/ListenSocket.rs                |  31 +++++
 test/js/bun/http/bun-serve-ssl.test.ts     | 124 ++++++++++++++++++++
 test/js/node/tls/node-tls-server.test.ts   | 176 ++++++++++++++++++++++++++++-
 11 files changed, 640 insertions(+), 30 deletions(-)

gate history · 5 passed · 0 rejected · iteration 8

evidence per changed file
file                                        reads  edits  tests
packages/bun-usockets/src/crypto/openssl.c      5      6      0
packages/bun-usockets/src/libusockets.h         2      2      0
src/js/node/tls.ts                              7      7      0
src/runtime/api/BunObject.rs                    0      0      0
src/runtime/server/mod.rs                       1      3      0
src/runtime/server/server_body.rs               1      1      0
src/runtime/socket/Listener.rs                 18     31      0
src/uws_sys/App.rs                              1      1      0
src/uws_sys/ListenSocket.rs                     3      7      0
test/js/bun/http/bun-serve-ssl.test.ts          1      4      0
test/js/node/tls/node-tls-server.test.ts        5      6      0

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:58 AM PT - Jul 29th, 2026

@robobun, your commit bb4392c has some failures in Build #84990 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33365

That installs a local version of the PR into your bun-33365 executable, so you can run:

bun-33365 --bun

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds runtime TLS context replacement for listening servers. It introduces a uSockets SSL_CTX swap API, Rust and Listener bindings, live Server#setSecureContext integration, ALPN and authorization-option handling, and tests for certificate, SNI, ALPN, and failure behavior.

Changes

Live TLS Secure Context Rotation

Layer / File(s) Summary
uSockets SSL_CTX swap API
packages/bun-usockets/src/crypto/openssl.c, packages/bun-usockets/src/libusockets.h
Adds us_listen_socket_set_ssl_ctx to replace a listener’s SSL_CTX, manage references, reinstall SNI callbacks, update matching SNI entries, and expose the public declaration.
Rust FFI binding
src/uws_sys/ListenSocket.rs
Adds ListenSocket::set_ssl_ctx and the corresponding FFI declaration.
Listener state and secure-context replacement
src/runtime/socket/Listener.rs
Stores TLS contexts in Cell values, persists the SNI server name, updates lifecycle handling, swaps contexts for TCP and Windows named-pipe listeners, rebuilds contexts, and exposes js_set_secure_context.
Node TLS integration and validation
src/js/node/tls.ts, test/js/node/tls/node-tls-server.test.ts
Adds live listener context updates, preserves omitted ALPNProtocols and authorization settings, and tests certificate replacement, SNI and ALPN retention, client-certificate behavior, and failed rotation handling.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: live TLS certificate rotation for setSecureContext and Bun.serve reload.
Description check ✅ Passed The description matches the template with both required sections and includes clear implementation details plus verification steps.

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot 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.

I didn't find any bugs, but this touches SSL_CTX lifetime management across C/Rust/JS FFI boundaries and changes live TLS certificate handling — worth a human pass on the ref-counting and the ALPNProtocols behavior change.

Extended reasoning...

Overview

This PR makes tls.Server.setSecureContext() actually rebuild the live SSL_CTX on an already-listening server (previously a silent no-op). It spans three languages: a new C function us_listen_socket_set_ssl_ctx() in usockets that swaps the listen socket's default SSL_CTX and re-arms SNI callbacks; a Rust Listener::set_secure_context() that parses fresh TLS options, builds a new context, and swaps it in with proper cleanup on failure; a JS-side hook in tls.ts that invokes the native path when _handle exists; and a struct field change from Option<NonNull<SSL_CTX>> to Cell<Option<NonNull<SSL_CTX>>> plus a new server_name field. It also changes setSecureContext() to stop clearing ALPNProtocols when omitted (matching Node). Three new tests cover cert rotation, addContext/ALPN preservation, and failure-path rollback.

Security risks

This is squarely security-sensitive code: live TLS certificate rotation, SSL_CTX reference counting across an FFI boundary, and SNI callback re-registration. The primary risks are (a) an SSL_CTX ref-count imbalance leading to UAF or leak — the C function up-refs before dropping and the Rust side releases the retiring ref only after the swap succeeds, which looks correct, but the ownership handoff crosses three layers; (b) the SNI-tree entry migration checking node->ctx == old to distinguish the listen-time hint from user addContext() entries — subtle enough to warrant scrutiny; (c) the re-entrancy guard where parsing options can run user JS that closes the server, handled by re-reading this.listener.get() after SSLConfig::from_js.

Level of scrutiny

High. Per the repo's own review guidance, TLS/crypto paths and manual ref-counting across FFI are the most-blocked category. The change is well-tested and the reasoning is clearly documented, but the correctness of SSL_CTX_up_ref/SSL_CTX_free pairing across us_listen_socket_set_ssl_ctxListenSocket::set_ssl_ctxListener::set_secure_context (particularly the double up-ref: one in C for ls->ssl_ctx, one in Rust kept as this.secure_ctx) deserves a maintainer's eyes.

Other factors

The ALPNProtocols behavior change reverses a prior explicit decision (the removed comment said "An omitted ALPNProtocols clears the previous call's protocols"). The PR argues Node never touches ALPN in setSecureContext(), which is correct, but a maintainer should confirm they're comfortable with the semantic reversal. The tests are solid — they exercise the SNI path with the bind hostname, verify addContext survives, and check the failure path leaves the live context intact. No CODEOWNERS check performed, but packages/bun-usockets and TLS bindings typically warrant owner review.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Two things worth writing down for whoever reviews this, since both were flagged as wanting a human pass.

SSL_CTX reference accounting

After listen() the default context O carries three references that this codebase owns, plus one per live SSL that BoringSSL owns via SSL_new:

ref taken by
1 Listener::secure_ctx (the one create_ssl_context() returns)
2 ls->ssl_ctx (us_internal_init_listen_socket up_refs)
3 the SNI-tree node for the bind hostname (us_listen_socket_add_server_name up_refs)

setSecureContext() builds N (refcount 1, owned by Rust) and then:

  1. us_listen_socket_set_ssl_ctx() up_refs N and stores it in ls->ssl_ctxN = 2
  2. if the SNI node still points at O, it up_refs N, frees O's node reference, and repoints → N = 3
  3. it unrefs O's ls->ssl_ctx reference
  4. back in Rust, secure_ctx.replace(N) frees O's listener reference and keeps N's build reference → N = 3

So N lands on exactly the three references listen() would have left, O drops to zero once the last live SSL goes away, and teardown is unchanged (us_internal_listen_socket_ssl_free drops two, Listener::deinit drops one). Every reference is taken before the matching one is dropped, so even N == O would be a no-op rather than a use-after-free. If set_ssl_ctx() reports a non-TLS listener, Rust frees N's single reference and nothing else moves.

Empirically: 50 back-to-back rotations leave sslCtxLiveCount() (the atomic us_ssl_ctx_build_raw bumps and the ex_data free callback decrements) unchanged at its pre-rotation value, and the new tests run under ASAN.

The node->ctx == old check in step 2 is what keeps an addContext("<bind hostname>", ctx) entry from being clobbered: Listener::add_server_name removes and re-adds the node with the caller's own context, so it no longer points at the default one and the migration skips it. There is a test for exactly this.

The ALPNProtocols change

Node's Server.prototype.setSecureContext (lib/_tls_wrap.js) assigns pfx/key/cert/ca/ciphers/minVersion/... and rebuilds _sharedCreds. ALPNProtocols is not in that list; it is only read by the Server constructor. Confirmed against Node 26:

const s = tls.createServer({ key, cert, ALPNProtocols: ["h2", "http/1.1"] });
s.setSecureContext({ key, cert });
s.ALPNProtocols; // <Buffer 02 68 32 08 68 74 74 70 2f 31 2e 31> — unchanged

Clearing it was harmless while setSecureContext() was a no-op. Once the rotation is live it would drop the server's ALPN list mid-flight, and that is precisely the path @grpc/grpc-js takes (Http2SecureServer extends tls.Server, and grpc-js calls setSecureContext() on credential reload). So the two changes have to land together.


The second commit is unrelated to the fix: SNICallback runs even when the requested servername matches the bind hostname was dialing localhost by name after binding to it, which is ECONNREFUSED on a dual-stack host because the two resolutions need not agree (Node splits the same way). It now dials the address the server reports and passes servername explicitly; the property under test is unchanged.

@claude claude Bot 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.

No bugs found, but this spans C/Rust/JS with manual SSL_CTX refcounting and SNI-tree mutation on a live listener — worth a human pass on the lifecycle and the ALPNProtocols behavior change.

Extended reasoning...

Overview

This PR makes tls.Server.setSecureContext() actually rebuild the live TLS context on an already-listening server (previously a silent no-op). It threads a new us_listen_socket_set_ssl_ctx() primitive through uSockets C code, adds Rust FFI bindings in ListenSocket.rs, implements Listener::set_secure_context() in Listener.rs (converting secure_ctx to a Cell and persisting the bind-hostname server_name), and wires it from src/js/node/tls.ts. It also changes setSecureContext() to preserve ALPNProtocols when omitted rather than clearing it. Three new tests cover rotation, SNI/ALPN preservation, and the failure path.

Security risks

This is squarely security-sensitive: it swaps the SSL_CTX that every subsequent accepted connection handshakes with, rewires the SNI servername/select-certificate callbacks onto the fresh context, and moves the bind-hostname's SNI-tree node across contexts. A refcount imbalance here would either leak an SSL_CTX (memory) or free one still referenced by an in-flight accept (UAF in BoringSSL). The C function is careful — it up-refs before freeing so ctx == old is a no-op — and the Rust side re-reads the listener after SSLConfig::from_js (which can run user JS) and frees the new ctx on the non-TLS-listener bail-out. I didn't spot a leak or UAF, but manual refcounting across an FFI boundary in the TLS accept path is exactly the class of change that benefits from a second set of eyes.

Level of scrutiny

High. Per the repo's own review guidance, TLS/crypto paths and manual reference-count balancing across C↔Rust are the most-blocked category. The change also alters user-visible Node-compat behavior (ALPNProtocols is now preserved on omit instead of cleared), which the description justifies against Node semantics but is a behavior change nonetheless.

Other factors

The PR is well-structured with a clear root-cause explanation, differential testing against Node, and an sslCtxLiveCount() leak check across 50 rotations mentioned in the description. The bug-hunting system found nothing. Test coverage looks solid (rotation with SNI, addContext/ALPN survival, bad-cert rollback). The unrelated hunk in the SNICallback bind-hostname test is a flakiness fix (dial the resolved address instead of re-resolving "localhost"). None of that changes the fact that this is a multi-layer TLS lifecycle change that shouldn't be auto-approved.

@robobun
robobun force-pushed the farm/72dcb218/tls-set-secure-context-rebuild branch from 13d135e to 0e96bdc Compare July 10, 2026 15:13

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/js/node/tls.ts (1)

1387-1391: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Normalize rejectUnauthorized to a strict boolean before storing.

This stores the raw options.rejectUnauthorized when defined. The new live-rotation path at Line 1419 (this[buntls](0, undefined, false)[0]) now feeds this._rejectUnauthorized straight into the live native listener context, so a non-boolean falsy value like null (typeof null !== "undefined" is true) is stored verbatim and can weaken client-cert verification on an mTLS server — Node only disables verification when the value is explicitly false.

Root cause is the raw assignment here; the changed rotation call amplifies the impact.

Based on learnings, options.rejectUnauthorized must be normalized to a strict boolean (e.g. rejectUnauthorized !== false) anywhere it is read, including Server.prototype.setSecureContext, because non-boolean falsy values may bypass certificate verification.

🛡️ Proposed fix
       const rejectUnauthorized = options.rejectUnauthorized;

       if (typeof rejectUnauthorized !== "undefined") {
-        this._rejectUnauthorized = rejectUnauthorized;
+        this._rejectUnauthorized = rejectUnauthorized !== false;
       } else this._rejectUnauthorized = rejectUnauthorizedDefault();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/tls.ts` around lines 1387 - 1391, Normalize rejectUnauthorized to
a strict boolean wherever it is read, including the shown initialization logic
and Server.prototype.setSecureContext. Store true unless the option is
explicitly false (for example, using rejectUnauthorized !== false), while
preserving rejectUnauthorizedDefault() when the option is undefined, so live
rotation via buntls receives only boolean values.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/js/node/tls.ts`:
- Around line 1387-1391: Normalize rejectUnauthorized to a strict boolean
wherever it is read, including the shown initialization logic and
Server.prototype.setSecureContext. Store true unless the option is explicitly
false (for example, using rejectUnauthorized !== false), while preserving
rejectUnauthorizedDefault() when the option is undefined, so live rotation via
buntls receives only boolean values.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6de8dfa5-6d7d-49ea-898e-906e08253a40

📥 Commits

Reviewing files that changed from the base of the PR and between f65f769 and 0e96bdc.

📒 Files selected for processing (3)
  • packages/bun-usockets/src/crypto/openssl.c
  • packages/bun-usockets/src/libusockets.h
  • src/js/node/tls.ts

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Re src/js/node/tls.ts:1387-1391 (rejectUnauthorized normalization):

Applied in aff360d — the field is now normalized to a strict boolean (value !== false), matching Node:

undefined -> true    null -> true    0 -> true    "" -> true
false     -> false   true -> true    "yes" -> true

For the record, the rotation path could not actually weaken verification even before the normalization: the native consumer reads the option as IDLNullable<IDLStrictBoolean>, so null mapped to None and fell back to the process default (normally true), while 0 / "" / any other non-boolean threw ERR_INVALID_ARG_TYPE before the context was swapped. The normalization aligns the JS-visible field with Node and removes any doubt.

One more thing worth noting for the reviewer: Node's setSecureContext never touches rejectUnauthorized / requestCert at all (only the constructor sets them). Bun's Server routes the constructor through setSecureContext, so those assignments were already reachable from a post-listen call. The native Listener.reject_unauthorized gate is deliberately left at its listen-time value, so a setSecureContext({rejectUnauthorized: false, ...}) on a strict mTLS server does not relax enforcement for subsequent handshakes — fail-closed, and what Node does.

1 similar comment
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Re src/js/node/tls.ts:1387-1391 (rejectUnauthorized normalization):

Applied in aff360d — the field is now normalized to a strict boolean (value !== false), matching Node:

undefined -> true    null -> true    0 -> true    "" -> true
false     -> false   true -> true    "yes" -> true

For the record, the rotation path could not actually weaken verification even before the normalization: the native consumer reads the option as IDLNullable<IDLStrictBoolean>, so null mapped to None and fell back to the process default (normally true), while 0 / "" / any other non-boolean threw ERR_INVALID_ARG_TYPE before the context was swapped. The normalization aligns the JS-visible field with Node and removes any doubt.

One more thing worth noting for the reviewer: Node's setSecureContext never touches rejectUnauthorized / requestCert at all (only the constructor sets them). Bun's Server routes the constructor through setSecureContext, so those assignments were already reachable from a post-listen call. The native Listener.reject_unauthorized gate is deliberately left at its listen-time value, so a setSecureContext({rejectUnauthorized: false, ...}) on a strict mTLS server does not relax enforcement for subsequent handshakes — fail-closed, and what Node does.

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/runtime/socket/Listener.rs (2)

793-797: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not report a failed native context swap as success.

When set_ssl_ctx returns false, the new context is freed but undefined is returned. The caller therefore observes success while JavaScript credentials and the active native context diverge. Throw an appropriate error while retaining the old context.

As per coding guidelines, never swallow a failure or signal success on one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/socket/Listener.rs` around lines 793 - 797, In the `set_ssl_ctx`
failure branch, do not return `Ok(JSValue::UNDEFINED)` after freeing `ctx`;
preserve the existing context and return an appropriate JavaScript error
instead. Update the surrounding listener method to propagate the failure while
retaining the current cleanup of the newly created native context.

Source: Coding guidelines


756-760: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Rotate TLS contexts for Windows named-pipe listeners.

This returns success for an active TLS NamedPipe, so subsequent connections retain the old certificate while JavaScript state reflects the replacement. Implement the corresponding named-pipe context swap and cover it with a Windows test.

As per coding guidelines, cover every platform branch and the full behavioral variant matrix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/socket/Listener.rs` around lines 756 - 760, The TLS rotation
guard in the listener rotation method incorrectly excludes active Windows
named-pipe listeners. Add the named-pipe branch to swap its active TLS context
while preserving the existing JS-side options, and retain the unlistened/non-TLS
behavior. Update the Windows test coverage to exercise active and unlistened
named pipes, TLS and non-TLS cases, and successful rotation with subsequent
connections using the replacement certificate.

Source: Coding guidelines

src/js/node/tls.ts (1)

1416-1423: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make live secure-context updates transactional.

All TLS fields are committed before the native rebuild. If rebuilding throws, the old listener context remains active but this.key, this.cert, and related fields retain the rejected configuration; a later re-listen can then fail unexpectedly. Build from candidate values and commit only after success, or restore the previous state in catch.

As per coding guidelines, error paths must leave cross-layer state consistent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/tls.ts` around lines 1416 - 1423, Make setSecureContext’s live
update transactional: preserve the existing TLS fields, construct the native
listener context from the candidate configuration, and only commit the candidate
fields after setListenerSecureContext succeeds. If rebuilding throws, restore or
retain every related field (including key, cert, and options) so JavaScript and
native listener state remain consistent.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/js/node/tls.ts`:
- Around line 1416-1423: Make setSecureContext’s live update transactional:
preserve the existing TLS fields, construct the native listener context from the
candidate configuration, and only commit the candidate fields after
setListenerSecureContext succeeds. If rebuilding throws, restore or retain every
related field (including key, cert, and options) so JavaScript and native
listener state remain consistent.

In `@src/runtime/socket/Listener.rs`:
- Around line 793-797: In the `set_ssl_ctx` failure branch, do not return
`Ok(JSValue::UNDEFINED)` after freeing `ctx`; preserve the existing context and
return an appropriate JavaScript error instead. Update the surrounding listener
method to propagate the failure while retaining the current cleanup of the newly
created native context.
- Around line 756-760: The TLS rotation guard in the listener rotation method
incorrectly excludes active Windows named-pipe listeners. Add the named-pipe
branch to swap its active TLS context while preserving the existing JS-side
options, and retain the unlistened/non-TLS behavior. Update the Windows test
coverage to exercise active and unlistened named pipes, TLS and non-TLS cases,
and successful rotation with subsequent connections using the replacement
certificate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 97b127c4-5835-42e5-b828-6299b83ed2c2

📥 Commits

Reviewing files that changed from the base of the PR and between 0e96bdc and aff360d.

📒 Files selected for processing (2)
  • src/js/node/tls.ts
  • src/runtime/socket/Listener.rs

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Addressing the three findings in the latest review:

set_ssl_ctx returning false (Listener.rs:793-797) — kept as a no-op, documented as unreachable with a debug_assert! (6829525). us_listen_socket_set_ssl_ctx returns 0 only when ls->ssl_ctx is null, and this.ssl == true means listen() stored a non-null one; the only path that clears it is us_listen_socket_close, after which this.listener is None and the function already returned above. Throwing would turn a proven-impossible state into a user-facing error class.

Windows named-pipe TLS listeners (Listener.rs:756-760) — good catch, fixed in 6829525. WindowsNamedPipeListeningContext.ctx is now a Cell and set_secure_context swaps it the same way. Each accept SSL_CTX_up_refs per connection, so the retired context stays alive for established clients. rust:check-all passes on all 10 targets; Windows CI will exercise the existing named-pipe TLS test.

Transactional JS fields (tls.ts:1416-1423) — not changing this. Node is not transactional here either, verified against Node 26:

const s = tls.createServer({key, cert});          // s.cert.length == 1452
s.setSecureContext({key, cert: badPEM});          // throws ERR_OSSL_ASN1_HEADER_TOO_LONG
s.cert.length                                     // == 58 (the bad PEM — Node kept it)

Node's setSecureContext assigns this.key/this.cert/this.ca first and then calls tls.createSecureContext(options), which is where it throws. It leaves the JS fields at the rejected values. Making Bun roll them back would be a divergence. The live native context is left untouched on throw, which is the property the test asserts.

Comment thread src/js/node/tls.ts
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on this branch at bb4392c (build 84990): the diff is green for its scope. node-tls-server.test.ts and bun-serve-ssl.test.ts pass on every lane that ran and appear in zero annotations. 192+ lanes passed; the only annotations are WARNING[flaky] (retried-and-passed, none TLS) and INFO[binary-size].

The only non-passing lanes never ran a test:

  • darwin-14-aarch64 and darwin-26-aarch64 expired repeatedly in the macOS agent queue (exit=-1, never started; Buildkite keeps requeueing them)
  • darwin-14-x64 landed on agent macOS-13-x64-1 twice and failed the checkout step both times with Error: cloning git repository: exit status 128 — the agent cannot clean its builds directory (unlinkat .../test/node_modules/.bun/@types+nlcst@2.0.3: directory not empty)

Both are macOS CI fleet issues, not this diff.

@claude claude Bot 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.

The requestCert/rejectUnauthorized fix in 1396bff looks right and the two new tests cover both failure modes I flagged. Deferring to a human for the overall SSL_CTX refcount accounting across the C/Rust boundary and the verify-mode semantics — this is live TLS certificate rotation and worth a second pair of eyes.

Extended reasoning...

Overview

This PR makes tls.Server.prototype.setSecureContext() actually rebuild the live SSL_CTX on a listening server (previously a silent no-op). It adds us_listen_socket_set_ssl_ctx() in usockets C, a Rust FFI wrapper in ListenSocket.rs, Listener::set_secure_context() in Listener.rs (plus converting secure_ctx to a Cell and the Windows named-pipe ctx likewise), the JS-side call in tls.ts with preserved ALPNProtocols/requestCert/rejectUnauthorized and the net.ts verify-mode clamp, and five new tests in node-tls-server.test.ts.

Security risks

This is squarely security-sensitive: it swaps the live SSL_CTX on a TLS listener, re-arms SNI callbacks, migrates the bind-hostname SNI-tree entry, and derives the rebuilt context's SSL_VERIFY_* mode from JS-side requestCert/rejectUnauthorized. My earlier review caught that the rotation path bypassed net.ts's listen-time !requestCert → rejectUnauthorized=false clamp and cleared _requestCert on omission — both would have shifted the verify mode on rotation (certless clients rejected, or mTLS servers silently dropping CertificateRequest). That's fixed in 1396bff with tests. The refcount accounting (three owned refs per context, up-ref-before-free ordering, ctx == old no-op safety) is documented in a PR comment and reads correctly to me, and the rejectUnauthorized normalization is fail-closed (!== false).

Level of scrutiny

High. This is production-critical TLS certificate rotation touching BoringSSL SSL_CTX lifetime management across a C/Rust FFI boundary, SNI-tree mutation, and Node-compat verify-mode semantics. A mistake here is either a UAF on SSL_CTX, a leaked context per rotation, or a silent weakening/strengthening of client-cert enforcement after a cert renew — none of which fail loudly.

Other factors

The PR has been through several review rounds with substantive fixes (aff360d normalized rejectUnauthorized, 6829525 added the Windows named-pipe swap and the debug_assert! on the unreachable non-TLS-listener branch, 1396bff fixed the verify-mode regression I flagged). Test coverage is good — five new cases including the two verify-mode regressions, the failure path, and SNI/ALPN survival — and the author reports sslCtxLiveCount() is stable across 50 rotations under ASAN. The bug hunter found nothing on the current head. I'm comfortable with the JS-side changes; the cross-language refcount plumbing and the SNI-tree migration in openssl.c are where I'd want a maintainer who knows that code to sign off.

@robobun
robobun force-pushed the farm/72dcb218/tls-set-secure-context-rebuild branch from 1396bff to 9f06e57 Compare July 12, 2026 14:39

@claude claude Bot 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.

The verify-mode issue I flagged earlier is addressed in 9f06e57_requestCert/_rejectUnauthorized are now preserved when omitted and the net.ts listen-time clamp is applied before the native rebuild, with two new tests covering both directions. No further issues found this pass.

That said, this is live SSL_CTX rotation with hand-tracked refcounts across C/Rust/JS plus SNI-tree migration and verify-mode semantics, so it's worth a human sign-off.

What was reviewed:

  • SSL_CTX reference accounting through us_listen_socket_set_ssl_ctxsecure_ctx.replace on both the Uws and Windows named-pipe arms, and the ListenerType::None / !swapped release paths.
  • The SNI-tree migration's node->ctx == old guard vs. addContext() entries, and re-arming of sni_cb/us_select_cert_cb on the fresh context.
  • setSecureContext option handling: ALPNProtocols preserved on omission, rejectUnauthorized normalized to a strict boolean, and the !requestCert → rejectUnauthorized = false clamp matching net.ts's listen path.
Extended reasoning...

Overview

This PR makes tls.Server.prototype.setSecureContext() actually rebuild the live SSL_CTX on a listening server (previously a silent no-op). It spans six files: a new us_listen_socket_set_ssl_ctx() in usockets C (openssl.c, libusockets.h), a Rust FFI wrapper (ListenSocket.rs), Listener::set_secure_context() plus Cell-wrapping of secure_ctx/server_name and the Windows named-pipe context (Listener.rs), the JS integration in tls.ts, and five new tests in node-tls-server.test.ts.

Security risks

This is squarely security-sensitive: it swaps the server's TLS identity mid-flight and touches the CTX-level verify mode (SSL_VERIFY_PEER / SSL_VERIFY_FAIL_IF_NO_PEER_CERT). The verify-mode regression I flagged on a prior revision — where a rotation could either start hard-failing certless clients or stop sending CertificateRequest on an mTLS server — has been fixed and is now covered by two dedicated tests. The rejectUnauthorized normalization is fail-closed (value !== false), and the native Listener.reject_unauthorized gate is deliberately left at its listen-time value so a rotation cannot relax post-handshake enforcement. The C swap takes every new reference before dropping any old one, so ctx == old is a safe no-op rather than a UAF.

Level of scrutiny

High. Manual SSL_CTX refcounting across a C/Rust FFI boundary, an SNI-tree pointer rewrite, and verify-mode semantics that gate client-cert enforcement are exactly the kind of change CLAUDE.md flags for human review. The refcount table in the PR thread checks out against the code, and the author reports sslCtxLiveCount() is stable across 50 rotations under ASAN, but a maintainer familiar with the usockets SSL lifecycle should confirm the SNI-tree migration and the Windows named-pipe arm.

Other factors

The PR has already been through two substantive review rounds (Windows named-pipe support added, rejectUnauthorized normalization, the verify-mode fix), each with new tests. The five new tests exercise the swap itself, SNI/ALPN survival, the failure path, and both verify-mode directions; the PR description documents differential testing against Node 26. The unrelated fetch-gzip CI failure is confirmed to reproduce on main. Given the security surface and the cross-language refcount choreography, deferring to a human reviewer rather than auto-approving.

@claude claude Bot 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.

The requestCert/rejectUnauthorized fix in 9f06e57 addresses my earlier finding — the clamp is now applied before the rebuild and both fields are preserved on omission, with tests covering both directions. No further issues found this pass, but given the scope (live SSL_CTX swap with manual refcounting across the C/Rust/JS boundary, verify-mode handling, plus a Windows-only named-pipe arm) this should get a human review before merging.

Checked this run: the SSL_CTX reference accounting in us_listen_socket_set_ssl_ctx (up_ref before free, SNI-node migration guarded by node->ctx == old); the ListenerType::None re-read after SSLConfig::from_js in case user JS closed the server mid-parse; the secure_ctx Cell release in deinit and the error-path cleanup guard; and that on_server_name is not re-armed on rotation (it doesn't need to be — us_select_cert_cb is reinstalled from ls->on_server_name inside the C swap).

Extended reasoning...

Overview

This PR makes tls.Server.prototype.setSecureContext() actually rebuild the live SSL_CTX on a listening server instead of only updating JS-side fields. It adds us_listen_socket_set_ssl_ctx() in usockets (C), a Rust FFI wrapper in ListenSocket.rs, Listener::set_secure_context() in Listener.rs (with a Windows named-pipe branch), and wires it from tls.ts's setSecureContext. It also fixes setSecureContext clearing ALPNProtocols and (after my earlier review) preserves requestCert/rejectUnauthorized across rotations and re-applies net.ts's listen-time verify-mode clamp. Five new tests cover cert rotation, SNI/ALPN survival, failure-path preservation, and the two verify-mode regressions.

Security risks

This is squarely in security-sensitive territory: it swaps the SSL_CTX a TLS server uses for subsequent handshakes, moves an SNI-tree entry, reinstalls SNI callbacks, and touches how SSL_VERIFY_PEER / SSL_VERIFY_FAIL_IF_NO_PEER_CERT are derived on the rebuilt context. A refcount imbalance is a UAF on an SSL_CTX; a wrong verify-mode derivation either locks out all clients or silently stops requesting client certificates on an mTLS server. The PR thread contains a detailed refcount accounting (3 owned refs before and after, every up_ref precedes its matching free) and reports sslCtxLiveCount() unchanged over 50 rotations under ASAN, which is reassuring but doesn't remove the need for a human pass on the C swap and the Windows arm.

Level of scrutiny

High. Per the repo guidelines this falls under "security-sensitive code (auth, crypto, permissions)" — live TLS certificate/verify-mode rotation with manual SSL_CTX_up_ref/SSL_CTX_free across an FFI boundary, plus a #[cfg(windows)] branch that CI on this run may or may not have exercised. The change is well-reasoned and well-tested, but it is not mechanical.

Other factors

My earlier finding (rotation shifting the CTX-level verify mode) was addressed in 9f06e57 with the exact fix suggested plus two tests that fail on the prior commit. The remaining CI red (fetch-gzip.test.ts, a napi flake) was investigated by the author and reproduces on main with this diff stashed, so it's unrelated. The ALPNProtocols behavior change is a Node-compat correction bundled with this PR because it becomes load-bearing once rotation is live — that coupling is explained in the thread. Given all that, deferring rather than approving.

robobun added 6 commits July 29, 2026 05:37
The native SSL_CTX is built once, from the server's options, when listen()
runs. setSecureContext() only reassigned the JS-side option fields, so a
listening server kept serving the certificate it started with and live
certificate rotation silently did nothing.

Rebuild the context and swap it into the listen socket, carrying the SNI
tree entry the bind hostname was registered under. Sockets already accepted
keep the certificate they handshook with; subsequent handshakes get the new
one, matching Node.

Also stop clearing ALPNProtocols: Node's setSecureContext() replaces the
default context only and leaves the server's ALPN list alone.

Unrelated to the fix, make the SNICallback bind-hostname test dial the
address listen() reported instead of re-resolving "localhost"; on a
dual-stack host the two resolutions need not agree (Node splits the same
way), so the test was ECONNREFUSED there.
Node only disables verification on an explicit `false`; null, 0 and the
empty string all keep it enabled. setSecureContext() now feeds this field
back into the native listener on every rotation, so fail closed.
set_secure_context() early-returned for ListenerType::NamedPipe, so a TLS
server listening on a Windows named pipe kept serving its original
certificate after setSecureContext(). Swap WindowsNamedPipeListeningContext.ctx
the same way; each accept up_refs per connection so the retiring context
stays alive for established clients.

Also documents (via debug_assert) why us_listen_socket_set_ssl_ctx cannot
report failure on the Uws arm.
…text()

The rotation path handed the raw [buntls] output to the native context
builder, bypassing the clamp net.ts applies at listen() time
(`if (!tls.requestCert) tls.rejectUnauthorized = false`). On a server with
`ca` but no `requestCert` the rebuilt context ended up with
SSL_VERIFY_FAIL_IF_NO_PEER_CERT, so every certless client was aborted at the
handshake after a rotation.

setSecureContext() was also clearing _requestCert when omitted, so a
key/cert-only rotation on an mTLS server rebuilt the context with
request_cert=0 and stopped sending CertificateRequest. Node's
setSecureContext() never touches requestCert/rejectUnauthorized; only the
constructor sets them. Keep both when omitted, normalize rejectUnauthorized
to a strict boolean when passed, and apply the listen-time clamp before
rebuilding.
…te it

server.reload({tls}) was a silent no-op: ServerConfig::from_js parsed the
replacement tls options, but on_reload_from_zig never looked at ssl_config,
so new connections kept receiving the original certificate and a mismatched
key/cert pair or garbage PEM was accepted without error.

reload() now builds the replacement SSL_CTX up front, so a bad PEM
(ERR_OSSL_PEM_NO_START_LINE) or key/cert mismatch
(ERR_OSSL_X509_KEY_VALUES_MISMATCH) throws the same error Bun.serve()
throws at startup and no handler or route is swapped. A valid rotation is
applied to the listen socket via us_listen_socket_set_ssl_ctx, so
subsequent handshakes use the new certificate while connections already
accepted keep the one they handshook with.

The --hot reload path goes through the same on_reload_from_zig, so it
picks up the rotation too.
@robobun
robobun force-pushed the farm/72dcb218/tls-set-secure-context-rebuild branch from d2f56c4 to 99b40c9 Compare July 29, 2026 06:12
Comment thread src/js/node/tls.ts
Comment thread src/js/node/tls.ts
Comment thread src/js/node/tls.ts
Comment thread src/runtime/server/server_body.rs
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/socket/Listener.rs
Comment thread src/runtime/socket/Listener.rs
Comment thread src/runtime/socket/Listener.rs
Comment thread src/runtime/socket/Listener.rs
Comment thread src/runtime/socket/Listener.rs
Comment thread src/runtime/socket/Listener.rs
Comment thread src/uws_sys/App.rs Outdated
Comment thread src/uws_sys/ListenSocket.rs Outdated
Comment thread src/uws_sys/ListenSocket.rs Outdated
@robobun robobun changed the title node:tls: make Server.setSecureContext() rebuild the live TLS context tls: rotate the live certificate on setSecureContext() and Bun.serve reload({tls}) Jul 29, 2026
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs
Comment thread src/uws_sys/ListenSocket.rs Outdated
robobun added 2 commits July 29, 2026 06:39
…the rebase

Main removed it in #35002; the rebase onto that change resolved the
ListenSocket.rs conflict by keeping both sides, which re-introduced the
wrapper, its FFI extern, and the NonNull import with no callers.
…ls})

uWS's addServerName registers a separate per-domain context in the SNI tree,
not the app's default ssl_ctx, so us_listen_socket_set_ssl_ctx's
`node->ctx == old` check never held on that path and a client sending SNI
matching the configured serverName kept receiving the retired certificate.
Add a force flag so the Bun.serve caller (which has no addContext) moves the
entry unconditionally; node:tls keeps the heuristic so an addContext() entry
on the bind hostname survives.
Comment thread src/uws_sys/App.rs
Comment thread src/uws_sys/ListenSocket.rs
Comment thread src/runtime/server/server_body.rs
Comment thread packages/bun-usockets/src/crypto/openssl.c Outdated
Comment thread src/runtime/server/server_body.rs Outdated
… context; read serverName from its listen-time slot

Two follow-ups to cac9561 for Bun.serve({tls:{serverName}}).reload({tls}):

- us_listen_socket_set_ssl_ctx no longer sets us_sni_ex_idx on the rotated
  context. That context is also ls->ssl_ctx, so stamping node->user (uWS's
  per-domain HttpRouter*) onto it routed every no-SNI client via that router,
  whose UserRoute* entries were freed when reload() rebuilt self.user_routes.
  After the swap node->ctx == ls->ssl_ctx anyway, so both SNI-matching and
  no-SNI clients fall through to the freshly reloaded default router.

- on_reload_from_zig read the SNI-migration hostname from self.config.ssl_config
  and then overwrote it with the incoming config, so a reload that omitted or
  changed serverName made the next one pass the wrong hostname and skip the
  SNI swap. Store the listen-time name once on the server struct (same as
  Listener.server_name on the node:tls path) and read that instead.
Comment thread src/runtime/server/mod.rs
Comment thread src/runtime/server/server_body.rs
Comment thread src/js/node/tls.ts
…ve rebuild throws

The next-staging pattern keeps every JS-side validator throw before any
this.* mutation, but the native setListenerSecureContext() call ran after
the commit. A bad PEM or key/cert mismatch left this.cert/this.key at the
rejected values while the native listener still served the previous
certificate, so a later listen() or the STARTTLS wrap (both of which read
those fields directly) failed with the same OSSL error the caller had
already caught. Snapshot before committing and restore on throw.
Comment thread src/js/node/tls.ts

@claude claude Bot 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.

No new findings on this pass — every issue raised in earlier rounds (dead find_server_name_userdata binding, force_sni ex_data stamping the domainRouter onto the default ctx, serverName drift across reloads, torn this.cert on a failing native rebuild) has been addressed and is now covered by a test. Deferring to a human because this is ~580 lines across the TLS certificate-rotation path with manual SSL_CTX refcounting over FFI, which is squarely in the security-sensitive / memory-safety category the approval bar excludes.

What was reviewed:

  • us_listen_socket_set_ssl_ctx refcount ordering — up_ref before free on both ls->ssl_ctx and the SNI node; ctx == old is a no-op, not UAF.
  • Listener::set_secure_context — the fresh ctx is freed on every non-swap path (!swapped, ListenerType::None after re-read, NamedPipe replace); secure_ctx.replace drops exactly the listener's prior ref.
  • on_reload_from_zig throw ordering — context build fails before clear_routes()/handler swap; SSL_CTX_free runs regardless of swapped.
  • tls.ts snapshot/restore — commit(prev) on throw keeps this.cert/buildSharedCreds consistent; the !requestCert => rejectUnauthorized=false clamp mirrors net.ts's listen path.
Extended reasoning...

Overview

Wires live TLS certificate rotation on both server surfaces: tls.Server.prototype.setSecureContext() and Bun.serve().reload({tls}). Adds a new usockets C primitive us_listen_socket_set_ssl_ctx() that swaps a listen socket's default SSL_CTX, re-arms SNI callbacks, and migrates the bind-hostname SNI-tree entry. Rust wrappers land in uws_sys/ListenSocket.rs and uws_sys/App.rs; Listener::set_secure_context() and on_reload_from_zig() drive them. tls.ts gains a snapshot/commit/restore around the now-fallible native rebuild, plus ALPNProtocols/requestCert preservation fixes. Seven new tests across two files.

Security risks

This is TLS credential-handling code. The interesting failure modes are (a) leaking or double-freeing SSL_CTX refs across the swap, (b) leaving an SNI-tree entry pointing at a freed context, (c) silently accepting a bad rotation, and (d) changing verify-mode semantics (requestCert/rejectUnauthorized) on rotation. Each is exercised: refcounts are up-then-down on both the default and node contexts; the SNI node's ctx is replaced only when it pointed at the retiring default (or force_sni for uWS's separate domainCtx); bad PEM / key-cert mismatch throws before any swap on both surfaces; and the !requestCert => rejectUnauthorized=false clamp is applied before rebuild with a dedicated test. The earlier UAF (per-domain router ex_data on the rotated default ctx) was fixed in 5a6ac3b and has a regression test.

Level of scrutiny

High. Manual SSL_CTX_up_ref/SSL_CTX_free across a C/Rust FFI boundary, on a hot path that every accepted TLS socket depends on, with two intentionally-scoped-out variants (h3, array-form tls) documented in the description. This went through 8 iterations with two 🔴-severity fixes during review. It is well past the "simple/mechanical" bar for auto-approval.

Other factors

Test coverage is thorough for the in-scope surface (rotation observed via CN, SNI-match and no-SNI clients, addContext survival, ALPN survival, failure-path rollback, requestCert preservation, close+relisten after a rejected rotation). All prior review threads are resolved. The Windows named-pipe ctx field change from Option<NonNull<...>> to Cell<Option<NonNull<...>>> looks mechanically correct (get_accepted_by up_refs per accept, deinit frees via .take()), but I could not exercise it. Given the security-sensitive surface and the review history, a human should sign off.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant