diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 1e9d83b4dda9..193e04d4ece8 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -692,6 +692,14 @@ function SocketEmitEndNT(self, _err?) { } } +// The native layer reports "the ClientHello carried no SNI" as undefined; node's +// TLSWrap::GetServername() reports it as false, and that is what reaches users +// through socket.servername and the ALPNCallback argument. +// https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1359-L1373 +function servernameOrFalse(servername: string | undefined): string | false { + return servername ?? false; +} + // --- SNICallback dispatch helpers (hoisted: no per-handshake closures) --- // Normalizes non-Error rejections (cb(true), cb("reason"), throw true): the @@ -782,7 +790,8 @@ const ServerHandlers: SocketHandler = { } let result; try { - result = cb.$call(self, { servername, protocols }); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L243 + result = cb.$call(self, { servername: servernameOrFalse(servername), protocols }); } catch (err) { // Node: a throwing ALPNCallback refuses the connection (fatal // no_application_protocol alert) and surfaces the thrown error as @@ -933,6 +942,10 @@ const ServerHandlers: SocketHandler = { } else { err = tlsHandshakeError(verifyError); } + // Not servernameOrFalse: node only stores a name the ClientHello did carry + // here (TLSWrap::SelectSNIContextCallback); its `false` comes from + // _finishInit, which a failed handshake never reaches. + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1396-L1399 self.servername = socket.getServername(); self._hadError = true; // Node's onerror destroys *with* the error when the handshake never @@ -947,7 +960,8 @@ const ServerHandlers: SocketHandler = { self._securePending = false; self.secureConnecting = false; self._secureEstablished = !!success; - self.servername = socket.getServername(); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1094-L1096 + self.servername = servernameOrFalse(socket.getServername()); self.alpnProtocol = socket.alpnProtocol; self[kVerifyError] = verifyError ?? null; // The native verifier reports a non-OK code when there is no peer certificate, diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index dc5623688e61..e91746fc629f 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2333,6 +2333,79 @@ describe("node v26.3.0 tls.Server parity follow-ups", () => { server.close(); } }); + + // Once the handshake is done node stores TLSWrap::GetServername(), which is + // the SNI name or `false` (never undefined) when the ClientHello carried + // none; the ALPNCallback argument is built from the same call. Node's own + // test-https-agent-sni.js branches on `servername !== false`. + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1359-L1373 + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1094-L1096 + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L243 + const sniCases: [label: string, servername: string | undefined, expected: string | false][] = [ + ["the client sent no SNI", undefined, false], + ["the client sent SNI", "sni.example.com", "sni.example.com"], + ]; + // @types/node does not declare the property. + const servernameOf = (socket: TLSSocket) => (socket as unknown as { servername: unknown }).servername; + + it.each(sniCases)( + "socket.servername and the ALPNCallback servername report what the ClientHello carried (%s)", + async (_label, servername, expected) => { + let alpnServername: unknown = "ALPNCallback did not run"; + const server = createServer({ + ...COMMON_CERT, + ALPNCallback: ({ servername: offered, protocols }) => { + alpnServername = offered; + return protocols[0]; + }, + }); + const observed = Promise.withResolvers<{ socket: unknown; alpn: unknown }>(); + server.on("secureConnection", socket => { + observed.resolve({ socket: servernameOf(socket), alpn: alpnServername }); + socket.end(); + }); + server.on("tlsClientError", observed.reject); + let client: TLSSocket | undefined; + try { + const port = await listen(server); + // An IP host gets no SNI of its own: only `servername` adds one. + client = connect({ port, host: "127.0.0.1", servername, ALPNProtocols: ["a"], rejectUnauthorized: false }); + client.on("error", observed.reject); + expect(await observed.promise).toEqual({ socket: expected, alpn: expected }); + } finally { + client?.destroy(); + server.close(); + } + }, + ); + + it.each(sniCases)( + "a standalone server-side TLSSocket wrap reports servername after 'secure' (%s)", + async (_label, servername, expected) => { + const raw = net.createServer(); + const observed = Promise.withResolvers(); + let secured: TLSSocket | undefined; + raw.on("connection", socket => { + secured = new TLSSocket(socket, { isServer: true, ...COMMON_CERT }); + secured.on("error", observed.reject); + secured.on("secure", () => { + observed.resolve(servernameOf(secured!)); + secured!.end(); + }); + }); + let client: TLSSocket | undefined; + try { + const port = await listen(raw as unknown as Server); + client = connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }); + client.on("error", observed.reject); + expect(await observed.promise).toBe(expected); + } finally { + client?.destroy(); + secured?.destroy(); + raw.close(); + } + }, + ); }); describe("throwing 'secureConnection' listener", () => {