Skip to content
Merged
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
5 changes: 4 additions & 1 deletion src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
// The gate alone is the cross-servername resume of CVE-2026-48934.
Comment thread
robobun marked this conversation as resolved.
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);
Expand Down
105 changes: 105 additions & 0 deletions test/js/node/tls/node-tls-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Outcome>(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",
});
},
);