Skip to content

node:tls: keep ALPN across an SNI SecureContext selection - #33253

Open
robobun wants to merge 5 commits into
mainfrom
farm/73b69977/tls-sni-alpn
Open

robobun wants to merge 5 commits into
mainfrom
farm/73b69977/tls-sni-alpn

Conversation

@robobun

@robobun robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator

What

Any SecureContext selected through SNI silently drops the server's ALPN configuration. An h2/gRPC client that negotiates h2 against the default context gets no ALPN at all (alpnProtocol === false on both ends) once SNICallback or addContext picks a per-domain certificate, and nothing logs why. A genuine ALPN mismatch also completes the handshake instead of sending the RFC 7301 no_application_protocol alert. This affects every SNI/multi-domain tls/https/http2 server, which is exactly the deployment shape that needs ALPN.

const srv = tls.createServer({
  key, cert,
  ALPNProtocols: ["h2", "http/1.1"],
  SNICallback(name, cb) {
    cb(null, tls.createSecureContext({ key, cert })); // the standard multi-domain pattern
  },
});
// client: tls.connect({ servername: "a.example.com", ALPNProtocols: ["h2"], ... })
// node: socket.alpnProtocol === "h2"
// bun:  socket.alpnProtocol === false

All three paths that hand a context to the SNI machinery reproduce it: a synchronous SNICallback, an asynchronous SNICallback (the resumeSNI resume), and server.addContext(). The same loss also silences a server ALPNCallback whenever an SNICallback is present, which is the remaining half of #17932 (its exact SNICallback + ALPNCallback repro gives alpnCalled: 0, alpnProtocol: false before this change and matches Node after it). It is invisible in single-cert tests because without SNI the default context never gets replaced.

Fixes #17932

Why

BoringSSL resolves the server ALPN selection callback off the current ssl->ctx (ssl_negotiate_alpn, vendor/boringssl/ssl/extensions.cc), and resolves it after the SNI callbacks have run. Bun registered its selector (select_alpn_callback in src/runtime/socket/socket_body.rs) only on the listener's default SSL_CTX, in on_open at accept time. Every SNI selection path installs a different context with SSL_set_SSL_CTX (us_select_cert_cb / sni_cb in packages/bun-usockets/src/crypto/openssl.c). The freshly created SecureContext has no alpn_select_cb, so BoringSSL's "ignore ALPN if not configured" branch fires and ALPN is skipped without an error.

Node never hits this because it copies only the certificate/key/chain from the SNI-selected SecureContext onto the connection; its connection-level ALPN handling is independent of which context SNI selected.

Fix

The selector already dispatches entirely through per-SSL state (it reads the TLSSocket back from the ex_data slot on_open sets, never from the SSL_CTX), so the context it is registered on does not matter as long as it is the one BoringSSL looks at. Add a helper that installs it on a given SSL_CTX, and call it from the three places a context reaches the native SNI machinery:

  • decode_sni_result (src/runtime/socket/Listener.rs), which us_dispatch_server_name calls to turn the synchronous SNICallback result into the SSL_CTX* handed back to C.
  • resume_sni (src/runtime/socket/socket_body.rs): the asynchronous SNICallback result.
  • Listener::add_server_name (src/runtime/socket/Listener.rs): addContext / addServerName static SNI tree entries.

The selector returns SSL_TLSEXT_ERR_NOACK when the per-SSL ex_data is unset, so installing it on a shared or user-owned context is inert for connections that do not negotiate ALPN, including clients (BoringSSL only invokes it server-side).

Not changed: the bind-hostname SNI tree entry points at the listener's own default context, which on_open already registers. setKeyCert also calls SSL_set_SSL_CTX, but Node restricts it to ALPNCallback, which runs from inside BoringSSL's ALPN negotiation after the callback has already been resolved, so it cannot drop ALPN.

Tests

test/js/node/tls/node-tls-context.test.ts, "ALPN survives an SNI-selected SecureContext": five cases covering the synchronous SNICallback, the asynchronous SNICallback, addContext, the ALPNCallback (the other consumer of the same selector), and the fatal no_application_protocol alert on a genuine mismatch.

Before / after
$ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-context.test.ts
 7 pass
 5 fail
(fail) ALPN survives an SNI-selected SecureContext > negotiates the server's ALPNProtocols: synchronous SNICallback
       Expected: "h2"   Received: false
(fail) ... asynchronous SNICallback
(fail) ... addContext
(fail) ... still consults the server's ALPNCallback
(fail) ... sends the fatal no_application_protocol alert on a genuine mismatch
       error: handshake succeeded with alpnProtocol=false

$ bun bd test test/js/node/tls/node-tls-context.test.ts
 12 pass
 0 fail

Also green with the fix: test/js/node/tls/ (the three remaining failures there reproduce with this diff stashed), test-tls-sni-option.js, test-tls-empty-sni-context.js, test-tls-snicallback-error.js, test-tls-psk-alpn-callback-exception-handling.js, test-tls-secure-context-usage-order.js.


[human-review] gate passed · iteration 9 · 3 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-context.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/node/tls/node-tls-context.test.ts:
(pass) tls.Server > addContext [839.84ms]
(pass) tls.Server > should select the most recently added SecureContext [130.96ms]
(pass) tls.Server > should allow multiple CA [193.47ms]
(pass) tls.Server > should allow multiple CA in newline-separated strings [57.69ms]
(pass) tls.Server > SNI tls.Server + tls.connect [264.73ms]
407 |         ALPNProtocols: ["h2"],
408 |         rejectUnauthorized: false,
409 |       });
410 |       client.on("error", serverSide.reject);
411 |       await once(client, "secureConnect");
412 |       expect({ client: client.alpnProtocol, server: await serverSide.promise }).toEqual({
                                                                                      ^
error: expect(received).toEqual(expected)

  {
-   "client": "h2",
-   "server": "h2",
+   "client": false,
+   "server": false,
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/node/tls/node-tls-conte
... (truncated)

release without fix: 5 FAILED
bun test v1.4.1-canary.1 (a6c4cc276)

test/js/node/tls/node-tls-context.test.ts:
(pass) tls.Server > addContext [21.87ms]
(pass) tls.Server > should select the most recently added SecureContext [6.61ms]
(pass) tls.Server > should allow multiple CA [4.43ms]
(pass) tls.Server > should allow multiple CA in newline-separated strings [2.60ms]
(pass) tls.Server > SNI tls.Server + tls.connect [11.45ms]
407 |         ALPNProtocols: ["h2"],
408 |         rejectUnauthorized: false,
409 |       });
410 |       client.on("error", serverSide.reject);
411 |       await once(client, "secureConnect");
412 |       expect({ client: client.alpnProtocol, server: await serverSide.promise }).toEqual({
                                                                                      ^
error: expect(received).toEqual(expected)

  {
-   "client": "h2",
-   "server": "h2",
+   "client": false,
+   "server": false,
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/node/tls/node-tls-context.test.ts:412:81)
(fail) ALPN survives an SNI-selected SecureContext > negotiates the server's ALPNProtocols: synchronous SNICallback [4.69ms]
407 |         ALPNProtocols:
... (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/pr_gate.xml" test/js/node/tls/node-tls-context.test.ts
bun test v1.4.1 (a6c4cc276)

test/js/node/tls/node-tls-context.test.ts:
(pass) tls.Server > addContext [755.87ms]
(pass) tls.Server > should select the most recently added SecureContext [135.77ms]
(pass) tls.Server > should allow multiple CA [186.57ms]
(pass) tls.Server > should allow multiple CA in newline-separated strings [59.20ms]
(pass) tls.Server > SNI tls.Server + tls.connect [260.59ms]
(pass) ALPN survives an SNI-selected SecureContext > negotiates the server's ALPNProtocols: synchronous SNICallback [157.00ms]
(pass) ALPN survives an SNI-selected SecureContext > negotiates the server's ALPNProtocols: asynchronous SNICallback [49.18ms]
(pass) ALPN survives an SNI-selected SecureContext > negotiates the server's ALPNProtocols: addContext [47.24ms]
(pass) ALPN survives an SNI-selected SecureContext > still consults the server's ALPNCallback [74.28ms]
(pass) ALPN survives an SNI-selected SecureContext > sends the fatal no_application_protocol alert on a genuine mismatch [72.35ms]
(pass) Bun.
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 595ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 244 extern-C blocks audited
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 5m 16s
[2/5] link bun-profile
[4/5] strip bun
[4/5] bun-profile --revision
1.4.1-canary.1+77846daff
[build] done
bun test v1.4.1-canary.1 (77846daff)

test/js/node/tls/node-tls-context.test.ts:
(pass) tls.Server > addContext [21.66ms]
(pass) tls.Server > should select the most recently added SecureContext [6.55ms]
(pass) tls.Server > should allow multiple CA [4.27ms]
(pass) tls.Server > should allow multiple CA in newline-separated strings [2.54ms]
(pass) tls.Server > SNI tls.Server + tls.connect [11.72ms]
(pass) ALPN survives an SNI-selected SecureContext > negotiates the server's ALPNProtocols: synchronous SNICallback [4.37ms]
(pass) ALPN survives an SNI-selected Secu
... (truncated)
diff hotspot
src/runtime/socket/Listener.rs            |   6 +-
 src/runtime/socket/socket_body.rs         |  14 ++++
 test/js/node/tls/node-tls-context.test.ts | 125 ++++++++++++++++++++++++++++++
 3 files changed, 144 insertions(+), 1 deletion(-)

gate history · 2 passed · 0 rejected · iteration 9

evidence per changed file
file                                       reads  edits  tests
src/runtime/socket/Listener.rs                 7      9     29
src/runtime/socket/socket_body.rs              7      6     29
test/js/node/tls/node-tls-context.test.ts      3      6     29

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

robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:08 AM PT - Sep 1st, 2026

✅ @robobun, your commit 77846daffdb9399317f3cf7be310249842ab8b17 passed in Build #109014! 🎉


🧪   To try this PR locally:

bunx bun-pr 33253

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

bun-33253 --bun

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: bfb2ebe9-a02c-4cb3-a12f-a36a6df765c8

📥 Commits

Reviewing files that changed from the base of the PR and between e2eac5f and 77846da.

📒 Files selected for processing (3)
  • src/runtime/socket/Listener.rs
  • src/runtime/socket/socket_body.rs
  • test/js/node/tls/node-tls-context.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 7 remain after this review.


Walkthrough

SNI-selected SSL contexts now install the ALPN selector before registration or use during an in-flight handshake. Tests cover synchronous and asynchronous SNICallback, addContext, ALPNCallback, and protocol mismatch errors.

Changes

TLS SNI and ALPN integration

Layer / File(s) Summary
Install ALPN selectors on SNI contexts
src/runtime/socket/Listener.rs, src/runtime/socket/socket_body.rs
A helper installs the ALPN selector on non-null SSL contexts. Listener registration, dynamic SecureContext decoding, and SNI resumption use the helper.
Validate SNI and ALPN negotiation
test/js/node/tls/node-tls-context.test.ts
Tests cover synchronous and asynchronous SNI callbacks, addContext, ALPNCallback invocation, successful protocol selection, and protocol mismatch errors.

Suggested reviewers: jarred-sumner, cirospaciari, dylan-conway

Merge Risk: ⚪ Minimal · up to 77846

The PR fixes ALPN negotiation for SNI-selected TLS contexts, and the supplied current-head ASAN and release tests pass. No actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses the ALPN portion of issue #17932. It preserves ALPNProtocols, continues invoking ALPNCallback, and produces no_application_protocol for genuine mismatches across synchronous SNICallba…
Out of Scope Changes check ✅ Passed The source changes directly implement ALPN preservation for SNI-selected contexts. The added tests cover the stated regression cases. No unrelated code changes are identified.
Description check ✅ Passed The description clearly explains the ALPN/SNI fix, affected paths, implementation, issue linkage, and verification results. It uses a different heading from the template for the change summary and doe…
Title check ✅ Passed The title is concise, specific, and accurately summarizes the primary change: preserving ALPN when SNI selects a SecureContext.
Full details: Linked Issues check

Explanation

The PR addresses the ALPN portion of issue #17932. It preserves ALPNProtocols, continues invoking ALPNCallback, and produces no_application_protocol for genuine mismatches across synchronous SNICallback, asynchronous SNICallback, and addContext paths.

Full details: Description check

Explanation

The description clearly explains the ALPN/SNI fix, affected paths, implementation, issue linkage, and verification results. It uses a different heading from the template for the change summary and does not include the exact verification heading, but it provides the required information in detail.


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

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. SNICallback and ALPNCallback not triggered in Bun when creating TLS server #17932 - SNICallback and ALPNCallback not triggered: this PR fixes ALPN being silently dropped when SNICallback selects a different SecureContext, which is exactly the broken SNI+ALPN interaction reported
  2. HTTP/1.1 fallback broken for node:http2 secure server (allowHTTP1 ignored, ALPN only advertises h2) #26721 - HTTP/1.1 fallback broken on node:http2 secure server (allowHTTP1 ignored, ALPN only advertises h2): the ALPN configuration loss on SNI-selected contexts could cause this mismatch
  3. tls: sni_cb should return SSL_TLSEXT_ERR_ALERT_FATAL when SNICallback throws or returns non-SecureContext #31580 - sni_cb should return SSL_TLSEXT_ERR_ALERT_FATAL when SNICallback throws: the PR's fix for genuine ALPN mismatches now properly sending the RFC 7301 no_application_protocol fatal alert addresses the fail-closed behavior requested

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #17932
Fixes #26721
Fixes #31580

🤖 Generated with Claude Code

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Verified all three against this branch:

Comment thread test/js/node/tls/node-tls-context.test.ts Outdated

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

Thanks for the await using fix — no further issues on my end, but since this touches the TLS handshake path and installs the ALPN callback onto user-supplied SSL_CTXs, I'd like a human reviewer to sign off.

Extended reasoning...

Overview

This PR fixes ALPN negotiation being silently dropped whenever SNI selects a non-default SecureContext. The native change is small and surgical: a new install_sni_alpn_selector() helper in src/runtime/socket/socket_body.rs that registers the existing select_alpn_callback on a given SSL_CTX, called from the three SNI selection paths (us_dispatch_server_name for sync SNICallback, resume_sni for async SNICallback, and Listener::add_server_name for addContext). The callback itself is unchanged — it already dispatches through the per-SSL ex_data slot and returns NOACK when unset. Five new tests in test/js/node/tls/node-tls-context.test.ts cover all three paths plus ALPNCallback and the RFC 7301 no_application_protocol fatal alert.

Security risks

This is TLS handshake code interfacing directly with BoringSSL. The change adds a callback registration to SSL_CTX objects that previously had none, and those contexts can be user-created (tls.createSecureContext(...)) and potentially shared across servers or reused as client contexts. The PR argues (and select_alpn_callback at socket_body.rs:79 confirms) that the selector is inert when the per-SSL ex_data slot is unset (returns NOACK), and BoringSSL only invokes the ALPN select callback server-side. I don't see a concrete vulnerability, but mutating a user-owned shared SSL_CTX in a security-critical path is exactly the kind of change that benefits from a domain expert's eye — e.g., confirming there's no scenario where a shared context's newly-installed callback overrides an intentionally different ALPN configuration on another listener, or where ex_data slot 0 collides with something else.

Level of scrutiny

High. TLS/ALPN negotiation is security-sensitive and production-critical (every multi-domain HTTPS/h2/gRPC server hits this path). The fix is well-reasoned, the PR description traces the root cause through BoringSSL's ssl_negotiate_alpn, and test coverage is thorough — but per the approval guidelines I should not auto-approve changes to crypto/TLS paths regardless of apparent quality.

Other factors

  • My prior nit (test cleanup ordering) was addressed in 1dda998 with await using and the thread is resolved.
  • The bug-hunting system found no issues.
  • No CODEOWNERS entry covers these files.
  • Tests demonstrate before/after behavior against USE_SYSTEM_BUN=1 and pass locally per the description; the wider test/js/node/tls/ suite and relevant Node parallel tests were also run.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

To save the human reviewer the dig on the two specific concerns raised above:

Shared context overriding another listener's ALPN configuration. It can't. select_alpn_callback reads no state from the SSL_CTX it is registered on (its arg is always NULL): it reads the connection's own TLSSocket back out of the per-SSL ex_data slot and uses that socket's protos/ALPNCallback, which were cloned from its own listener at accept. Two listeners sharing a context therefore each negotiate with their own configuration, and registering the callback on a shared context is already the established pattern here: on_open has always registered this same callback on the listener's default SSL_CTX, which is itself shareable via { secureContext }.

ex_data slot 0 collision. BoringSSL reserves index 0 of the SSL ex_data class for SSL_set_app_data (ExDataClass g_ex_data_class_ssl(/*with_app_data=*/true) in ssl_lib.cc), so every SSL_get_ex_new_index consumer, including the four indices packages/bun-usockets/src/crypto/openssl.c allocates, gets index 1 or higher. The only writer of slot 0 in the tree is the existing on_open registration in socket_body.rs, and it only sets it for the NewSocket server connections this callback dispatches on. Neither the slot nor the write is new in this PR.

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

Thanks for addressing the cleanup nit — no further issues from my side, but since this installs an ALPN selector on user-supplied SSL_CTXs across three TLS handshake paths, I'd like a human familiar with the BoringSSL/SNI layer to confirm the shared-context reasoning before merge.

Extended reasoning...

Overview

The PR fixes ALPN negotiation being silently dropped whenever SNI (via SNICallback, resumeSNI, or addContext) swaps in a fresh SSL_CTX that lacks the ALPN selection callback. The native change is small: a new install_sni_alpn_selector() helper in src/runtime/socket/socket_body.rs that wraps SSL_CTX_set_alpn_select_cb, plus three call sites in Listener.rs / socket_body.rs covering every path a context reaches the SNI machinery. Five new tests in test/js/node/tls/node-tls-context.test.ts cover sync/async SNICallback, addContext, ALPNCallback-with-SNI (#17932), and the RFC 7301 fatal-alert path.

My earlier nit about resource cleanup on assertion failure was addressed in 1dda998 (await using for servers/clients, with a documented try/finally exception for the intentionally-erroring client). The bug-hunting pass found nothing further.

Security risks

This is a TLS handshake code path. The change registers Bun's select_alpn_callback on SSL_CTX objects that may be user-created and/or shared across connections/listeners. The author's argument that this is inert — the callback dispatches entirely through per-SSL ex_data slot 0 (returning SSL_TLSEXT_ERR_NOACK when unset) and reads no state from the SSL_CTX it's registered on — checks out against the source (select_alpn_callback at socket_body.rs:79-226), and the same registration already happens on the listener's default context in on_open. Still, mutating a user-owned SecureContext's underlying SSL_CTX and the cross-listener sharing scenario are exactly the kind of subtle TLS-layer side effects a maintainer should sign off on.

Level of scrutiny

Medium-high. The diff is small and well-reasoned with thorough test coverage, but it sits in production-critical crypto/TLS handshake code (src/runtime/socket/, BoringSSL FFI). Per repo guidelines, security-sensitive changes should get human review regardless of size.

Other factors

  • No CODEOWNERS entry for these paths.
  • The PR description pre-emptively addresses the two non-obvious concerns (shared-context cross-contamination, ex_data slot 0 collision) with source citations, which should make the human review quick.
  • Tests verified to fail on system Bun and pass on the debug build per the description.

@robobun

robobun commented Jul 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Ready to merge: CI is green on the rebased head (build 109014 passed at 77846da, base e2eac5f).

Rebased onto current main per the request above. Conflicts were main's move to the OwnedSslCtx RAII handle and its new decode_sni_result helper in Listener.rs, plus an import block in the test file; the PR's three one-line calls were re-seated on the new code. Details in #33253 (comment).

Verified on the rebased build: all 22 tests in test/js/node/tls/node-tls-context.test.ts pass, and the 5 new cases still fail with origin/main's src/ swapped in (the gate procedure), so the bug is still live upstream.

The two questions the automated reviewer raised (whether installing the ALPN selector on a shared SecureContext can cross-contaminate listeners, and whether SSL ex_data slot 0 can collide with another consumer) are answered in #33253 (comment).

Earlier CI red on this PR (builds 67976, 67990, 70802, all pre-rebase) was infra or pre-existing flake on lanes unrelated to the diff: a darwin-26-aarch64 agent's artifact-download timeout, a MySQL docker container health-check timeout on alpine, Bake dev-server and Bun.WebView tests on two darwin hosts. This PR's own tests passed on every lane in every build.

@robobun
robobun force-pushed the farm/73b69977/tls-sni-alpn branch from 39f515b to f8fd1f1 Compare July 9, 2026 02:17

@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 issues found and my earlier nit is addressed — deferring only because this touches the TLS handshake / BoringSSL ALPN path, which warrants a human sign-off.

Extended reasoning...

Overview

This PR adds a small helper, install_sni_alpn_selector, that registers the existing select_alpn_callback on any SSL_CTX that the SNI machinery may install via SSL_set_SSL_CTX, and calls it from the three SNI selection paths (us_dispatch_server_name for the synchronous SNICallback, resume_sni for the asynchronous one, and Listener::add_server_name for addContext/addServerName). Five new tests in test/js/node/tls/node-tls-context.test.ts cover each path plus the ALPNCallback interaction and the RFC 7301 no_application_protocol alert on a genuine mismatch.

Security risks

The change lives in the TLS handshake path: it mutates user-owned/shared SSL_CTX instances by installing a server-side ALPN selection callback, and it affects whether a mismatched ALPN offer produces a fatal alert. The author's analysis (the selector reads only per-SSL ex_data set in on_open and returns SSL_TLSEXT_ERR_NOACK when unset, so installing it on a shared context is inert for other connections; ex_data slot 0 is BoringSSL's reserved app-data slot and not otherwise written) looks sound and the callback itself is unchanged, but this is exactly the class of change where a subtle assumption error weakens or breaks TLS negotiation.

Level of scrutiny

High. This is production-critical, security-sensitive native code (BoringSSL callback registration inside the SNI/ALPN handshake sequence) rather than a mechanical or config change. The native diff is small and well-argued, but per policy I don't auto-approve changes to crypto/TLS paths.

Other factors

The bug-hunting pass found nothing. My earlier nit about test resource cleanup on the failure path was addressed with await using (and a documented try/finally for the intentionally-erroring client). Tests are hermetic (port: 0, local loopback, rejectUnauthorized: false), assert both client- and server-side alpnProtocol, and were shown to fail against an unfixed binary. The PR description enumerates the sibling SSL_set_SSL_CTX sites and explains why the two untouched ones (bind-hostname SNI tree entry, setKeyCert) don't need the same treatment. Given all that, this looks correct to me — I'm deferring purely because TLS handshake changes should get a human reviewer's sign-off.

@robobun

robobun commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

Issue #41061 is a new report of this bug, reproduced on 1.4.0 and 1.4.1: both the ALPNProtocols + SNICallback and the ALPNCallback + SNICallback shapes negotiate no ALPN, and it blocks mockttp as an HTTP/2 proxy under Bun. The branch currently conflicts with main and needs a rebase.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts rebase

BoringSSL resolves the server-side ALPN selection callback off the
current ssl->ctx. Bun only registered that callback on the listener's
default context (in on_open), so whenever the SNI machinery installed a
different SSL_CTX with SSL_set_SSL_CTX (an SNICallback result, an
asynchronous resumeSNI result, or an addContext/addServerName tree
entry), BoringSSL found no callback at ALPN-negotiation time and
silently skipped ALPN. Every SNI/multi-domain TLS server lost its
ALPNProtocols and ALPNCallback the moment SNI selected a per-domain
certificate (alpnProtocol === false on both ends), and a genuine
mismatch completed the handshake instead of sending the RFC 7301
no_application_protocol alert.

Register the selector on every context the SNI machinery can install.
The selector dispatches through the per-SSL ex_data slot on_open sets
and returns NOACK when it is unset, so installing it on a shared or
user-owned SecureContext is inert for connections that do not negotiate
ALPN.
A failing assertion left the listening server and client socket alive.
Register both for disposal at declaration (await using), and destroy the
expected-to-error client in a finally since a Readable's
Symbol.asyncDispose rejects with the stream's terminal error.
@robobun
robobun force-pushed the farm/73b69977/tls-sni-alpn branch from f8fd1f1 to bd2233f Compare September 1, 2026 07:42
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Comment thread src/runtime/socket/socket_body.rs
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@robobun

robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (e2eac5f, #39848) and force-pushed; the branch is now conflict-free at 77846da.

What the conflicts were, in order:

  • src/runtime/socket/Listener.rs: main replaced the raw SSL_CTX* borrow with the OwnedSslCtx RAII handle (sc.ctx.clone()) and moved the SNICallback result decoding into a decode_sni_result helper. I kept main's structure and moved this PR's one call into that helper, where the context pointer is produced.
  • src/runtime/socket/socket_body.rs: the resume_sni path moved to OwnedSslCtx::into_raw; same one-line call, adjusted to the new type.
  • test/js/node/tls/node-tls-context.test.ts: main added imports above mine. Both sets kept.

Verified on the rebased build: all 22 tests in node-tls-context.test.ts pass (main added 10 to this file since the PR opened), and the 5 new cases still fail with origin/main's src/ swapped in, so the bug is still live upstream. Also green: node-tls-server.test.ts and ssl-ctx-cache.test.ts (91 of 92, the one failure reproduces on main without this diff), plus the Node test-tls-sni-option, test-tls-empty-sni-context, test-tls-snicallback-error, test-tls-psk-alpn-callback-exception-handling, and test-tls-secure-context-usage-order parallel tests.

The two follow-up commits trim the comments the comment-cop flagged: the three call-site comments are gone, and the helper's doc comment is down to the one BoringSSL fact the code depends on.

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

Re-reviewed after the latest push and didn't find any bugs — the earlier cleanup nit is now addressed (tests use await using on the server/client, and the mismatch case wraps the expected-to-error client in try/finally { client.destroy() }). Since this touches the TLS handshake path across three SNI entry points, a human sign-off is still worthwhile.

What was reviewed:

  • install_sni_alpn_selector — null-guarded, registers the existing select_alpn_callback (which already dispatches via per-SSL ex_data and returns NOACK when unset), so installing it on a shared/user-owned ctx is inert for connections that never set the ex_data.
  • Refcount at decode_sni_result: sc.ctx.clone().into_raw() +1 is unchanged and still freed by the C dispatcher after SSL_set_SSL_CTX; the new helper only sets a callback and takes no reference.
  • New tests: port: 0, event-driven waits (once/withResolvers), error events wired to reject, one .toEqual on {client, server}, and the RFC 7301 case asserts the specific ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL code.
Extended reasoning...

Overview

The PR fixes ALPN negotiation being silently dropped when a node:tls server selects a per-domain SecureContext via SNI. It adds a small helper install_sni_alpn_selector(ctx) in src/runtime/socket/socket_body.rs that registers the pre-existing select_alpn_callback on any SNI-selected SSL_CTX, and calls it from three sites: Listener::add_server_name (addContext), decode_sni_result (synchronous SNICallback), and the async resume_sni path in NewSocket. Five new tests in test/js/node/tls/node-tls-context.test.ts cover sync/async SNICallback, addContext, ALPNCallback still firing after SNI (issue #17932), and the RFC 7301 fatal no_application_protocol alert on a genuine mismatch.

Security risks

This is TLS handshake code. The change does not weaken any security check — it restores ALPN negotiation (and with it, the fatal-alert-on-mismatch behaviour) that was being skipped. The callback being installed is the same one already registered on the listener's default context; it reads the TLSSocket back from per-SSL ex_data and returns SSL_TLSEXT_ERR_NOACK when that slot is unset, so registering it on a user-owned or shared SSL_CTX is a no-op for connections that never had the ex_data set (including client-side use of the same context, where BoringSSL doesn't invoke the server ALPN callback anyway). No new trust decisions, no flag removals, no relaxed validation. That said, any change to how BoringSSL callbacks are wired across SSL_set_SSL_CTX deserves a maintainer's eyes.

Level of scrutiny

Medium-high. The native diff is tiny (~15 lines) and mechanical — it reuses an existing callback and existing FFI wrapper — but it sits in the TLS handshake path and touches three distinct SNI entry points, one of which involves an into_raw() refcount hand-off to C. The FFI/refcount shape is unchanged (the helper only calls SSL_CTX_set_alpn_select_cb, which stores a function pointer and takes no reference), and the null guard covers the async path where ctx_ptr can legitimately be null. Per REVIEW.md, changes to TLS/crypto paths should not be auto-approved.

Other factors

An earlier run of this review left a nit about the new tests leaking the server/client on assertion failure. Commit bd2233fb addressed it: the first four tests now use await using on both tls.createServer and tls.connect, and the fifth (which expects the client to error, so Symbol.asyncDispose would reject) wraps the client in try/finally { client.destroy() } with a comment explaining why. The tests follow the harness conventions in test/CLAUDE.md — port: 0, once(emitter, event) and Promise.withResolvers instead of sleeps, error/tlsClientError wired to reject the awaited promise, a single .toEqual on the combined client/server result, and a specific error-code assertion rather than a bare toThrow. The PR description's before/after evidence shows all five new tests fail on USE_SYSTEM_BUN=1 and pass on the debug build. No outstanding third-party CHANGES_REQUESTED reviews are visible in the timeline; a maintainer commented today, so surfacing a fresh clean-review status is useful rather than noise.

Jarred-Sumner pushed a commit that referenced this pull request Sep 25, 2026
…'s TLS context (#43015)

### Problem
- `tls.Server#setSecureContext()` on a listening server changes nothing.
Later handshakes keep the original certificate, key and client-CA store.
A CA that the operator removed keeps authorizing client certificates
(`authorized=true`). Node applies the new context to the next
connection.
- Cause: `Listener::listen` (`src/runtime/socket/Listener.rs`) builds
the `SSL_CTX` once. `setSecureContext` (`src/js/node/tls.ts`) only
reassigned the option fields that `listen()` reads.

### Fix
- `setSecureContext()` on a listening server builds an `SSL_CTX` from
the staged options. `us_listen_socket_set_default_ssl_ctx()` makes it
the default for later accepts. It runs before any field is assigned, so
rejected material throws and nothing changes.
- First commit: `listen()` no longer registers the default context under
the bind hostname in the SNI tree. That entry shadowed an `addContext()`
wildcard and would pin the name to the old context.
- As in Node, `setSecureContext()` no longer reads `ALPNProtocols`
(constructor only). A cluster worker reads its TLS options when the
primary answers, so a call made before `'listening'` counts.
- Verified on Linux and Windows: `node-tls-server.test.ts` (17 new
tests, 11 fail on bun 1.4.2), `node-tls-namedpipes.test.ts`. On Linux:
`test/js/node/tls/`, `node-http2.test.js`, `node-net-server.test.ts`.
Self-reviewed: one regression found and fixed (Notes).

### Background
- An `SSL_CTX` is BoringSSL's TLS configuration: certificate chain, key,
CA store, verify mode. Each accepted socket creates its `SSL` from the
listen socket's default and holds a reference.
- The SNI tree maps server names to other contexts (`addContext()`). A
matching ClientHello switches that connection's context.
- Session tickets belong to one `SSL_CTX`, so a new context never
resumes an old session.

<details><summary>Notes</summary>

**Repro** (two CAs, server trusts `caA`, then `setSecureContext({ ...,
ca: caB })`):
```
                         bun 1.4.2 / main                 this branch = node v26.3.0
after  clientA (caA):    hello clientA authorized=true    refused
after  clientB (caB):    refused                          hello clientB authorized=true
resume clientA session:  authorized=true reused=true      refused
```
The same holds for an `Http2SecureServer`, which is the class
`@grpc/grpc-js` reloads credentials on.

**Scope.** `tls.Server` and `Http2SecureServer` only. Not covered, on
purpose:
- `https.Server` and `Bun.serve().reload({ tls })` (#8872). They go
through `Bun.serve`, not `Listener.rs`. #42576 and the draft #35535 add
`https.Server#setSecureContext()`. No open PR covers
`Bun.serve().reload({ tls })`.
- `setSecureContext(secureContextInstance)`. Node takes an options
object only. node v26.3.0 reads the instance as options with no key and
no certificate, and every later handshake fails. Bun 1.4.2 and this
branch both leave the listening socket on its context for that form. A
context from `tls.createSecureContext()` carries no `requestCert` verify
mode, so this PR does not install it.
- ALPN on a connection whose SNI selects an `addContext()` or
`SNICallback` context is still not negotiated. That is #33253. The
wildcard test here asserts the certificate only.

**Cluster workers.** A worker's `listen()` completes when the primary
answers. `net.ts` built the native TLS options when `listen()` was
called, so a `setSecureContext()` or `addContext()` made before
`'listening'` was lost. `listenOnPrimaryHandle` now reads them again.
For `tls-cluster-set-secure-context-fixture.mjs`, node v26.3.0 prints
`default: agent3, viaAddContext: agent2` and bun 1.4.2 prints `agent1`
for both.

**ALPNProtocols.** This is a behavior change outside a listening server
too. `setSecureContext({ ALPNProtocols })` no longer sets the list, also
before `listen()`. node v26.3.0 reads the option in the `Server`
constructor only (`lib/internal/tls/wrap.js` L1381-L1382). Probe: a
server created with `['h2']` and given `['http/1.1']` through
`setSecureContext()` negotiates `h2` in node, also after `close()` +
`listen()`. Bun 1.4.2 negotiates `http/1.1` after the re-listen. Before
this change a listening server reported the new list on
`server.ALPNProtocols` while its listener kept the old one.

**requestCert clamp.** A server that does not request a client
certificate must not reject for one. `net.ts` applied that to the
options after `[buntls]` built them. The rule now lives in `[buntls]`,
so `listen()`, the cluster reply and the context swap share it.

**Related PRs.** The C primitive and its header comment are taken as
they are from the drafts #35535 and #41400, so the C hunks merge without
a conflict. The Rust wrapper takes an `&OwnedSslCtx` where the drafts
take a raw pointer, so safe code cannot pass a dead pointer. Neither
draft calls it from `Listener.rs`. #42576 has the same function under
the name `us_listen_socket_set_ssl_ctx`. #42050 builds `_sharedCreds`
eagerly so that bad material throws before `listen()`. Its notes say it
does not rotate the listener. It edits the same function in `tls.ts`, so
one of the two needs a rebase. #32435 adds the `tls.Server` methods to
`https.Server`. #37896 changes what a cluster TLS worker's `_handle` is.
Here a `_handle` that is not a `Listener` is a no-op, and the worker's
connections are wrapped in JS from the server's own fields. #33365 was
the first version of this fix. A stale-PR sweep closed it. #42355 and
#42998 edit `Listener.rs` and `openssl.c` in other places.

**Self-review.** A review of the first version of this diff found one
regression, and it is fixed. That version moved the bind-hostname SNI
entry to the new context. A connection accepted before the swap whose
ClientHello arrived after it was switched to the new context, which had
no ALPN selector yet, so an h2 server answered `unknownProtocol`. The
first commit removes the entry, and two tests pin the case for
`tls.Server` and `Http2SecureServer`. The review also asked for h2
coverage, for the `ALPNProtocols` fix, for the
requestCert/rejectUnauthorized matrix tests and for the maintainer's
symbol name. All are in.

**Reference counts.** `secure_ctx` is an `OwnedSslCtx`. The swap is
`set_default_ssl_ctx` (C takes its own reference and drops the one on
the old default) and `secure_ctx.set(Some(ctx))` (drops the listener's
reference on the old one). The test `frees the context it replaces`
asserts with `sslCtxLiveCount()` that 20 swaps and one rejected swap add
no live `SSL_CTX`, and that `close()` releases the last one.

**Windows.** A named-pipe TLS server keeps its context in
`WindowsNamedPipeListeningContext.ctx`, now a `JsCell`. Each accept
clones the context out of the cell before it can run JS, because
`setSecureContext()` can replace the slot. At ddd655c I built the
debug binary on Windows x64 and ran `node-tls-namedpipes.test.ts` (7
pass) and the new tests in `node-tls-server.test.ts` (18 pass, the
cluster test included). The canary on that machine (1.4.3) fails the
named-pipe test: it serves `agent1` after the swap. `bun run
rust:check-all` passes for all 12 targets at the same commit.

**Test hygiene.** `SNICallback runs even when the requested servername
matches the bind hostname` now dials the address `listen()` bound.
`localhost` resolves to `::1` and `127.0.0.1` on a dual-stack host, and
`connect()` need not pick the one `listen()` did. #35160 makes the same
change on its own.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 13 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/node/tls/node-tls-server.test.ts,
test/js/node/tls/node-tls-namedpipes.test.ts

<!-- robobun:evidence:end -->

---------

Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
@fabianlindfors

Copy link
Copy Markdown

Any plans on merging this? It has become a blocker for us

robobun added a commit that referenced this pull request Oct 2, 2026
The tests of #33253: the server's ALPNProtocols and ALPNCallback hold after a
synchronous SNICallback, an asynchronous SNICallback and addContext(), and a
protocol mismatch gets the no_application_protocol alert.

This branch has not been deployed

No deployments
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.

SNICallback and ALPNCallback not triggered in Bun when creating TLS server

3 participants