Skip to content

node:tls: report servername as false on server sockets when the client sent no SNI - #39088

Open
robobun wants to merge 1 commit into
mainfrom
farm/ade0e560/tls-server-servername-false
Open

node:tls: report servername as false on server sockets when the client sent no SNI#39088
robobun wants to merge 1 commit into
mainfrom
farm/ade0e560/tls-server-servername-false

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • In a tls.createServer() connection listener, socket.servername is undefined when the client sent no SNI extension. Node v26.3.0 reports false. Same for a standalone new tls.TLSSocket(raw, { isServer: true }) after 'secure'.
  • The object passed to a server's ALPNCallback carries servername: undefined in the same case. Node passes false there too.
  • Node code branches on this value. Node's own test/parallel/test-https-agent-sni.js does if (req.socket.servername !== false) res.setHeader('x-sni', servername); on a tls.Server socket under Bun that branch is taken with undefined and setHeader throws.
  • Cause: src/js/node/net.ts ServerHandlers.handshake assigns the native socket.getServername() result as is, and ServerHandlers.alpnCallback forwards the native SNI argument as is. The native side (src/runtime/socket/tls_socket_functions.rs get_servername, src/runtime/socket/socket_body.rs select_alpn_callback) reports a NULL SSL_get_servername() as undefined.
  • The https server was already right: _http_server.ts's socket getter normalizes to false. Only the tls.Server / server-side TLSSocket paths were inconsistent.

Fix

  • Adds servernameOrFalse() in net.ts (name ?? false) and applies it at the two sites that produce the node-visible values: the handshake-success assignment of self.servername and the ALPNCallback argument object. The fixing lines are the two servernameOrFalse(...) call sites; the rest is comments.
  • Why false and why these two sites: both values come from the same node primitive, TLSWrap::GetServername(), which returns the SNI name or false when SSL_get_servername() is NULL (crypto_tls.cc#L1359-L1373). _finishInit stores it on the socket (wrap.js#L1094-L1096) and callALPNCallback passes it to the callback (wrap.js#L243).
  • Why ?? and not an empty-string check: BoringSSL rejects an empty SNI host_name at ClientHello parse time (vendor/boringssl/ssl/handshake_server.cc, extract_sni), so on the server side the native value is either a non-empty name or undefined; ?? mirrors node's NULL check exactly.
  • Deliberately unchanged: the handshake-failure assignment a few lines above (the 'tlsClientError' socket). Node never reaches _finishInit on that path; only SelectSNIContextCallback writes the property, and only when the ClientHello carried a name (crypto_tls.cc#L1396-L1399), so a no-SNI failure keeps the property's initial value in node. That matches what Bun does there today; a comment now says why that line differs from the one below it. The client-side value after the handshake (node: false without SNI, Bun: "") is the subject of node:tls: do not derive the SNI server_name from host in tls.connect #33476 and is not touched here.
  • Kept in the node: compat layer on purpose: Bun.Socket#getServername() is a public Bun API and keeps returning undefined.
  • Verified: test/js/node/tls/node-tls-server.test.ts gains four cases in the v26.3.0 parity block (tls.Server socket + ALPNCallback, and a standalone server-side wrap, each with and without SNI). On the unfixed build the two no-SNI cases fail (undefined received, false expected) and the two with-SNI cases pass; all four pass with the fix.
  • Also run on the fixed build: the full test/js/node/tls/ directory (the only failures are SNICallback runs even when the requested servername matches the bind hostname, which is the host-dependent ECONNREFUSED that test(tls): dial bound IP in 'SNICallback matches bind hostname' (host-dependent ECONNREFUSED) #35160 fixes and fails the same way before this change, and the root-certs worker race test hitting its 5s budget on this debug+ASAN container, also unchanged), plus the vendored test-tls-sni-*.js, test-tls-alpn-server-client.js, test-tls-psk-alpn-callback-exception-handling.js, test-tls-add-context.js, test-tls-empty-sni-context.js, test-tls-snicallback-error.js and test-https-agent-sni.js, all exit 0.

Background

  • SNI (Server Name Indication) is the TLS ClientHello extension in which a client names the host it wants to talk to, so one listener can pick a certificate per name. Clients connecting to an IP address send none (RFC 6066 forbids IP literals), which is the case this PR is about.
  • SSL_get_servername() is the BoringSSL/OpenSSL accessor for that name on a connection; it returns NULL when the ClientHello had no such extension.
  • TLSWrap is node's native per-connection TLS object; TLSWrap::GetServername() is the JS-callable wrapper around SSL_get_servername() and is where node's false originates.
  • _finishInit is the node function that runs when a handshake completes; callALPNCallback is the one that invokes a server's ALPNCallback option while the handshake is still in progress. ServerHandlers.handshake and ServerHandlers.alpnCallback in net.ts are Bun's counterparts, shared by tls.Server connections and standalone server-side wraps, which is why one change covers both.
node v26.3.0 vs Bun on the affected paths

Probe: a tls.createServer with an ALPNCallback, a server expecting TLS 1.3 dialed by a TLS 1.2-only client (to reach 'tlsClientError'), and a new TLSSocket(raw, { isServer: true }) wrap; each dialed once without SNI and once with servername: "sni.example".

node v26.3.0
secureConnection (no SNI): socket.servername=false alpn.servername=false
tlsClientError (no SNI): socket.servername=null
standalone TLSSocket isServer (no SNI): before=null after=false
secureConnection (SNI=sni.example): socket.servername="sni.example" alpn.servername="sni.example"
standalone TLSSocket isServer (SNI=sni.example): before=null after="sni.example"

bun main (before)
secureConnection (no SNI): socket.servername=undefined alpn.servername=undefined
tlsClientError (no SNI): socket.servername=undefined
standalone TLSSocket isServer (no SNI): before=undefined after=undefined
secureConnection (SNI=sni.example): socket.servername="sni.example" alpn.servername="sni.example"
standalone TLSSocket isServer (SNI=sni.example): before=undefined after="sni.example"

bun with this PR
secureConnection (no SNI): socket.servername=false alpn.servername=false
tlsClientError (no SNI): socket.servername=undefined
standalone TLSSocket isServer (no SNI): before=undefined after=false
secureConnection (SNI=sni.example): socket.servername="sni.example" alpn.servername="sni.example"
standalone TLSSocket isServer (SNI=sni.example): before=undefined after="sni.example"

The remaining undefined vs null before the handshake (and on the failure path) is the TLSSocket constructor's initial value, shared with client sockets, and is outside this change.

…t sent no SNI

After the handshake, tls.Server sockets (and standalone server-side
TLSSocket wraps) exposed socket.servername as undefined when the
ClientHello carried no SNI extension, and the ALPNCallback received
servername: undefined in the same case. Node reports false on both:
TLSWrap::GetServername() returns false when SSL_get_servername() is
NULL, _finishInit stores that on the socket and callALPNCallback passes
it to the callback.

Normalize the native "no name" result in the two ServerHandlers sites
that feed those node-visible values. The handshake-failure path is left
as is: node never reaches _finishInit there, so it only ever stores a
name the ClientHello did carry.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4e084d63-f0af-45ae-99f3-1dcd6a12de7c

📥 Commits

Reviewing files that changed from the base of the PR and between 88a6398 and f1d0399.

📒 Files selected for processing (2)
  • src/js/node/net.ts
  • test/js/node/tls/node-tls-server.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:13 PM PT - Aug 15th, 2026

🔄 @robobun, the build for your commit f1d03996 (Build #98202) was cancelled — waiting for the next build...

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

  • Reproduced on main (debug build at 88a6398) with the cases now in test/js/node/tls/node-tls-server.test.ts: socket.servername and the ALPNCallback servername are undefined without SNI; node v26.3.0 reports false (probe output in the PR description).
  • Fix and tests are in this PR. On the unfixed build the two no-SNI cases fail and the two with-SNI cases pass; all four pass with the fix.

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

LGTM — a two-line ?? false normalization in the node: compat layer, matching Node's TLSWrap::GetServername().

What was reviewed:

  • The two servernameOrFalse() call sites (handshake-success self.servername and the ALPNCallback argument) against the cited Node sources; the deliberately-unchanged failure-path assignment is correctly justified.
  • Downstream consumers of socket.servername (http2.ts initOriginSet, net.ts:311) — all use falsy checks (!/||), so false vs undefined is invisible to them.
  • The four new tests: both SNI/no-SNI variants for tls.Server and standalone server wraps, error events wired to reject, cleanup in finally, reusing the block's existing listen() helper.
Extended reasoning...

Overview

This PR normalizes undefinedfalse for the server-side socket.servername property and the ALPNCallback servername argument when the client's ClientHello carried no SNI extension, matching Node v26.3.0's TLSWrap::GetServername() semantics. The functional change is a one-line helper (servername ?? false) applied at exactly two sites in src/js/node/net.ts (ServerHandlers.alpnCallback and the success branch of ServerHandlers.handshake). The rest of the source diff is comments — including one explaining why the adjacent handshake-failure assignment is not changed (Node never reaches _finishInit there, so the property keeps its constructor default). Four test cases are added to the existing v26.3.0 parity block in test/js/node/tls/node-tls-server.test.ts.

Security risks

None. This is a value-normalization change that only affects what user code observes for a reporting property; it does not alter any verification, authorization, or handshake decision. The ?? operator maps only null/undefined to false — a non-empty SNI string passes through unchanged, and BoringSSL's extract_sni rejects empty host_name at ClientHello parse time, so the server-side native value cannot be "".

Level of scrutiny

Low-to-medium. The behavioral surface is two expressions changing from x to x ?? false. I checked every in-tree consumer of .servername on server-side sockets: http2.ts initOriginSet reads it via if (!hostName) and net.ts:311 via self.servername || ... — both treat false and undefined identically. The _http_server.ts getter already normalized to false independently, so this brings the tls.Server path in line. The change is correctly placed in the node: compat layer per REVIEW.md guidance (Bun-native Bun.Socket#getServername() still returns undefined).

Other factors

The PR description is unusually thorough: it cites the exact Node source lines for each decision (why false, why ?? not an empty-string check, why the failure path stays as-is, why client-side is out of scope via #33476). The tests cover both the fixed case and a with-SNI control, for both entry points that share ServerHandlers (natively-accepted tls.Server connections and standalone new TLSSocket(raw, { isServer: true }) wraps). Tests follow the file's existing conventions — the shared listen() helper, Promise.withResolvers with error events wired to reject, and try/finally cleanup. The author reports the no-SNI cases fail on the unfixed build and pass with the fix, and that the vendored Node test-tls-* / test-https-agent-sni.js suites all pass.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun can you submit a PR to our boringssl fork with the patches changes instead? and then can you remove the patch from this PR and push up a bump

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

This PR does not carry a BoringSSL patch. The diff is two ?? false lines in src/js/node/net.ts plus tests, and it builds against the BORINGSSL_COMMIT already on main, so there is nothing here to move into the fork or to bump. I think this was meant for a different PR.

The open robobun PRs that currently add a patches/boringssl/*.patch are:

main itself still carries patches/boringssl/require-memory-hooks.patch from #34847.

Leave the same comment on the PR you meant (or name it here, "all of the above" works too) and it gets the #35433 / oven-sh/boringssl#10 treatment: PR against oven-sh/boringssl, patch removed, BORINGSSL_COMMIT bumped.

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