Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -782,7 +790,8 @@ const ServerHandlers: SocketHandler<NetSocket> = {
}
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
Expand Down Expand Up @@ -933,6 +942,10 @@ const ServerHandlers: SocketHandler<NetSocket> = {
} 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
Expand All @@ -947,7 +960,8 @@ const ServerHandlers: SocketHandler<NetSocket> = {
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,
Expand Down
73 changes: 73 additions & 0 deletions test/js/node/tls/node-tls-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>();
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", () => {
Expand Down