Skip to content

node:https: thread SNICallback through to the TLS listener - #36707

Open
robobun wants to merge 17 commits into
mainfrom
farm/c80d6219/https-snicallback
Open

robobun wants to merge 17 commits into
mainfrom
farm/c80d6219/https-snicallback

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes the remaining half of #14395: https.createServer({ SNICallback }) accepted the option but never dispatched it, so every handshake served the default certificate and the callback was never reached. tls.createServer({ SNICallback }) already works because it listens via the Bun.listen socket path, which registers a dynamic SNI resolver on the listen socket. The node:https server listens via Bun.serve, whose uWS listen socket had no such hook.

Repro

import https from "node:https";
import tls from "node:tls";
const calls = [];
const server = https.createServer(
  { key, cert, SNICallback: (name, cb) => { calls.push(name); cb(null, tls.createSecureContext(altCert)); } },
  (req, res) => res.end("ok"),
);
server.listen(0, "127.0.0.1", () => {
  const s = tls.connect({ host: "127.0.0.1", port: server.address().port, servername: "agent1", rejectUnauthorized: false }, () => {
    console.log(s.getPeerCertificate().subject.CN, calls);
  });
});
// node:   agent1  ["agent1"]
// before: <default CN>  []
// after:  agent1  ["agent1"]

Cause

_http_server.ts built a tls config for Bun.serve from key/cert/ca/ciphers/minVersion/etc but dropped SNICallback on the floor, and Bun.serve had no dynamic SNI dispatch to receive it anyway: the uWS listen socket's group->ext belongs to the uWS HttpContext, so the existing Bun.listen dispatch (which recovers its owner via ls->group->ext) could not be reused as-is.

Fix

Wire the same SSL_CTX_set_select_certificate_cb hook the Bun.listen path uses into the Bun.serve listen socket:

  • us_listen_socket_on_server_name gains a user-data pointer, stored alongside the resolver on the listen socket, so a dispatch whose group->ext belongs to someone else (the uWS HttpContext) can still recover its owning server. The existing Bun.listen caller passes NULL and keeps using group->ext.
  • NewServer carries an on_server_name: JSValue (GC-rooted via the generated m_onServerName WriteBarrier slot), set through setServerCustomOptions alongside the existing onClientError / onConnection handlers. When set on an HTTPS server the setter registers the select-certificate dispatch on the live listen socket with the server as user data.
  • The per-handshake dispatch recovers the server from the listen socket, calls the stored handler with the ClientHello's servername, and decodes the result via the existing decode_sni_result (native SecureContext / fall-through / Error / suspend), so the return-value contract is shared with the Bun.listen path.
  • _http_server.ts validates options.SNICallback (ERR_INVALID_ARG_TYPE when not a function), stores it as server._SNICallback, and passes a serverName handler to setServerCustomOptions that invokes the user callback and returns the selected context. cb(err) and cb(null, <not a SecureContext>) drop the connection before the handshake completes (no TLS alert), and cb(null, undefined) falls through to the default context, matching Node and tls.createServer.

An SNICallback that defers its completion callback (does not call cb synchronously) currently falls through to the default context on the node:https path: there is no resume handle for the in-flight uWS socket yet, so the handler takes the same !socketHandle fall-through the net.ts serverName handler already has. A synchronous cb(null, ctx) (the issue's case) is fully supported.

How did you verify your code works?

New tests in test/js/node/http/node-http.test.ts under "node https server":

  • dispatches SNICallback and serves the selected certificate: two handshakes against an HTTPS server whose SNICallback selects an alternate context for agent1 and undefined otherwise; asserts the peer CN is agent1 / server-bun respectively and that the callback was invoked with both servernames.
  • rejects a non-function SNICallback: asserts ERR_INVALID_ARG_TYPE.

Both fail on main (CN is the default cert, calls stays [], and the non-function option was accepted) and pass with this change. test/js/node/tls/node-tls-server.test.ts -t SNICallback and test/js/node/test/parallel/test-tls-sni-option.js continue to pass.


[review] gate passed · iteration 3 · 15 files touched

fails on main (without fix)
ASAN without fix: 7 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/http/node-https-snicallback.test.ts
bun test v1.4.0 (658fe8507)

test/js/node/http/node-https-snicallback.test.ts:
40 |           resolve(cn);
41 |         });
42 |         s.on("error", reject);
43 |       });
44 |     // SNICallback selects the agent1 context; the client must receive its CN.
45 |     expect(await connectCN("agent1")).toBe("agent1");
                                           ^
error: expect(received).toBe(expected)

Expected: "agent1"
Received: "server-bun"

      at <anonymous> (/workspace/bun/test/js/node/http/node-https-snicallback.test.ts:45:39)
(fail) https.createServer dispatches SNICallback and serves the selected certificate [579.50ms]
52 |     server.close();
53 |   }
54 | });
55 | 
56 | it("https.createServer rejects a non-function SNICallback", () => {
57 |   expect(() => createHttpsServer({ key: tlsCert.key, cert: tlsCert.cert, SNICallback: 1 as any })).toThrow(
                                                                                                        ^
error: expect(received).to
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (3fdc0d126)

test/js/node/http/node-https-snicallback.test.ts:
(pass) https.createServer dispatches SNICallback and serves the selected certificate [26.09ms]
(pass) https.createServer rejects a non-function SNICallback [0.14ms]
(pass) https.createServer SNICallback cb(error) drops the connection and emits tlsClientError [4.75ms]
(pass) https.createServer SNICallback cb(string) drops the connection and emits tlsClientError [2.59ms]
(pass) https.createServer SNICallback invalid primitive drops the connection and emits tlsClientError [2.52ms]
(pass) https.createServer SNICallback invalid object drops the connection and emits tlsClientError [2.48ms]
(pass) https.createServer SNICallback throw drops the connection and emits tlsClientError [2.47ms]

 7 pass
 0 fail
 20 expect() calls
Ran 7 tests across 1 file. [210.00ms]
__F:0:S:0
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/node/http/node-https-snicallback.test.ts
bun test v1.4.0 (658fe8507)

test/js/node/http/node-https-snicallback.test.ts:
(pass) https.createServer dispatches SNICallback and serves the selected certificate [744.58ms]
(pass) https.createServer rejects a non-function SNICallback [6.39ms]
(pass) https.createServer SNICallback cb(error) drops the connection and emits tlsClientError [115.08ms]
(pass) https.createServer SNICallback cb(string) drops the connection and emits tlsClientError [58.25ms]
(pass) https.createServer SNICallback invalid primitive drops the connection and emits tlsClientError [58.33ms]
(pass) https.createServer SNICallback invalid object drops the connection and emits tlsClientError [67.27ms]
(pass) https.createServer SNICallback throw drops the connection and emits tlsClientError [54.02ms]

 7 pass
 0 fail
 20 expect() calls
Ran 7 tests across 1 file. [4.08s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 722ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/47] gen cpp.rs (cppbind)
[2/47] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[3/47] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (13 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameP
... (truncated)
diff hotspot
packages/bun-usockets/src/context.c              |  1 +
 packages/bun-usockets/src/crypto/openssl.c       |  8 +-
 packages/bun-usockets/src/internal/internal.h    |  4 +
 packages/bun-usockets/src/libusockets.h          |  4 +-
 packages/bun-uws/src/App.h                       |  4 +-
 src/js/internal/http.ts                          |  2 +
 src/js/internal/tls.ts                           | 13 ++++
 src/js/node/_http_server.ts                      | 52 +++++++++++++
 src/jsc/bindings/NodeHTTP.cpp                    |  9 ++-
 src/runtime/server/mod.rs                        |  8 +-
 src/runtime/server/server.classes.ts             |  1 +
 src/runtime/server/server_body.rs                | 96 +++++++++++++++++++++++
 src/runtime/socket/Listener.rs                   |  8 +-
 src/uws_sys/ListenSocket.rs                      |  9 ++-
 test/js/node/http/node-https-snicallback.test.ts | 97 ++++++++++++++++++++++++
 15 files changed, 307 insertions(+), 9 deletions(-)

gate history · 7 passed · 1 rejected · iteration 3

evidence per changed file
file                                              reads  edits  tests
packages/bun-usockets/src/context.c                   1      1      0
packages/bun-usockets/src/crypto/openssl.c            2      1      0
packages/bun-usockets/src/internal/internal.h         1      1      0
packages/bun-usockets/src/libusockets.h               2      2      0
packages/bun-uws/src/App.h                            1      1      0
src/js/internal/http.ts                               1      1      0
src/js/internal/tls.ts                                2      2      0
src/js/node/_http_server.ts                          12     11      0
src/jsc/bindings/NodeHTTP.cpp                         2      3      0
src/runtime/server/mod.rs                            10      5      0
src/runtime/server/server.classes.ts                  1      1      0
src/runtime/server/server_body.rs                     7      8      0
src/runtime/socket/Listener.rs                        1      2      0
src/uws_sys/ListenSocket.rs                           1      2      0
test/js/node/http/node-https-snicallback.test.ts      5      7      0

https.createServer({ SNICallback }) was accepted but never dispatched: the
node:https server path listens via Bun.serve, whose uWS listen socket had no
dynamic SNI resolver hooked up, so every handshake served the default context
and the callback was never reached. tls.createServer already worked because it
listens via the Bun.listen path, which registers us_listen_socket_on_server_name.

Wire the same select-certificate hook into the Bun.serve listen socket:

- us_listen_socket_on_server_name gains a user-data pointer so the dispatch can
  recover its owning server (group->ext belongs to the uWS HttpContext here, not
  the server).
- NewServer carries an on_server_name JSValue (GC-rooted via the wrapper's
  m_onServerName WriteBarrier), set through setServerCustomOptions alongside the
  existing onClientError / onConnection handlers.
- _http_server.ts validates and stores options.SNICallback, and passes a
  serverName handler to setServerCustomOptions that calls the user callback and
  returns the selected native SecureContext / undefined / Error, matching the
  net.ts ServerHandlers.serverName contract so Listener.rs's decode_sni_result
  is reused for the return-value decoding.

Fixes #14395.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The change adds HTTPS SNICallback support. It connects Node HTTPS server options to native SNI dispatch, supports raw and wrapped secure contexts, handles callback failures, and adds uSockets userdata plumbing and integration tests.

HTTPS SNI callback support

Layer / File(s) Summary
Native SNI userdata plumbing
packages/bun-usockets/src/..., packages/bun-uws/src/App.h, src/uws_sys/ListenSocket.rs
uSockets stores opaque server-name callback data. Registration signatures and accessors now accept and expose the data pointer.
Runtime SNI registration and dispatch
src/runtime/server/*, src/runtime/socket/Listener.rs
The server runtime stores the SNI callback, registers it for HTTPS listeners, invokes it with the hostname, and decodes the handshake result.
Node HTTPS SNI integration
src/js/internal/http.ts, src/js/internal/tls.ts, src/js/node/_http_server.ts, src/jsc/bindings/NodeHTTP.cpp
Node HTTPS validates and forwards SNICallback, unwraps secure contexts, handles synchronous and asynchronous failures, and emits tlsClientError.
SNI behavior validation
test/js/node/http/node-https-snicallback.test.ts
Tests cover certificate selection, secure-context forms, fallback behavior, callback validation, connection termination, and tlsClientError events.

Possibly related PRs

  • oven-sh/bun#36174: Both changes modify uSockets SNI handling and server-name selection paths.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes threading the SNICallback through to the TLS listener.
Description check ✅ Passed The description covers the change, cause, implementation, limitations, tests, and verification results using both required template sections.
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.

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

@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Aug 4th, 2026

❌ @robobun, your commit 75d5af3 has some failures in Build #88708 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 36707

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

bun-36707 --bun

@github-actions github-actions Bot added the claude label Aug 1, 2026
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/runtime/server/mod.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. SNICallback support #4053 - Requests SNICallback support for Bun.serve, which this PR implements by wiring SSL_CTX_set_select_certificate_cb into the Bun.serve listen socket
  2. SNICallback and ALPNCallback not triggered in Bun when creating TLS server #17932 - Reports SNICallback not being triggered when creating a TLS server, which is the exact symptom this PR fixes by threading SNICallback through to the TLS listener

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

Fixes #4053
Fixes #17932

🤖 Generated with Claude Code

Comment thread src/js/node/_http_server.ts
Comment thread src/runtime/server/mod.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/js/node/_http_server.ts Outdated
Match Node's context.context || context so cb(null, secureContext.context)
works on the node:https path the same as node:tls. decode_sni_result on the
native side already validates SecureContext and aborts on anything else, so
the JS handler hands the unwrapped value through and lets that check reject
an invalid context.
Comment thread src/js/node/_http_server.ts Outdated
Comment thread packages/bun-usockets/src/libusockets.h Outdated
robobun and others added 3 commits August 1, 2026 11:20
Narrow to __attribute__((nonnull(1, 2))) instead of dropping the attribute
when the nullable data parameter was added, matching the surrounding
declarations in this header.
node-http.test.ts has a host-dependent ECONNREFUSED in 'request via http
proxy, issue#4295' (proxy listens on localhost, client dials 127.0.0.1);
keep the SNICallback coverage in a dedicated file alongside the other
node-https-* tests so it stays green independent of that.
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts
cb(null, true) was evaluating to the suspend sentinel via the
context.context || context fallthrough and parking the handshake with no
resume handle on this path. Require the unwrapped value to be an object so
primitives hit 'Invalid SNI context' (matching net.ts consumeSNIResult and
its sentinel-collision guard). Emit tlsClientError on the server before
returning a failure so the SNI error is observable like it is on
tls.createServer.
Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Match on_client_error_callback/on_connection_callback's guard before reading
the WriteBarrier-shadow JSValue; the listen socket is closed before the
wrapper can finalize so the window is not reachable today, but the shadow
slots share the same storage model and the file documents the guard as the
contract for reading them.
The cb() result is now decoded via a single internal/tls helper that
instanceof-checks against the native SecureContext, so cb(null, {}) and
cb(null, true) both surface 'Invalid SNI context' through tlsClientError on
node:https the same as on node:tls. Wrap the error-test loop body in
try/finally so a failing assertion does not leak a listening server.
Comment thread src/js/internal/tls.ts
Comment thread src/js/node/_http_server.ts
Comment thread src/js/node/_http_server.ts Outdated
process.nextTick the tlsClientError emission so a throwing listener surfaces
as uncaughtException instead of its thrown value being fed back into
decode_sni_result as the select-certificate result; matches net.ts where the
emit happens after us_select_cert_cb returns.

Validate options.SNICallback only inside the isTlsSymbol branch and gate on
truthiness so plain http.createServer ignores the option the way Node's
http.Server 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.

Actionable comments posted: 3

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

Inline comments:
In `@src/js/internal/tls.ts`:
- Around line 143-149: Update unwrapSNIContext so the context.context access is
wrapped in exception handling and any getter or Proxy failure is returned as the
caught Error rather than escaping; preserve the existing valid-context and
invalid-context behavior. Add coverage for a callback result containing a
throwing getter or Proxy and verify the failure follows the intended SNI
error/tlsClientError path.

In `@test/js/node/http/node-https-snicallback.test.ts`:
- Around line 77-87: Update the test’s event synchronization around the
tlsConnect call: register once(server, "tlsClientError") before creating the
client, then await both the client error and server tlsClientError events before
making assertions. Use the awaited server error for the expectedMessage check,
while preserving the existing client termination assertion and tlsClientError
occurrence validation.
- Around line 22-27: Update the SNICallback test to defer cb(null, altContext)
for the agent1 server name until a later turn, and adjust the listener resume
path to select that deferred context. Assert the client receives the agent1
certificate, while preserving the existing raw-handle and default-context cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: be6b97ca-485c-494b-b423-c1bfe61b4261

📥 Commits

Reviewing files that changed from the base of the PR and between f91d5c9 and 05d8c2e.

📒 Files selected for processing (15)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/crypto/openssl.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-uws/src/App.h
  • src/js/internal/http.ts
  • src/js/internal/tls.ts
  • src/js/node/_http_server.ts
  • src/jsc/bindings/NodeHTTP.cpp
  • src/runtime/server/mod.rs
  • src/runtime/server/server.classes.ts
  • src/runtime/server/server_body.rs
  • src/runtime/socket/Listener.rs
  • src/uws_sys/ListenSocket.rs
  • test/js/node/http/node-https-snicallback.test.ts

Comment thread src/js/internal/tls.ts
Comment thread test/js/node/http/node-https-snicallback.test.ts
Comment thread test/js/node/http/node-https-snicallback.test.ts Outdated
Register once(server, 'tlsClientError') before connecting and await both so
the assertion does not depend on nextTick running before the client's error
event.
Comment thread test/js/node/http/node-https-snicallback.test.ts Outdated
Comment thread src/js/internal/tls.ts
Comment thread src/js/node/_http_server.ts
…h for the error matrix

Match net.ts toSNIError so cb("boom") / throw 42 carry the original value
as .reason on the tlsClientError payload, and move the per-variant loop into
it.each so a failure names the case and later cases still run.

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

Actionable comments posted: 1

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

Inline comments:
In `@test/js/node/http/node-https-snicallback.test.ts`:
- Around line 62-65: The “cb(string)” SNI error test only checks the normalized
message and must also verify preservation of the original callback value in
Error.reason. Update the assertions in the sniErrorCases tests and the related
assertions at the referenced locations to check that the produced error’s reason
equals "boom", while retaining the existing message and error-case coverage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ccf61e41-9a61-4084-b63f-f1242e475efa

📥 Commits

Reviewing files that changed from the base of the PR and between 05d8c2e and 3fdc0d1.

📒 Files selected for processing (2)
  • src/js/node/_http_server.ts
  • test/js/node/http/node-https-snicallback.test.ts

Comment thread test/js/node/http/node-https-snicallback.test.ts Outdated
Comment thread test/js/node/http/node-https-snicallback.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.

No new issues found in this pass — the earlier inline findings (SNICallback validation gate, tlsClientError emit timing, unwrapSNIContext decode contract, .reason attachment, it.each matrix) have all been addressed through 658fe85. Deferring to a human because this threads a TLS certificate-selection hook through native code across the C/C++/Rust FFI boundary (raw *mut Self stashed on the listen socket's SNI data slot, a new GC-rooted on_server_name slot, and a dispatch extern fn that recovers the server from that pointer mid-handshake), which is security-adjacent and lifetime-sensitive enough to warrant a maintainer look.

What was reviewed:

  • server_body.rs dispatch: null/shutdown guards, decode_sni_result reuse, and the "listen socket cannot outlive the server" lifetime claim against do_stop/deinit ordering.
  • onServerSNI in _http_server.ts: fail-closed on invalid context / non-Error / throw; deferred-cb fall-through when socketHandle is undefined.
  • us_listen_socket_on_server_name signature change: all call sites updated (App.h ×2, Listener.rs), on_server_name_data zero-init in context.c.
  • m_onServerName WriteBarrier slot wiring via server.classes.ts + wrap_handler_slot.
Extended reasoning...

Overview

This PR wires https.createServer({ SNICallback }) through to the underlying TLS listener. It touches 15 files across four languages: bun-usockets C (context.c, openssl.c, internal.h, libusockets.h) to add an opaque on_server_name_data pointer alongside the existing SNI resolver hook; bun-uws C++ (App.h) and ListenSocket.rs to update call sites for the widened signature; NodeHTTP.cpp to plumb an onServerName callback through setServerCustomOptions; server_body.rs (~90 new lines) for Server__setOnServerName and its per-handshake dispatch extern fn; server.classes.ts / mod.rs for the m_onServerName WriteBarrier slot; _http_server.ts for the onServerSNI JS handler and SNICallback option validation; internal/tls.ts for the shared unwrapSNIContext helper; Listener.rs to expose decode_sni_result and pass null for the new data arg; and a new 97-line test file.

Security risks

SNI certificate selection is on the TLS handshake path. The failure modes that matter — an invalid/absent SNICallback result must not leave the connection serving the wrong certificate, and a callback error must not leave the handshake in a half-open state — appear to be handled fail-closed: unwrapSNIContext instanceof-checks both the wrapper and the raw handle against NativeSecureContext and returns an Error for anything else, which decode_sni_result maps to abort_handshake = 1; cb(null, undefined) returns undefined → default context (matching Node). The deferred-cb case (async SNICallback) falls through to the default context because there is no resume handle on the uWS path yet — acknowledged in the PR description and by CodeRabbit as out of scope. Nothing here changes the default-cert path for servers that don't pass SNICallback.

Level of scrutiny

High. This is not a mechanical change: it introduces a new native→JS re-entry point mid-TLS-handshake (dispatch in server_body.rs calls callback.call(...) from inside BoringSSL's select-certificate callback), adds a raw *mut NewServer stashed on the C listen socket with a lifetime justified by a comment ("the listen socket cannot outlive the server that owns it"), and adds a GC-visited JSValue slot. Each of these is the kind of thing REVIEW.md's memory-safety section calls out (re-entrancy under a raw pointer, GC rooting, cross-FFI ownership). The dispatch fn does guard vm.is_shutting_down() and js_value_for_dispatch().is_none() before calling into JS, and the pattern mirrors the existing us_dispatch_server_name in Listener.rs, but the ownership chain (uWS HttpContext owns the group, server owns the listener via this.listener, on_server_name_data points back at the server) is novel enough that a maintainer familiar with the Bun.serve teardown ordering should confirm it.

Other factors

The PR has been through ~10 rounds of bot review; every prior inline finding (from both the bug-hunting system and CodeRabbit) is marked resolved, and the author folded each into follow-up commits (7538071, 05d8c2e, 05abc55, 3fdc0d1, 658fe85). Test coverage is solid for the synchronous path: certificate selection by servername, raw-handle acceptance, fall-through on cb(null, undefined), ERR_INVALID_ARG_TYPE for a non-function option, and a 5-case it.each error matrix asserting both tlsClientError.message and .reason. The one outstanding sibling-parity nit (migrating net.ts consumeSNIResult to the shared unwrapSNIContext) was explicitly deferred to a follow-up and does not change any existing behavior. Given the cross-language surface and TLS-handshake sensitivity, I'm deferring rather than approving.

@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

The diff is ready for review. The fail-before/pass-after gate passed on both ASAN and release builds, all GitHub lints are green, and the automated review pass on 75d5af3 reports no remaining issues.

Buildkite #88708 for 75d5af3: 190/196 jobs passed including node-https-snicallback.test.ts on every lane that ran. Every failure is tagged [flaky] (passed alone or on retry) and none touch node:https / node:tls / Bun.serve. The 6 remaining jobs are all :darwin: test-bun lanes stuck SCHEDULED for 2+ hours (macOS fleet not picking them up).

(Builds #87329/#87368/#87404 earlier all expired with every build-* job stuck SCHEDULED during a linux build-agent outage window; #87509 then ran 190/196 passed with only pre-existing infra/flakes.)

One deferred follow-up (noted in the resolved review threads): migrating consumeSNIResult in src/js/node/net.ts to the shared unwrapSNIContext helper, which would let the kNativeSecureContextCtor indirection be dropped. Left out of this PR to keep the diff scoped to the node:https path.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do not add unsafe.

Store any_server_packed (not a typed raw pointer) in the listen socket's SNI
data slot and unpack it via a new AnyServer::from_packed (the inverse of
to_packed, moved next to it from NodeHTTPResponse.rs). The dispatch then reads
vm()/global_this()/js_value_for_dispatch()/on_server_name() through AnyServer's
existing safe accessors, dropping the raw '&*data.cast::<$T>()' deref and
pulling the extern fn out of the per-type macro so there is one copy instead
of two.

The remaining 'unsafe { &mut *this }' in the setter and the CStr::from_ptr for
the callback's hostname arg are byte-identical to the sibling
server_set_on_connection/on_client_error setters and us_dispatch_server_name
respectively.
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

73aea6a reworks the dispatch so it does not dereference a typed raw pointer. The listen socket's SNI data slot now carries the server's any_server_packed word; the dispatch unpacks it with AnyServer::from_packed (the inverse of to_packed, moved next to it and delegated from NodeHTTPResponse.rs) and reads vm()/global_this()/js_value_for_dispatch()/on_server_name() through the existing safe AnyServer accessors. That removes unsafe { &*data.cast::<$T>() } and also pulls the extern fn out of the per-type macro so there is one copy of it instead of two.

The remaining unsafe in the Rust diff are the file's existing idiom and not a new pattern:

  • unsafe { &mut *this } after server.as_::<$T>() in the setter, identical to server_set_on_client_error/server_set_on_connection/server_set_idle_timeout right above it.
  • unsafe { CStr::from_ptr(hostname) } for the C callback's hostname arg, identical to us_dispatch_server_name in Listener.rs (the one other consumer of this C callback).
  • #[unsafe(export_name = ...)], the Rust 2024 attribute form used by every sibling shim.

If the intent is to eliminate those too (which would mean diverging from the surrounding setters' pattern or adding new safe helpers for them), happy to go further with a pointer on the preferred abstraction.

autofix-ci Bot and others added 2 commits August 4, 2026 04:46
The listen socket's SNI data slot now holds the server's encoded JSValue (the
JS wrapper cell). The dispatch decodes it with JSValue::from_encoded and
downcasts via the safe as_class_ref, reading vm()/global_this() from the
resulting &NewServer and the callback from the m_onServerName WriteBarrier
slot via the codegen'd on_server_name_get_cached. The setter uses the same
safe as_class_ref for the listener read and writes the callback via
js_gc_on_server_name_set, so no NewServer field is mutated and the
on_server_name shadow field and the AnyServer::from_packed/on_server_name
additions are dropped.

That leaves two 'unsafe' in the Rust diff: the Rust 2024
#[unsafe(export_name)] attribute on the shim (same as every sibling shim),
and CStr::from_ptr for the C callback's hostname arg (same as
us_dispatch_server_name in Listener.rs for this C callback).
Comment thread src/runtime/server/server_body.rs
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

75d5af3 redoes this per the Slack note: the listen socket's SNI data slot holds the server's JS wrapper cell (encoded JSValue), and the dispatch recovers &NewServer via the safe as_class_ref downcast and reads the callback from the m_onServerName WriteBarrier slot with on_server_name_get_cached. The setter uses the same safe downcast for the listener read and writes the callback via js_gc_on_server_name_set, so there is no raw Rust pointer stashed and no NewServer field mutation. The on_server_name shadow field and the AnyServer::from_packed/on_server_name() additions from 73aea6a are gone.

Remaining unsafe in the Rust diff: the #[unsafe(export_name = ...)] attribute on the shim (Rust 2024 syntax, same as every sibling shim) and CStr::from_ptr(hostname) for the C callback's string arg (same as us_dispatch_server_name in Listener.rs for this callback).

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

Actionable comments posted: 2

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

Inline comments:
In `@src/runtime/server/server_body.rs`:
- Around line 3936-3941: Update the SNICallback invocation around callback.call
so it does not pass undefined as the callback argument while the uWS handshake
cannot resume. Reject the unsupported asynchronous form during registration or
route deferred completion to tlsClientError; do not allow decode_sni_result to
silently return the default certificate when the callback returns undefined.
Preserve synchronous SNICallback behavior and ensure callback(err, ctx) receives
a valid callable callback when supported.
- Around line 3968-3974: Update the listener lifecycle around the on_server_name
registration and the associated us_select_cert_cb SNI-resolution flow so
listeners remain alive until every pending TLS handshake completes, including
resolutions returning no context after stop(). Alternatively, ensure stop()
detaches and aborts all pending handshakes before closing the listener,
preventing stale us_listen_socket_t dereferences.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ac08b5a0-df36-4804-8cb1-9a446b2cc800

📥 Commits

Reviewing files that changed from the base of the PR and between 3fdc0d1 and 75d5af3.

📒 Files selected for processing (3)
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • test/js/node/http/node-https-snicallback.test.ts

Comment thread src/runtime/server/server_body.rs
Comment thread src/runtime/server/server_body.rs

@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 on the 75d5af3 rework, but this spans TLS handshake dispatch across C/C++/Rust/TS with GC-lifetime reasoning (encoded JSValue stashed in the listen socket's void* data slot), so it warrants a maintainer look.

What was reviewed on 75d5af3:

  • us_dispatch_serve_server_name: server recovery via as_class_ref on the stashed encoded JSValue, is_shutting_down guard, callback read from the m_onServerName WriteBarrier slot — no raw shadow field remains.
  • EventLoop::enter_scope omission checked against us_dispatch_server_name in Listener.rs (same select-certificate caller inside SSL_do_handshake) — matches the sibling, not a regression.
  • decode_sni_result return contract shared with the Bun.listen path; onServerSNI's unwrapSNIContext rejects non-SecureContext values before true can reach the suspend arm.
Extended reasoning...

Overview

Wires SNICallback from node:https through to the uWS listen socket's select-certificate hook. 15 files, ~300 LOC: adds a void* on_server_name_data slot to us_listen_socket_t (bun-usockets C), a Rust dispatch (us_dispatch_serve_server_name in server_body.rs) that recovers the HTTPSServer from that slot and calls the JS handler, a new onServerName WriteBarrier value slot on the server class, and the _http_server.ts glue (onServerSNI, unwrapSNIContext, toSNIError). The dispatch was reworked twice on 2026-08-04 (73aea6a → 75d5af3) based on offline maintainer direction, landing on stashing the server's encoded JSValue in the C data slot and downcasting via as_class_ref.

Security risks

This is TLS certificate selection during the handshake — a security-relevant path. The fail-closed contract looks correct: invalid contexts (unwrapSNIContext returns an Error), callback errors, and non-SecureContext returns all set *abort_handshake = 1 and drop the connection before any cert is served; cb(null, undefined) falls through to the default context. The suspend sentinel (true) cannot leak from user input because unwrapSNIContext instanceof-checks before onServerSNI returns. No trust-store or verification-mode changes.

Level of scrutiny

High. The change spans four languages with FFI boundaries, adds a GC-rooted callback slot, and stashes an encoded JSValue in a C void* that must stay live for the listen socket's lifetime. The lifetime argument (server wrapper is strongly held while listening; listener close precedes wrapper finalize) was traced in earlier review rounds and looks sound, but the 75d5af3 approach is fresh and should be confirmed by someone who owns src/runtime/server/.

Other factors

All prior inline findings from earlier revisions are resolved. The enter_scope omission was re-examined this run and matches the Listener.rs sibling for the same C caller. Buildkite #88708 for 75d5af3 was still building at review time. The author noted one deferred follow-up (migrating net.ts's consumeSNIResult to the shared unwrapSNIContext).

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.

2 participants