diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 2b2762a81e2b..fe8e5c6d95ae 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -356,8 +356,11 @@ function onClientHandshake(self, socket, success, verifyError) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1107 try { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1662-L1673 + // Unlike Node, don't gate on !isSessionReused(): BoringSSL keeps the peer + // chain on a resumed SSL_SESSION, so re-check it against this servername. + // The gate alone is the cross-servername resume of CVE-2026-48934. const { checkServerIdentity } = self[bunTLSConnectOptions]; - if (!verifyError && !self.isSessionReused() && typeof checkServerIdentity === "function") { + if (!verifyError && typeof checkServerIdentity === "function") { const options = self[kConnectOptions]; const hostname = self.servername || options?.host || options?.socket?._host || self._host || "localhost"; const cert = self.getPeerCertificate(true); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index cbb1c842a4ba..51519d2a11f0 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -1993,3 +1993,108 @@ describe.each([ }); }); }); + +it.each(["TLSv1.3", "TLSv1.2"] as const)( + "%s: re-checks server identity on a resumed session (cross-servername resume must not authorize)", + async version => { + // A resumed handshake sends no certificate, so a ticket alone says nothing + // about the name the peer was verified for. BoringSSL keeps the peer chain + // on the SSL_SESSION, and Bun re-checks that certificate against the current + // servername. Without the check, any peer that can decrypt the ticket (a + // vhost on the same context, a server that shares ticket keys) is authorized + // under a name its certificate does not cover. Node had the same hole + // (CVE-2026-48934). Node closes it earlier: it does not offer a session that + // was authenticated for another servername. + await using server = tls.createServer({ ...COMMON_CERT_, minVersion: version, maxVersion: version }, c => { + c.on("error", () => {}); + c.end("x"); + }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + + // Full handshake: capture the ticket. The server ends the connection, so by + // 'close' the post-handshake NewSessionTicket has arrived. + let ticket: Buffer | undefined; + const first = tlsConnect({ + port, + host: "127.0.0.1", + ca: COMMON_CERT_.cert, + servername: "localhost", + minVersion: version, + maxVersion: version, + }); + first.on("session", s => (ticket = s)); + first.on("data", () => {}); + first.on("error", () => {}); + await once(first, "close"); + expect(first.authorized).toBe(true); + expect(Buffer.isBuffer(ticket)).toBe(true); + + type Outcome = { + reused: boolean; + authorized: boolean; + authorizationError: unknown; + identityChecks: [string, unknown][]; + result: string; + }; + const resume = (servername: string) => + new Promise(resolve => { + const identityChecks: [string, unknown][] = []; + let done = false; + let reused = false; + const s = tlsConnect({ + port, + host: "127.0.0.1", + ca: COMMON_CERT_.cert, + servername, + session: ticket, + minVersion: version, + maxVersion: version, + checkServerIdentity: (host, cert) => { + identityChecks.push([host, cert?.subject?.CN]); + return tls.checkServerIdentity(host, cert); + }, + }); + const finish = (result: string) => { + if (done) return; + done = true; + s.destroy(); + resolve({ + reused, + authorized: s.authorized, + authorizationError: s.authorizationError, + identityChecks, + result, + }); + }; + s.on("secureConnect", () => (reused = s.isSessionReused())); + s.on("data", () => finish("ok")); + s.on("close", () => finish("ok")); + s.on("error", (e: NodeJS.ErrnoException) => finish(String(e.code ?? e.message))); + }); + + // Resuming under a servername the certificate does not cover is rejected: + // the stored peer certificate (CN=server-bun, SAN=localhost) is re-checked + // against the new servername and fails exactly as it would on a full + // handshake. + expect(await resume("not-in-cert.example")).toEqual({ + reused: false, + authorized: false, + authorizationError: "ERR_TLS_CERT_ALTNAME_INVALID", + identityChecks: [["not-in-cert.example", "server-bun"]], + result: "ERR_TLS_CERT_ALTNAME_INVALID", + }); + + // Resuming under the same servername still authorizes. checkServerIdentity + // runs again, on the stored certificate. Node does not call it on a resumed + // session. + expect(await resume("localhost")).toEqual({ + reused: true, + authorized: true, + authorizationError: null, + identityChecks: [["localhost", "server-bun"]], + result: "ok", + }); + }, +);