From 05a04487c6dc1c6d99f2017547cc471c0bb80ab4 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 5 Oct 2026 23:40:41 +0000 Subject: [PATCH 001/174] test(tls): tighten and speed up existing TLS coverage Test-only. Tightens the syscall-fault assertions, covers terminate() sending a bare RST, stop(true) from a selection callback, the redirect-hop TLS identity and the tunnel SNI; runs the cluster setSecureContext test in one process, the handshake-queue fixtures over a unix socket on macOS, and fewer destroySoon connections on debug/ASAN builds. The custom-lookup test passes agent: false so a reused keep-alive socket cannot skip the lookup it asserts on. --- test/js/bun/http/proxy.test.ts | 40 +- test/js/bun/net/socket-syscall-fault.test.ts | 11 +- test/js/bun/net/socket.test.ts | 82 ++-- test/js/bun/net/tcp-server.test.ts | 25 ++ .../bun/net/tls-close-all-sibling-fixture.ts | 26 +- test/js/bun/net/tls-fixture-transport.ts | 29 ++ .../net/tls-pause-parked-handshake-fixture.ts | 12 +- .../net/tls-stop-from-selection-fixture.ts | 63 +++ test/js/node/tls/node-tls-server.test.ts | 38 +- ...tls-cluster-set-secure-context-fixture.mjs | 57 +-- test/js/node/tls/tls-syscall-fault.test.ts | 380 ++++++++++-------- test/js/web/fetch/fetch.tls.test.ts | 11 +- test/regression/issue/27890.test.ts | 255 ++++-------- 13 files changed, 581 insertions(+), 448 deletions(-) create mode 100644 test/js/bun/net/tls-fixture-transport.ts create mode 100644 test/js/bun/net/tls-stop-from-selection-fixture.ts diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 8ed4f8e2c677..58753c65dac5 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -835,11 +835,11 @@ test("HTTPS proxy tunnel keep-alive does not share tunnel across different targe expect(connects.sort()).toEqual([`CONNECT localhost:${serverA.port}`, `CONNECT localhost:${serverB.port}`].sort()); }); -// The TLS handshake inside a CONNECT tunnel is keyed to the URL host like a -// direct connection: the ClientHello SNI and the certificate verification use -// the URL host, and a caller-supplied Host header only travels as an HTTP -// field on the tunneled request. -test("HTTPS proxy tunnel keeps a caller-supplied Host header out of SNI and certificate verification", async () => { +// The TLS handshake inside a CONNECT tunnel is keyed like a direct connection: +// the ClientHello SNI and the certificate verification use tls.servername when +// set, otherwise the URL host. A caller-supplied Host header only travels as an +// HTTP field on the tunneled request. +test("HTTPS proxy tunnel derives the inner SNI from tls.servername or the URL host, never from the Host header", async () => { const seen: { sni: string | null; host: string | undefined }[] = []; const target = tls.createServer( { @@ -861,14 +861,28 @@ test("HTTPS proxy tunnel keeps a caller-supplied Host header out of SNI and cert await once(target, "listening"); try { const port = (target.address() as net.AddressInfo).port; - const res = await fetch(`https://localhost:${port}/`, { - proxy: httpProxyServer.url, - keepalive: false, - headers: { Host: "other.example" }, - tls: { ca: tlsCert.cert }, - }); - expect(`${res.status} ${await res.text()}`).toBe("200 ok"); - expect(seen).toEqual([{ sni: "localhost", host: "other.example" }]); + const get = async (url: string, init: { headers?: Record; tls?: Record }) => { + const res = await fetch(url, { + proxy: httpProxyServer.url, + keepalive: false, + headers: init.headers, + tls: { ca: tlsCert.cert, ...init.tls }, + }); + return `${res.status} ${await res.text()}`; + }; + // DNS URL host with a foreign Host header: SNI is the URL host. + expect(await get(`https://localhost:${port}/`, { headers: { Host: "other.example" } })).toBe("200 ok"); + // IP URL host with tls.servername: SNI is the servername (the documented + // "dial an IP, verify a name" opt-in), with or without a Host header. + expect(await get(`https://127.0.0.1:${port}/`, { tls: { servername: "localhost" } })).toBe("200 ok"); + expect( + await get(`https://127.0.0.1:${port}/`, { headers: { Host: "other.example" }, tls: { servername: "localhost" } }), + ).toBe("200 ok"); + expect(seen).toEqual([ + { sni: "localhost", host: "other.example" }, + { sni: "localhost", host: `127.0.0.1:${port}` }, + { sni: "localhost", host: "other.example" }, + ]); } finally { target.close(); } diff --git a/test/js/bun/net/socket-syscall-fault.test.ts b/test/js/bun/net/socket-syscall-fault.test.ts index f8e3e999a649..e6a95b47812f 100644 --- a/test/js/bun/net/socket-syscall-fault.test.ts +++ b/test/js/bun/net/socket-syscall-fault.test.ts @@ -1,6 +1,6 @@ import { socketFaultInjection as fault } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, isWindows } from "harness"; +import { bunEnv, bunExe, isLinux, isMacOS, isWindows, tempDir } from "harness"; import { once } from "node:events"; import http2 from "node:http2"; import net from "node:net"; @@ -20,10 +20,17 @@ const skip = !fault.available() || isWindows; // The explicit timeout is required: a bare `bun bd test ` applies Bun's // 5000ms default, and each fixture spawns two Bun processes and takes a few // seconds on a debug+ASAN build. +// +// On macOS the fixtures get a directory for unix sockets, because TCP loopback +// does not deliver inside write() there (see tls-fixture-transport.ts). The +// low-prio fixture does not use it. It needs fault injection, so on macOS only +// a local debug build runs it and no CI lane does: a change of its transport +// could not be checked there. async function runFixture(name: string) { + using unixDir = isMacOS ? tempDir("tlsq", {}) : undefined; await using proc = Bun.spawn({ cmd: [bunExe(), join(import.meta.dir, name)], - env: bunEnv, + env: { ...bunEnv, TLS_FIXTURE_UNIX_DIR: unixDir && String(unixDir) }, stdout: "pipe", stderr: "pipe", }); diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 5cc4e33700bd..f913f5ac935a 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -5195,9 +5195,8 @@ describe.concurrent("close() error after the peer resets the connection", () => describe.each(["tcp", "tls"] as const)("%s", transport => { // The peer lives in a child process that is killed while data it never read sits // in its receive buffer: the kernel then closes its socket with an RST, and - // nothing (no FIN, and for TLS no close_notify) is queued ahead of the reset. An - // in-process terminate() is not usable for the TLS case: it writes a close_notify - // first, and on POSIX the reading side consumes that as a clean end. + // nothing (no FIN, and for TLS no close_notify) is queued ahead of the reset. + // The second test resets from inside this process with terminate() instead. const peerSource = ` await Bun.connect({ hostname: "127.0.0.1", @@ -5261,45 +5260,56 @@ describe.concurrent("close() error after the peer resets the connection", () => expect(closeErrorShape(await closedWith.promise)).toEqual(readReset); }); - }); - it("a connected socket reports the reset the same way (tcp)", async () => { - // Plain TCP terminate() queues nothing ahead of the RST, so the server side of - // the same process can reset the connection. - const accepted = Promise.withResolvers(); - using listener = Bun.listen({ - hostname: "127.0.0.1", - port: 0, - socket: { - open(socket) { - accepted.resolve(socket); - socket.write("greeting"); + it("a connected socket reports the accepted socket's terminate() the same way", async () => { + // terminate() queues nothing ahead of the RST. For tls that means no close_notify: + // a reading peer takes a close_notify as a clean end (close() without an error), + // and on POSIX the queued alert is delivered before the reset behind it. + const accepted = Promise.withResolvers(); + const greet = (socket: Socket) => { + accepted.resolve(socket); + socket.write("greeting"); + }; + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: transport === "tls" ? tls : undefined, + socket: { + open(socket) { + if (transport === "tcp") greet(socket); + }, + handshake(socket, success, authorizationError) { + if (success) greet(socket); + else accepted.reject(authorizationError ?? new Error("server handshake failed")); + }, + data() {}, + close() {}, }, - data() {}, - close() {}, - }, - }); + }); - const greeted = Promise.withResolvers(); - const closedWith = Promise.withResolvers(); - await Bun.connect({ - hostname: "127.0.0.1", - port: listener.port, - socket: { - data: () => greeted.resolve(), - connectError: (_socket, error) => greeted.reject(error), - close(_socket, error) { - greeted.reject(new Error("the connected socket closed before the greeting arrived")); - closedWith.resolve(error as CloseError); + const greeted = Promise.withResolvers(); + const closedWith = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + tls: transport === "tls" ? { ca: tls.cert } : undefined, + socket: { + data: () => greeted.resolve(), + error: (_socket, error) => greeted.reject(error), + connectError: (_socket, error) => greeted.reject(error), + close(_socket, error) { + greeted.reject(new Error("the connected socket closed before the greeting arrived")); + closedWith.resolve(error as CloseError); + }, }, - }, - }); + }); - const server = await accepted.promise; - await greeted.promise; - server.terminate(); + const server = await accepted.promise; + await greeted.promise; + server.terminate(); - expect(closeErrorShape(await closedWith.promise)).toEqual(readReset); + expect(closeErrorShape(await closedWith.promise)).toEqual(readReset); + }); }); }); diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index dac52c5eab26..2202c2e742dd 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -312,3 +312,28 @@ it("should not leak memory", async () => { expect(stdout).toBe(""); expect(exitCode).toBe(0); }); + +// stop(true) from alpnCallback or serverName closes the socket whose SSL call +// is on the stack, so that close waits until the callback returns. stop(true) +// once spun on that socket and never returned. The fixture runs in a child +// process, because such a spin also blocks the event loop that times a test +// out. The cases are not concurrent: the runner kills a child that a timed-out +// test leaves behind only when that test runs alone. +describe("TLS listener: stop(true) from inside a selection callback", () => { + it.each(["alpnCallback", "serverName"])("%s: stop(true) returns and the connection closes", async hook => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "tls-stop-from-selection-fixture.ts"), hook], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + events: ["returned", "handshake:false", "close"], + clientConnected: false, + serverClosedFirst: true, + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/net/tls-close-all-sibling-fixture.ts b/test/js/bun/net/tls-close-all-sibling-fixture.ts index 613a2205b555..32c15d138705 100644 --- a/test/js/bun/net/tls-close-all-sibling-fixture.ts +++ b/test/js/bun/net/tls-close-all-sibling-fixture.ts @@ -37,6 +37,7 @@ import { getEventLoopStats } from "bun:internal-for-testing"; import net from "node:net"; import tls from "node:tls"; import { tls as certs, bunEnv, bunExe } from "harness"; +import { listen } from "./tls-fixture-transport"; const N = 64; // Well inside the test's own timeout, so a stalled step still gets to print @@ -126,28 +127,31 @@ async function captureClientHello(): Promise { const clientHello = await captureClientHello(); // The child reads one command per line on stdin: -// connect -> connects n sockets, writes ONLY the ClientHello to -// each, answers `hellos ` once every write callback -// has fired, and later `closed ` once all -// n sockets have closed, where is how many -// of them received any bytes first -// exit -> exits 0 +// connect
-> connects n sockets to the port or the unix socket +// path (see tls-fixture-transport.ts), writes ONLY +// the ClientHello to each, answers `hellos ` once +// every write callback has fired, and later +// `closed ` once all n sockets have +// closed, where is how many of them +// received any bytes first +// exit -> exits 0 const clientSrc = ` const net = require("node:net"); const readline = require("node:readline"); const hello = Buffer.from(process.env.REPRO_HELLO, "hex"); const say = line => process.stdout.write(line + "\\n"); readline.createInterface({ input: process.stdin }).on("line", line => { - const [cmd, arg, arg2] = line.split(" "); + const [cmd, arg, ...rest] = line.split(" "); if (cmd === "connect") { const n = Number(arg); + const address = rest.join(" "); let connected = 0; let written = 0; let closed = 0; let flights = 0; const socks = []; for (let i = 0; i < n; i++) { - const c = net.connect(Number(arg2), "127.0.0.1"); + const c = /^\\d+$/.test(address) ? net.connect(Number(address), "127.0.0.1") : net.connect(address); c.setNoDelay(true); let gotData = false; c.on("error", () => {}); @@ -246,9 +250,7 @@ async function scenario(kind: Scenario["kind"]) { other.end(); } - const server = Bun.listen({ - hostname: "127.0.0.1", - port: 0, + const { server, address } = listen(kind, { tls: { key: certs.key, cert: certs.cert }, socket: { open(s) { @@ -288,7 +290,7 @@ async function scenario(kind: Scenario["kind"]) { }); try { - send(`connect ${N} ${server.port}`); + send(`connect ${N} ${address}`); await withDeadline(allOpen.promise, `${N} opens`); await expectLine(`hellos ${N}`); if (kind === "parked") { diff --git a/test/js/bun/net/tls-fixture-transport.ts b/test/js/bun/net/tls-fixture-transport.ts new file mode 100644 index 000000000000..c751f6f1ec3b --- /dev/null +++ b/test/js/bun/net/tls-fixture-transport.ts @@ -0,0 +1,29 @@ +// The transport of tls-pause-parked-handshake-fixture.ts and tls-close-all-sibling-fixture.ts. +// +// Both count what one loop iteration reads, so a write() must return with its bytes already in +// the peer's receive buffer. TCP loopback does that on Linux and Windows. macOS hands a loopback +// segment to the dlil input thread first, and on a busy machine it arrives many iterations later. +// An AF_UNIX stream delivers inside write() everywhere. So on macOS the test passes a directory +// (runFixture in socket-syscall-fault.test.ts) and the fixtures use unix sockets in it. The +// handshake queue and the teardown walk are the same code for both kinds of socket. +import type { UnixSocketOptions } from "bun"; +import { join } from "node:path"; + +type Options = Omit, "unix">; + +/** `address` is what connect() and node's net.connect() take: the port, or the unix socket path. */ +export function listen(name: string, options: Options) { + const dir = process.env.TLS_FIXTURE_UNIX_DIR; + if (dir) { + const unix = join(dir, name + ".sock"); + return { server: Bun.listen({ ...options, unix }), address: unix }; + } + const server = Bun.listen({ ...options, hostname: "127.0.0.1", port: 0 }); + return { server, address: server.port }; +} + +export function connect(address: string | number, options: Options) { + return typeof address === "string" + ? Bun.connect({ ...options, unix: address }) + : Bun.connect({ ...options, hostname: "127.0.0.1", port: address }); +} diff --git a/test/js/bun/net/tls-pause-parked-handshake-fixture.ts b/test/js/bun/net/tls-pause-parked-handshake-fixture.ts index 3d0d04b61ce4..337005ed3a2e 100644 --- a/test/js/bun/net/tls-pause-parked-handshake-fixture.ts +++ b/test/js/bun/net/tls-pause-parked-handshake-fixture.ts @@ -10,7 +10,8 @@ // handshake ran, and `handshake` and `data` were delivered to a socket whose pause() had returned. // // Server and clients share this process, so a loop iteration is one step for both sides and the -// iteration counter is an exact clock: +// iteration counter is an exact clock, as long as a write() delivers at once (see +// tls-fixture-transport.ts): // 1. N clients connect. The server pauses each accepted socket in `open`, so every ClientHello // stays unread in the kernel. // 2. The server resumes all N at once. The next iteration reads 5 ClientHellos (their clients @@ -23,6 +24,7 @@ import type { Socket } from "bun"; import { getEventLoopStats } from "bun:internal-for-testing"; import { tls as certs } from "harness"; +import { connect, listen } from "./tls-fixture-transport"; const N = 32; // MAX_LOW_PRIO_SOCKETS_PER_LOOP_ITERATION in packages/bun-usockets/src/loop.c. @@ -68,9 +70,7 @@ const accepted: Socket[] = []; let clientOpens = 0; let clientHandshakes = 0; -const server = Bun.listen({ - hostname: "127.0.0.1", - port: 0, +const { server, address } = listen("s", { tls: { key: certs.key, cert: certs.cert }, socket: { open(s) { @@ -98,9 +98,7 @@ const clients: Socket[] = []; try { for (let i = 0; i < N; i++) { clients.push( - await Bun.connect({ - hostname: "127.0.0.1", - port: server.port, + await connect(address, { tls: { rejectUnauthorized: false }, socket: { // The ClientHello goes out as soon as this returns. diff --git a/test/js/bun/net/tls-stop-from-selection-fixture.ts b/test/js/bun/net/tls-stop-from-selection-fixture.ts new file mode 100644 index 000000000000..2ac36cb3e41d --- /dev/null +++ b/test/js/bun/net/tls-stop-from-selection-fixture.ts @@ -0,0 +1,63 @@ +// listener.stop(true) called from alpnCallback or serverName. +// +// Both hooks run inside the SSL read that processes the ClientHello, so the +// accepted socket's SSL call is still on the stack when stop(true) closes every +// connection. That close waits until the SSL call returns, and the socket stays +// in the group until then. stop(true) must return without it, and the socket +// must close when the callback returns. +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { connect } from "node:tls"; + +const hook = process.argv[2]; +const fixtures = join(import.meta.dir, "../../node/tls/fixtures"); +const events: string[] = []; +const serverClosed = Promise.withResolvers(); + +const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { + key: readFileSync(join(fixtures, "agent1-key.pem")), + cert: readFileSync(join(fixtures, "agent1-cert.pem")), + }, + socket: { + [hook]() { + server.stop(true); + events.push("returned"); + return hook === "alpnCallback" ? "x/1" : undefined; + }, + handshake(_socket, success) { + events.push(`handshake:${success}`); + }, + data() {}, + close() { + events.push("close"); + serverClosed.resolve(); + }, + }, +}); + +// The handshake never completes, so the client does not verify the certificate. +const client = connect({ + port: server.port, + host: "127.0.0.1", + servername: "localhost", + ALPNProtocols: ["x/1"], + rejectUnauthorized: false, +}); +const clientConnected = await new Promise(resolve => { + client.on("secureConnect", () => resolve(true)); + client.on("error", () => resolve(false)); + client.on("close", () => resolve(false)); +}); +// stop(true) has to close the server side by itself. Its close handler runs +// when the callback returns, before the client can see any outcome, so a close +// that only the cleanup below causes does not count. +const serverClosedFirst = events.includes("close"); +// If the server left the connection open, this closes it, so the events are +// printed and the test fails on them and not on a timeout. +client.destroy(); +await serverClosed.promise; + +console.log(JSON.stringify({ events, clientConnected, serverClosedFirst })); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 661c61a8234e..79ef5fb827e3 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1,8 +1,9 @@ // debug-only export import { sslCtxLiveCount } from "bun:internal-for-testing"; +import cluster from "cluster"; import crypto from "crypto"; import { readFileSync, realpathSync } from "fs"; -import { bunEnv, bunExe, tls as cert1, isDebug, isWindows } from "harness"; +import { bunEnv, bunExe, tls as cert1, isASAN, isDebug, isWindows } from "harness"; import http2 from "http2"; import https from "https"; import net, { AddressInfo } from "net"; @@ -1792,18 +1793,24 @@ describe("setSecureContext() on a listening server", () => { // A cluster worker's listen() completes when the primary answers. A call // made before that has to reach the listener the worker then creates. it("counts in a cluster worker when called before 'listening'", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "tls-cluster-set-secure-context-fixture.mjs")], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // stderr only shows up in the failure message of a worker that printed nothing. - expect(stdout.trim() || stderr).toBe( - JSON.stringify({ handleAfterListen: "none", default: "agent3", viaAddContext: "agent2" }), - ); - expect(exitCode).toBe(0); + // This process is the primary, so the test starts one process and not two. + const settings = cluster.settings; + cluster.setupPrimary({ exec: join(import.meta.dir, "tls-cluster-set-secure-context-fixture.mjs"), execArgv: [] }); + const worker = cluster.fork(bunEnv); + cluster.settings = settings; + const exited = once(worker, "exit"); + try { + const served = await Promise.race([ + once(worker, "message").then(([message]) => message), + exited.then(([code, signal]) => { + throw new Error(`the worker exited before it reported: code ${code}, signal ${signal}`); + }), + ]); + expect(served).toEqual({ handleAfterListen: "none", default: "agent3", viaAddContext: "agent2" }); + } finally { + worker.kill(); + await exited; + } }); }); @@ -1964,9 +1971,12 @@ describe("tls.Server socket destroySoon", () => { // destroySoon() after end(big) must deliver every byte even when the TLS write // batcher's final flush spills (#31584). The spill/kernel-buffer race hits ~4% of // connections at this payload, so loop (mirrors test-tls-client-destroy-soon.js). + // Under debug/ASAN each connection costs ~50-150ms (connection setup and teardown, + // not the payload), so 64 of them overran the 5s default timeout. + const connections = isDebug || isASAN ? 8 : 64; it("delivers the whole stream when destroySoon follows end", async () => { const big = Buffer.alloc(2 * 1024 * 1024, "Y"); - for (let i = 0; i < 64; i++) { + for (let i = 0; i < connections; i++) { const { promise, resolve, reject } = Promise.withResolvers(); const server = createServer(COMMON_CERT, socket => { socket.on("error", reject); diff --git a/test/js/node/tls/tls-cluster-set-secure-context-fixture.mjs b/test/js/node/tls/tls-cluster-set-secure-context-fixture.mjs index 925f1345a948..5baee77f3279 100644 --- a/test/js/node/tls/tls-cluster-set-secure-context-fixture.mjs +++ b/test/js/node/tls/tls-cluster-set-secure-context-fixture.mjs @@ -1,7 +1,6 @@ -// A cluster worker's listen() completes only when the primary answers. This -// worker calls setSecureContext() and addContext() before that, then reports -// the certificates it serves. -import cluster from "node:cluster"; +// Runs as a cluster worker, whose listen() completes only when the primary +// answers. It calls setSecureContext() and addContext() before that, then +// reports the certificates it serves. import { once } from "node:events"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -10,35 +9,23 @@ import tls from "node:tls"; const fixture = name => readFileSync(join(import.meta.dirname, "fixtures", name), "utf8"); const pair = agent => ({ key: fixture(`${agent}-key.pem`), cert: fixture(`${agent}-cert.pem`) }); -if (cluster.isPrimary) { - const worker = cluster.fork(); - worker.on("message", served => { - console.log(JSON.stringify(served)); - worker.kill(); - }); - worker.on("exit", () => process.exit(0)); -} else { - const server = tls.createServer(pair("agent1"), socket => socket.end()); - server.listen(0, "127.0.0.1"); - const handleAfterListen = server._handle == null ? "none" : "set"; - server.setSecureContext(pair("agent3")); - server.addContext("sni.example", pair("agent2")); - await once(server, "listening"); +const server = tls.createServer(pair("agent1"), socket => socket.end()); +server.listen(0, "127.0.0.1"); +const handleAfterListen = server._handle == null ? "none" : "set"; +server.setSecureContext(pair("agent3")); +server.addContext("sni.example", pair("agent2")); +await once(server, "listening"); - const { port } = server.address(); - const presented = async servername => { - const client = tls.connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }); - try { - await once(client, "secureConnect"); - return client.getPeerCertificate().subject.CN; - } finally { - client.destroy(); - } - }; - process.send({ - handleAfterListen, - default: await presented(undefined), - viaAddContext: await presented("sni.example"), - }); - server.close(); -} +const { port } = server.address(); +const presented = async servername => { + const client = tls.connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }); + try { + await once(client, "secureConnect"); + return client.getPeerCertificate().subject.CN; + } finally { + client.destroy(); + } +}; +const [byDefault, viaAddContext] = await Promise.all([presented(undefined), presented("sni.example")]); +process.send({ handleAfterListen, default: byDefault, viaAddContext }); +server.close(); diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 8f11e31c0f72..47bbc0b3711d 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -1,8 +1,9 @@ import { socketFaultInjection as fault } from "bun:internal-for-testing"; -import { afterEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tls as certs, isWindows } from "harness"; import { once } from "node:events"; import https from "node:https"; +import type { AddressInfo } from "node:net"; import { join } from "node:path"; import tls from "node:tls"; @@ -10,180 +11,201 @@ const skip = !fault.available() || isWindows; afterEach(() => fault.clear()); -// The loop's shared TLS plaintext buffer is one lazy 512 KiB malloc, and its -// NULL return used to be ignored: SSL_read then wrote to -// `NULL + LIBUS_RECV_BUFFER_PADDING`. Runs in a child because the allocation -// happens once per event loop, on its first TLS socket. No isWindows skip — -// the unchecked allocation is exactly the one that fails there. -// -// Every marker the fixture can print. A crashing debug build symbolizes its -// backtrace onto stdout, so match the fixture's lines rather than the whole -// stream; anything past "ARMED" means a TLS socket survived the failed -// allocation and reached its read loop. -const OOM_FIXTURE_MARKERS = ["ARMED", "READ DATA", "CLOSED", "CLIENT ERROR"]; -test.skipIf(!fault.available())( - "a failed per-loop TLS buffer allocation reports out of memory instead of faulting inside SSL_read", - async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), join(import.meta.dir, "tls-loop-buffer-oom-fixture.ts")], +// The OOM fixture is a separate bun process that aborts on purpose. It is +// started here, before the first test, so it runs alongside the serial +// in-process tests below and is only awaited by the last test of the file. +// It sets no fault in this process. +let oomChild: { proc: Bun.Subprocess; result: Promise<[string, string, number]> } | null = null; + +// One TLS server for every in-process test: the faults are injected into the +// client side's syscalls, and each test drives exactly one connection at a +// time, so the listening socket is never affected. Each accepted socket +// swallows its own error: the process-wide faults hit the server side too. +let server: tls.Server; +let port: number; + +beforeAll(async () => { + if (fault.available()) { + const proc = Bun.spawn({ + cmd: [ + bunExe(), + // Skip the debug build's symbolized backtrace: it costs seconds and the + // assertion only needs the crash reason line. + "--debug-crash-handler-use-trace-string", + join(import.meta.dir, "tls-loop-buffer-oom-fixture.ts"), + ], // BUN_CRASH_REPORT_URL="": this OOM is deliberate; uploading it to CI's // remap server would pin a spurious "crash reported" error on the next // unrelated failing test. env: { ...bunEnv, BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPORTING: "0" }, + stdin: "ignore", stdout: "pipe", stderr: "pipe", }); - const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // `CrashReason::OutOfMemory` phrasing varies with SHOW_CRASH_TRACE, so - // match the shared substring (see run-crash-handler.test.ts). - const outOfMemory = stderr.toLowerCase().includes("out of memory"); - expect({ - markers: stdout - .split("\n") - .map(line => line.trim()) - .filter(line => OOM_FIXTURE_MARKERS.includes(line)), - outOfMemory, - // Only populated when the assertion is about to fail, so the diff shows why. - stderrTail: outOfMemory ? "" : stderr.slice(-2000), - }).toEqual({ markers: ["ARMED"], outOfMemory: true, stderrTail: "" }); - }, - // Symbolizing the crash backtrace of a debug/ASAN binary takes several - // seconds on its own, well past the default per-test budget. - 60_000, -); - -async function connectedTLSPair(onServerSocket?: (s: tls.TLSSocket) => void) { - const server = tls.createServer({ key: certs.key, cert: certs.cert }); - server.on("secureConnection", s => { - s.on("error", () => {}); - onServerSocket?.(s); - }); + oomChild = { proc, result: Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) }; + } + if (skip) return; + server = tls.createServer({ key: certs.key, cert: certs.cert }); + server.on("secureConnection", s => s.on("error", () => {})); server.listen(0, "127.0.0.1"); await once(server, "listening"); - const port = (server.address() as import("node:net").AddressInfo).port; + port = (server.address() as AddressInfo).port; +}); + +afterAll(() => { + oomChild?.proc.kill(); + server?.close(); +}); +function connect(options: tls.ConnectionOptions = {}) { + return tls.connect({ port, host: "127.0.0.1", ca: certs.cert, rejectUnauthorized: true, ...options }); +} + +async function connectedTLSPair(onServerSocket?: (s: tls.TLSSocket) => void) { // Register the server-side listener before initiating connect so the // 'secureConnection' event cannot be missed. - const serverSockP = once(server, "secureConnection") as Promise<[tls.TLSSocket]>; - const client = tls.connect({ port, host: "127.0.0.1", ca: certs.cert, rejectUnauthorized: true }); - const [, [serverSock]] = await Promise.all([once(client, "secureConnect"), serverSockP]); + const serverSockP = (once(server, "secureConnection") as Promise<[tls.TLSSocket]>).then(([s]) => { + onServerSocket?.(s); + return s; + }); + const client = connect(); + const [, serverSock] = await Promise.all([once(client, "secureConnect"), serverSockP]); return { - server, client, serverSock, [Symbol.dispose]() { client.destroy(); serverSock.destroy(); - server.close(); }, }; } +type ObservedError = { name: string; code?: string; syscall?: string; message: string }; + +// Records the lifecycle events of a socket in the order they fire. `closed` +// resolves on 'close' whether or not an 'error' came first (events.once would +// reject on the error). +function observe(socket: tls.TLSSocket) { + const events: string[] = []; + const errors: ObservedError[] = []; + const closed = new Promise(resolve => socket.once("close", () => resolve())); + socket.on("end", () => events.push("end")); + socket.on("error", (e: NodeJS.ErrnoException) => { + events.push("error"); + errors.push({ name: e.name, code: e.code, syscall: e.syscall, message: e.message }); + }); + socket.on("close", hadError => events.push(`close(hadError=${hadError})`)); + return { events, errors, closed }; +} + +const CLEAN_CLOSE = ["end", "close(hadError=false)"]; +const RESET_CLOSE = { + events: ["error", "close(hadError=true)"], + errors: [{ name: "Error", code: "ECONNRESET", syscall: "read", message: "read ECONNRESET" }], + destroyed: true, +}; + +function collect(socket: tls.TLSSocket) { + const chunks: Buffer[] = []; + socket.on("data", c => chunks.push(c)); + return () => Buffer.concat(chunks); +} + describe.skipIf(skip)("node:tls under injected syscall faults", () => { test("recv → ECONNRESET during established session surfaces as 'error'", async () => { using p = await connectedTLSPair(); + const client = observe(p.client); fault.set({ syscall: "recv", action: "errno", errno: "ECONNRESET", repeat: -1 }); - const errP = once(p.client, "error"); p.serverSock.write("hello"); - const [err] = (await errP) as [NodeJS.ErrnoException]; - // BoringSSL may map a transport reset to ECONNRESET or to an SSL read error; - // either is acceptable, but the socket must be destroyed and not hang. - expect(err).toBeInstanceOf(Error); - expect(err).not.toBeInstanceOf(TypeError); - expect(p.client.destroyed).toBe(true); + await client.closed; + expect({ events: client.events, errors: client.errors, destroyed: p.client.destroyed }).toEqual(RESET_CLOSE); }); test("recv → short reads (1 byte) still decrypt complete payload", async () => { using p = await connectedTLSPair(); + const client = observe(p.client); + const received = collect(p.client); // The TLS record layer must reassemble across many tiny BIO reads. fault.set({ syscall: "recv", action: "short", bytes: 1, repeat: -1 }); - const chunks: Buffer[] = []; - p.client.on("data", (c: Buffer) => chunks.push(c)); const payload = Buffer.alloc(512, "Z"); - p.serverSock.write(payload); - p.serverSock.end(); - await once(p.client, "end"); - expect(Buffer.concat(chunks).equals(payload)).toBe(true); + p.serverSock.end(payload); + await client.closed; + expect(received()).toEqual(payload); + expect({ events: client.events, errors: client.errors }).toEqual({ events: CLEAN_CLOSE, errors: [] }); }); test("send → short writes (1 byte) still deliver complete encrypted payload", async () => { - let received = Buffer.alloc(0); + let received!: () => Buffer; + let serverSide!: ReturnType; using p = await connectedTLSPair(s => { - s.on("data", (c: Buffer) => (received = Buffer.concat([received, c]))); + received = collect(s); + serverSide = observe(s); }); fault.set({ syscall: "send", action: "short", bytes: 1, repeat: -1 }); const payload = Buffer.alloc(512, "Y"); - p.client.write(payload); - p.client.end(); - await once(p.serverSock, "end"); + p.client.end(payload); + await serverSide.closed; fault.clear(); - expect(received.equals(payload)).toBe(true); + expect(received()).toEqual(payload); + expect({ events: serverSide.events, errors: serverSide.errors }).toEqual({ events: CLEAN_CLOSE, errors: [] }); }); test("recv → 0 (peer closed) on established session emits 'end' without 'error'", async () => { using p = await connectedTLSPair(); - let gotError: unknown = null; - p.client.on("error", e => (gotError = e)); + const client = observe(p.client); + const received = collect(p.client); fault.set({ syscall: "recv", action: "zero", repeat: -1 }); - const endP = once(p.client, "end"); p.serverSock.write("hello"); - await endP; - expect(gotError).toBeNull(); + await client.closed; + // The injected EOF lands before the "hello" record is ever read. + expect({ events: client.events, errors: client.errors, bytes: received().length }).toEqual({ + events: CLEAN_CLOSE, + errors: [], + bytes: 0, + }); }); test("send → short writes during handshake still complete secureConnect", async () => { - const server = tls.createServer({ key: certs.key, cert: certs.cert }); - server.on("secureConnection", s => s.on("error", () => {})); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const port = (server.address() as import("node:net").AddressInfo).port; - try { - // Clamp every send to 3 bytes — the ClientHello/ServerHello/Finished - // flights are split across hundreds of partial writes. - fault.set({ syscall: "send", action: "short", bytes: 3, repeat: -1 }); - const serverSockP = once(server, "secureConnection") as Promise<[tls.TLSSocket]>; - const client = tls.connect({ port, host: "127.0.0.1", ca: certs.cert }); - const [, [serverSock]] = await Promise.all([once(client, "secureConnect"), serverSockP]); - expect(client.authorized).toBe(true); - client.destroy(); - serverSock.destroy(); - } finally { - fault.clear(); - server.close(); - } + // Clamp every send to 3 bytes — the ClientHello/ServerHello/Finished + // flights are split across hundreds of partial writes. + fault.set({ syscall: "send", action: "short", bytes: 3, repeat: -1 }); + using p = await connectedTLSPair(); + fault.clear(); + expect({ + authorized: p.client.authorized, + authorizationError: p.client.authorizationError, + serverEncrypted: p.serverSock.encrypted, + }).toEqual({ authorized: true, authorizationError: null, serverEncrypted: true }); }); test("recv → short reads at TLS record boundary (5 bytes = header only) still decrypt", async () => { using p = await connectedTLSPair(); + const client = observe(p.client); + const received = collect(p.client); // 5 bytes is exactly the TLS record header — forces the BIO to assemble // header and ciphertext across separate recv calls. fault.set({ syscall: "recv", action: "short", bytes: 5, repeat: -1 }); - const chunks: Buffer[] = []; - p.client.on("data", (c: Buffer) => chunks.push(c)); const payload = Buffer.alloc(256, "R"); - p.serverSock.write(payload); - p.serverSock.end(); - await once(p.client, "end"); - expect(Buffer.concat(chunks).equals(payload)).toBe(true); + p.serverSock.end(payload); + await client.closed; + expect(received()).toEqual(payload); + expect({ events: client.events, errors: client.errors }).toEqual({ events: CLEAN_CLOSE, errors: [] }); }); test("recv → ECONNRESET mid-handshake fails connect with an error (no hang)", async () => { - const server = tls.createServer({ key: certs.key, cert: certs.cert }, s => s.on("error", () => {})); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const port = (server.address() as import("node:net").AddressInfo).port; - try { - // Reset the very first wire read of the ServerHello. - fault.set({ syscall: "recv", action: "errno", errno: "ECONNRESET", repeat: -1 }); - const client = tls.connect({ port, host: "127.0.0.1", ca: certs.cert }); - const [err] = (await once(client, "error")) as [Error]; - expect(err).toBeTruthy(); - client.destroy(); - } finally { - fault.clear(); - server.close(); - } + // Reset the very first wire read of the ServerHello. + fault.set({ syscall: "recv", action: "errno", errno: "ECONNRESET", repeat: -1 }); + const c = connect(); + const client = observe(c); + let secureConnect = false; + c.on("secureConnect", () => (secureConnect = true)); + await client.closed; + fault.clear(); + expect({ events: client.events, errors: client.errors, destroyed: c.destroyed, secureConnect }).toEqual({ + ...RESET_CLOSE, + secureConnect: false, + }); }); }); @@ -194,67 +216,72 @@ describe.skipIf(skip)("node:tls close_notify / shutdown under faults", () => { // consumer makes the stream's backpressure pause the socket mid-burst. // No byte may be lost, and 'end' must come only after all of them. const BIG = 192 * 1024; - const server = tls.createServer({ key: certs.key, cert: certs.cert }); const serverClosed = Promise.withResolvers(); - server.on("secureConnection", s => { - s.on("error", () => {}); + using p = await connectedTLSPair(s => { s.on("close", () => serverClosed.resolve()); s.end(Buffer.alloc(BIG, "Y")); s.destroySoon(); }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const port = (server.address() as import("node:net").AddressInfo).port; - const client = tls.connect({ port, host: "127.0.0.1", ca: certs.cert, rejectUnauthorized: true }); - client.on("error", () => {}); - try { - await once(client, "secureConnect"); - client.pause(); - await serverClosed.promise; - fault.set({ syscall: "recv", action: "short", bytes: 65536, repeat: -1 }); - let bytes = 0; - client.on("readable", () => { - let chunk; - while ((chunk = client.read()) !== null) bytes += chunk.length; - }); - const ended = once(client, "end"); - client.resume(); - await ended; - fault.clear(); - expect(bytes).toBe(BIG); - } finally { - client.destroy(); - server.close(); - } + const client = observe(p.client); + p.client.pause(); + await serverClosed.promise; + fault.set({ syscall: "recv", action: "short", bytes: 65536, repeat: -1 }); + let bytes = 0; + let bytesAtEnd = -1; + p.client.on("readable", () => { + let chunk; + while ((chunk = p.client.read()) !== null) bytes += chunk.length; + }); + p.client.on("end", () => (bytesAtEnd = bytes)); + p.client.resume(); + await client.closed; + fault.clear(); + expect({ bytes, bytesAtEnd, events: client.events, errors: client.errors }).toEqual({ + bytes: BIG, + bytesAtEnd: BIG, + events: CLEAN_CLOSE, + errors: [], + }); }); test("client.end() under 1-byte sends still delivers close_notify and peer sees clean 'end'", async () => { - using p = await connectedTLSPair(); - let serverGotEnd = false; - p.serverSock.on("end", () => (serverGotEnd = true)); + let serverSide!: ReturnType; + using p = await connectedTLSPair(s => (serverSide = observe(s))); + const client = observe(p.client); fault.set({ syscall: "send", action: "short", bytes: 1, repeat: -1 }); p.client.end(); - await once(p.serverSock, "close"); + await Promise.all([serverSide.closed, client.closed]); fault.clear(); - expect(serverGotEnd).toBe(true); + expect({ + server: { events: serverSide.events, errors: serverSide.errors }, + client: { events: client.events, errors: client.errors }, + }).toEqual({ + server: { events: CLEAN_CLOSE, errors: [] }, + client: { events: CLEAN_CLOSE, errors: [] }, + }); }); test("server.end() with recv → 0 immediately after (FIN before close_notify drained) reaches 'close'", async () => { // Exercises openssl.c on_end (TCP FIN under TLS): close_notify may not // have been read yet when the transport reports EOF. using p = await connectedTLSPair(); - p.client.on("error", () => {}); + const client = observe(p.client); // The client must consume its readable side for the allowHalfOpen:false // teardown to run: with "bye" left unread, Node never emits 'end' and never // destroys (stream_base_commons.js defers kMaybeDestroy until 'end'). - p.client.resume(); + const received = collect(p.client); fault.set({ syscall: "recv", action: "zero", after: 1, repeat: -1 }); p.serverSock.end("bye"); - await once(p.client, "close"); + await client.closed; fault.clear(); - // close_notify was truncated by the injected EOF, but the socket must - // still reach 'close' without hanging. - expect(p.client.destroyed).toBe(true); + // close_notify was truncated by the injected EOF, but the data read before + // it must be delivered and the socket must still reach 'close'. + expect({ + received: received().toString(), + events: client.events, + errors: client.errors, + destroyed: p.client.destroyed, + }).toEqual({ received: "bye", events: CLEAN_CLOSE, errors: [], destroyed: true }); }); }); @@ -328,26 +355,65 @@ describe.skipIf(skip)("node:tls seeded syscall fuzz", () => { test("randomized short-I/O during established echo delivers intact and never crashes", async () => { const rand = makePrng(seed); for (let i = 0; i < 12; i++) { - let echoed = Buffer.alloc(0); using p = await connectedTLSPair(s => { s.on("data", c => s.write(c)); }); - p.client.on("error", () => {}); - p.client.on("data", (c: Buffer) => (echoed = Buffer.concat([echoed, c]))); + const client = observe(p.client); + const echoed = collect(p.client); const plan = PLANS[Math.floor(rand() * PLANS.length)]!; fault.set({ ...plan, after: Math.floor(rand() * 2), repeat: -1 } as any); const payload = Buffer.alloc(128, i & 0xff); p.client.write(payload); - while (echoed.length < payload.length) { + while (echoed().length < payload.length) { await once(p.client, "data"); } fault.clear(); - expect(echoed.subarray(0, payload.length).equals(payload)).toBe(true); + expect(echoed()).toEqual(payload); p.client.destroy(); - await once(p.client, "close").catch(() => {}); - expect(p.client.destroyed).toBe(true); + await client.closed; + expect({ lastEvent: client.events.at(-1), errors: client.errors, destroyed: p.client.destroyed }).toEqual({ + lastEvent: "close(hadError=false)", + errors: [], + destroyed: true, + }); } }); }); + +// The loop's shared TLS plaintext buffer is one lazy 512 KiB malloc, and its +// NULL return used to be ignored: SSL_read then wrote to +// `NULL + LIBUS_RECV_BUFFER_PADDING`. Runs in a child because the allocation +// happens once per event loop, on its first TLS socket. No isWindows skip — +// the unchecked allocation is exactly the one that fails there. +// +// Last in the file: the child was started in beforeAll, so by now it has had +// the whole run of in-process tests to finish. +test.skipIf(!fault.available())( + "a failed per-loop TLS buffer allocation reports out of memory instead of faulting inside SSL_read", + async () => { + const { proc, result } = oomChild!; + const [stdout, stderr, exitCode] = await result; + // Anything past "ARMED" on stdout means a TLS socket survived the failed + // allocation and reached its read loop (see the fixture's markers). + const outOfMemory = stderr.includes("Bun has run out of memory."); + expect({ + stdout, + outOfMemory, + // Only populated when the assertion is about to fail, so the diff shows why. + stderr: outOfMemory ? "" : stderr, + signalCode: proc.signalCode, + }).toEqual({ + stdout: "ARMED\n", + outOfMemory: true, + stderr: "", + // The crash handler ends in abort() on POSIX and in ExitProcess(3) on Windows. + signalCode: isWindows ? null : "SIGABRT", + }); + expect(exitCode).not.toBe(0); + }, + // The child has already run alongside the other tests; this is the budget + // for whatever is left of an ASAN child's startup and crash under CI load. + 15_000, +); diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index d9aff3ecef3a..f1d77f495747 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -69,11 +69,14 @@ describe.concurrent("fetch-tls", () => { // whose certificate the callback has not approved. So a redirect chain // yields one observation per hop, in order. The hostname handed to the // callback is the URL host of that hop: a request-level Host header is an - // HTTP field only and never becomes the TLS identity. + // HTTP field only and never becomes the TLS identity. The two hops use + // different URL hosts, and the Host header names neither, so the second + // observation proves the identity is re-derived from the redirect target + // rather than inherited from the first hop or taken from the header. const verifiedHostnames: string[] = []; - const res = await fetch(`https://127.0.0.1:${origin.port}/`, { + const res = await fetch(`https://localhost:${origin.port}/`, { keepalive: false, - headers: { Host: "localhost" }, + headers: { Host: "other.example" }, tls: { ca: validTls.cert, checkServerIdentity(hostname: string) { @@ -84,7 +87,7 @@ describe.concurrent("fetch-tls", () => { }); expect(await res.text()).toBe("from-target"); - expect(verifiedHostnames).toEqual(["127.0.0.1", "127.0.0.1"]); + expect(verifiedHostnames).toEqual(["localhost", "127.0.0.1"]); // The redirect target must see a Host header derived from its own URL, // not the override that was supplied for the previous origin. expect(receivedHostHeaders).toEqual([`127.0.0.1:${target.port}`]); diff --git a/test/regression/issue/27890.test.ts b/test/regression/issue/27890.test.ts index e533f7b8cb23..3b4d952aa0ee 100644 --- a/test/regression/issue/27890.test.ts +++ b/test/regression/issue/27890.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "fs"; -import { bunEnv, bunExe } from "harness"; -import { join } from "path"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import dns from "node:dns"; +import { readFileSync } from "node:fs"; +import https from "node:https"; +import type { AddressInfo } from "node:net"; +import { join } from "node:path"; +import type { TLSSocket } from "node:tls"; // Self-signed cert with ONLY DNS:localhost in SANs (no IP SANs). // Valid from 2025-01-01 to 2035-01-01 to avoid CI clock skew issues. @@ -15,183 +18,99 @@ const localhostOnlyTls = { key: readFileSync(join(import.meta.dir, "27890-localhost-only.key"), "utf8"), }; -// Uses a local HTTPS server with self-signed certs to avoid CI environments -// lacking system CA certificates (Windows, Alpine). -describe("custom lookup with HTTPS", () => { - test("https.request with custom lookup should not break TLS", async () => { - using server = Bun.serve({ - tls: localhostOnlyTls, - port: 0, - fetch() { - return new Response("OK"); - }, - }); - - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` -const https = require("https"); -const port = ${server.port}; -const ca = ${JSON.stringify(localhostOnlyTls.cert)}; +type Result = { + status: number | undefined; + body: string; + // SNI hostname the server saw in the ClientHello (req.socket.servername), + // echoed back in the x-servername response header. "false" when none was sent. + servername: string; +}; -function customLookup(hostname, options, callback) { - // Resolve "localhost" to 127.0.0.1 — simulates the real-world scenario - // where a custom lookup returns an IP address for a hostname. - if (options && options.all) { - callback(null, [{ address: "127.0.0.1", family: 4 }]); - } else { - callback(null, "127.0.0.1", 4); - } +function request(options: https.RequestOptions): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const req = https.request({ ...options, ca: localhostOnlyTls.cert, agent: false }, res => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", chunk => (body += chunk)); + res.on("end", () => resolve({ status: res.statusCode, body, servername: res.headers["x-servername"] as string })); + }); + req.on("error", reject); + req.end(); + return promise; } -const req = https.request("https://localhost:" + port, { - lookup: customLookup, - ca, -}, (res) => { - let data = ""; - res.on("data", (chunk) => data += chunk); - res.on("end", () => { - console.log("status:" + res.statusCode + " body:" + data); - }); -}); -req.on("error", (e) => { - console.error("error:" + e.message + " " + (e.code || "")); - process.exitCode = 1; -}); -req.end(); -`, - ], - env: bunEnv, - stderr: "pipe", - stdout: "pipe", +// Uses a local HTTPS server with a self-signed cert to avoid CI environments +// lacking system CA certificates (Windows, Alpine). +describe.concurrent("custom lookup with HTTPS", () => { + let server: https.Server; + let port: number; + + beforeAll(async () => { + server = https.createServer(localhostOnlyTls, (req, res) => { + res.setHeader("x-servername", String((req.socket as TLSSocket).servername)); + res.end("OK"); }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + port = (server.address() as AddressInfo).port; + }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).not.toContain("certificate"); - expect(stderr).not.toContain("CERT_ALTNAME"); - expect(stdout).toContain("status:200"); - expect(stdout).toContain("body:OK"); - expect(exitCode).toBe(0); - }, 30_000); - - test("https.request without custom lookup should still work", async () => { - using server = Bun.serve({ - tls: localhostOnlyTls, - port: 0, - fetch() { - return new Response("OK"); - }, - }); + afterAll(() => { + server.close(); + }); - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` -const https = require("https"); -const port = ${server.port}; -const ca = ${JSON.stringify(localhostOnlyTls.cert)}; + test("https.request with custom lookup should not break TLS", async () => { + const lookedUp: string[] = []; + // Resolve "localhost" to 127.0.0.1. Simulates the real-world scenario + // where a custom lookup returns an IP address for a hostname. + function customLookup(hostname: string, options: dns.LookupOptions, callback: Function) { + lookedUp.push(hostname); + if (options?.all) { + callback(null, [{ address: "127.0.0.1", family: 4 }]); + } else { + callback(null, "127.0.0.1", 4); + } + } -const req = https.request("https://localhost:" + port, { ca }, (res) => { - let data = ""; - res.on("data", (chunk) => data += chunk); - res.on("end", () => { - console.log("status:" + res.statusCode + " body:" + data); + const result = await request({ host: "localhost", port, lookup: customLookup }); + expect(result).toEqual({ status: 200, body: "OK", servername: "localhost" }); + expect(lookedUp).toEqual(["localhost"]); }); -}); -req.on("error", (e) => { - console.error("error:" + e.message + " " + (e.code || "")); - process.exitCode = 1; -}); -req.end(); -`, - ], - env: bunEnv, - stderr: "pipe", - stdout: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).not.toContain("certificate"); - expect(stdout).toContain("status:200"); - expect(stdout).toContain("body:OK"); - expect(exitCode).toBe(0); - }, 30_000); + test("https.request without custom lookup should still work", async () => { + const result = await request({ host: "localhost", port }); + expect(result).toEqual({ status: 200, body: "OK", servername: "localhost" }); + }); test("custom lookup via dns.lookup should preserve hostname for TLS SNI", async () => { - // This test verifies the specific scenario from issue #27890: - // A custom lookup that resolves hostname to IP should not break TLS. - // The lookup uses dns.lookup (which checks /etc/hosts) to resolve - // "localhost" to an IP, but the original hostname must be preserved - // for SNI and certificate SAN matching. - // The cert only has DNS:localhost (no IP SANs), so if SNI is broken - // and the IP is used for cert verification, it WILL fail. - using server = Bun.serve({ - tls: localhostOnlyTls, - port: 0, - fetch() { - return new Response("OK"); - }, - }); - - await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` -const https = require("https"); -const dns = require("dns"); -const port = ${server.port}; -const ca = ${JSON.stringify(localhostOnlyTls.cert)}; - -// Custom lookup using dns.lookup — exercises the real resolution path -// (including /etc/hosts) where hostname is resolved to IP, reproducing -// the exact issue #27890 scenario. Forces IPv4 to avoid inconsistent -// results on dual-stack hosts. -function customLookup(hostname, options, callback) { - dns.lookup(hostname, { all: true, family: 4 }, (err, addresses) => { - if (err) { callback(err); return; } - if (options && options.all) { - callback(null, addresses); - } else { - const first = addresses[0]; - callback(null, first.address, first.family); + // This is the exact scenario from issue #27890: a custom lookup that uses + // dns.lookup (which checks /etc/hosts) to resolve "localhost" to an IP. + // The original hostname must still be used for SNI and certificate SAN + // matching. Forces IPv4 to avoid inconsistent results on dual-stack hosts. + const resolved: { hostname: string; addresses: string[] }[] = []; + function customLookup(hostname: string, options: dns.LookupOptions, callback: Function) { + dns.lookup(hostname, { all: true, family: 4 }, (err, addresses) => { + if (err) return callback(err); + // dns.lookup may list the same hosts-file entry more than once. + resolved.push({ hostname, addresses: [...new Set(addresses.map(a => `${a.address}/${a.family}`))] }); + if (options?.all) { + callback(null, addresses); + } else { + callback(null, addresses[0].address, addresses[0].family); + } + }); } - }); -} -const req = https.request("https://localhost:" + port, { - lookup: customLookup, - ca, -}, (res) => { - let data = ""; - res.on("data", (chunk) => data += chunk); - res.on("end", () => { - console.log("status:" + res.statusCode + " body:" + data); + const result = await request({ host: "localhost", port, lookup: customLookup }); + expect(result).toEqual({ status: 200, body: "OK", servername: "localhost" }); + expect(resolved).toEqual([{ hostname: "localhost", addresses: ["127.0.0.1/4"] }]); }); -}); -req.on("error", (e) => { - console.error("error:" + e.message + " " + (e.code || "")); - process.exitCode = 1; -}); -req.end(); -`, - ], - env: bunEnv, - stderr: "pipe", - stdout: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("certificate"); - expect(stderr).not.toContain("CERT_ALTNAME"); - expect(stdout).toContain("status:200"); - expect(stdout).toContain("body:OK"); - expect(exitCode).toBe(0); - }, 30_000); + test("connecting by IP fails certificate verification (cert has no IP SAN)", async () => { + // Proves the cert cannot be matched by IP. If the custom lookup tests + // above leaked the resolved IP into SNI or hostname verification, they + // would fail with this same error. + await expect(request({ host: "127.0.0.1", port })).rejects.toMatchObject({ + code: "ERR_TLS_CERT_ALTNAME_INVALID", + }); + }); }); From 9c876dc70c727b7d5e7e74cb9fc2b7b88e7fdc71 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:05:58 +0000 Subject: [PATCH 002/174] tls: ignore setSession() once the handshake has started (#41671) BoringSSL's SSL_set_session abort()s when the handshake has started, so any late socket.setSession(validSession) killed the process (exit 134) on node:tls clients and servers, TLS over a Duplex, Bun.connect, Bun.listen and both upgradeTLS halves. set_session now returns undefined, as Node does, for a server SSL or once the client random is filled: do_start_connect fills it right before it moves the handshake off its initial state and nothing clears it again, so it also covers a finished handshake and a renegotiation. The JS guard for standalone wraps is subsumed and removed. --- packages/bun-types/bun.d.ts | 6 +- src/js/node/tls.ts | 2 - src/runtime/socket/tls_socket_functions.rs | 41 ++- test/js/bun/net/socket.test.ts | 29 ++ test/js/node/tls/node-tls-connect.test.ts | 32 +- ...ode-tls-set-session-after-start.fixture.ts | 346 ++++++++++++++++++ 6 files changed, 438 insertions(+), 18 deletions(-) create mode 100644 test/js/node/tls/node-tls-set-session-after-start.fixture.ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 724593dc4762..c3aef5aca399 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7162,7 +7162,11 @@ declare module "bun" { getSession(): void; /** - * Sets the session of the socket. + * Sets the TLS session to offer for resumption. + * + * Only has an effect before the handshake starts: in `open`, before any + * `write()`, on a client socket that also has a `handshake` handler. A later + * call is ignored. * * @param session The session to set. */ diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 64c2b6c0a8fc..34dd02fc3c79 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1081,8 +1081,6 @@ TLSSocket.prototype.setServername = function setServername(name) { }; TLSSocket.prototype.setSession = function setSession(session) { - // A wrap sent its ClientHello in the constructor, and BoringSSL aborts the process on a session set after that. - if (this[kStandaloneWrap]) return; this[ksession] = session; if (typeof session === "string") session = Buffer.from(session, "latin1"); return this._handle?.setSession?.(session); diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 2130a73e31d0..f5e4d033ceec 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -101,6 +101,12 @@ pub(super) mod ffi { use_context: c_int, ) -> c_int; pub(crate) safe fn SSL_session_reused(ssl: &SSL) -> c_int; + // Copies at most `min(max_out, 32)` bytes, so a 32-byte `out` leaves no precondition. + pub(crate) safe fn SSL_get_client_random( + ssl: &SSL, + out: &mut [u8; 32], + max_out: usize, + ) -> usize; pub(crate) safe fn SSL_get_privatekey(ssl: &SSL) -> *mut EVP_PKEY; // ── SSL_SESSION ─────────────────────────────────────────────────── @@ -1163,29 +1169,26 @@ pub(super) fn set_session( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::UNDEFINED); }; + let ssl = boringssl::SSL::opaque_ref(ssl_ptr); + // SSL_set_session abort()s once the ClientHello is built, which is when the client random is filled. + let mut client_random = [0u8; 32]; + ffi::SSL_get_client_random(ssl, &mut client_random, 32); + if ffi::SSL_is_server(ssl) != 0 || client_random != [0u8; 32] { + return Ok(JSValue::UNDEFINED); + } let mut tmp: *const u8 = session_slice.as_ptr(); // SAFETY: tmp/session_slice.len() describe a valid readable buffer borrowed from `sb` for the duration of this call. - let session = unsafe { + let session = OwnedSession(unsafe { ffi::d2i_SSL_SESSION( core::ptr::null_mut(), &raw mut tmp, c_long::try_from(session_slice.len()).expect("int cast"), ) - }; - if session.is_null() { + }); + if session.0.is_null() { return Ok(JSValue::UNDEFINED); } - // SSL_set_session takes its own reference ("the caller retains ownership of |session|"), - // so we must release the one returned by d2i_SSL_SESSION on every path. - // SAFETY: `s` is the +1 SSL_SESSION reference returned by d2i_SSL_SESSION; we own it. - let _guard = scopeguard::guard(session, |s| unsafe { ffi::SSL_SESSION_free(s) }); - if ffi::SSL_set_session( - boringssl::SSL::opaque_ref(ssl_ptr), - ffi::SSL_SESSION::opaque_ref(session), - ) != 1 - { - return Err(global.throw_value(get_ssl_exception(global, b"SSL_set_session error"))); - } + ffi::SSL_set_session(ssl, ffi::SSL_SESSION::opaque_ref(session.0)); Ok(JSValue::UNDEFINED) } else { Err(global.throw(format_args!( @@ -1194,6 +1197,16 @@ pub(super) fn set_session( } } +/// The +1 reference `d2i_SSL_SESSION` returns (or null). +struct OwnedSession(*mut ffi::SSL_SESSION); + +impl Drop for OwnedSession { + fn drop(&mut self) { + // SAFETY: we own this reference; SSL_SESSION_free accepts null. + unsafe { ffi::SSL_SESSION_free(self.0) }; + } +} + pub(super) fn get_tls_ticket( this: &This, global: &JSGlobalObject, diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index f913f5ac935a..129ef96d0c8d 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -5432,3 +5432,32 @@ it("concurrent end() on two allowHalfOpen TLS peers closes both sockets", async await Promise.all([serverClosed.promise, clientClosed.promise]); }); + +// In a process of its own: BoringSSL's SSL_set_session abort()s once the handshake has started. +// `finished` separates a finished handshake from one in flight; `reused` is true only if the offer reached the wire. +it("setSession() after the handshake started is ignored on every Bun socket door", async () => { + const expected = { + "bun-connect-handshake": { threw: null, finished: true, reused: false }, + "bun-connect-open-late": { threw: null, finished: true, reused: false }, + "bun-listen-handshake": { threw: null, finished: true }, + "bun-upgrade-tls-half": { threw: null, finished: true }, + "bun-upgrade-raw-half": { threw: null, finished: true }, + "bun-connect-failed-handshake": { threw: null, finished: false, success: false }, + "bun-connect-open-after-write": { threw: null, finished: false }, + "bun-connect-open-legal": { threw: null, finished: false, reused: true }, + }; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + join(import.meta.dirname, "../../node/tls/node-tls-set-session-after-start.fixture.ts"), + ...Object.keys(expected), + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index d9eacd96fcd9..574c94c47fce 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -597,6 +597,37 @@ for (const { name, connect } of tests) { }); } +// In a process of its own: BoringSSL's SSL_set_session abort()s once the handshake has started. +it("setSession() after the handshake started is ignored on every node:tls door", async () => { + const client = { threw: null, echo: "ping", reused: false }; + const server = { threw: null, side: "server" }; + const expected = { + "node-client": client, + "node-duplex": client, + "node-wrap": client, + "node-session-event": client, + "node-session-event-duplex": client, + "node-check-server-identity": client, + // Node v26.3.0 returns undefined from these too, but OpenSSL then fails the connection. + "node-duplex-in-flight": client, + "node-keylog": client, + "node-keylog-duplex": client, + "node-client-tls13": client, + "node-server": server, + "node-server-wrap": server, + }; + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dirname, "node-tls-set-session-after-start.fixture.ts"), ...Object.keys(expected)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); +}); + it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", async () => { // d2i_SSL_SESSION returns an owned SSL_SESSION; SSL_set_session takes its own // reference ("the caller retains ownership"), so the caller's reference must @@ -4055,7 +4086,6 @@ describe("new tls.TLSSocket(socket) on the client side", () => { }); }); - // Not covered here: setSession() after the handshake started on a tls.connect() socket aborts the process (#41671). it.skipIf(skip)("a session that is set before the handshake starts is resumed", async () => { expect(await session()).toEqual({ "tls.connect({ port, session })": true, diff --git a/test/js/node/tls/node-tls-set-session-after-start.fixture.ts b/test/js/node/tls/node-tls-set-session-after-start.fixture.ts new file mode 100644 index 000000000000..b5f157a4567e --- /dev/null +++ b/test/js/node/tls/node-tls-set-session-after-start.fixture.ts @@ -0,0 +1,346 @@ +// Fixture for the "setSession() after the handshake started" tests in node-tls-connect.test.ts and +// test/js/bun/net/socket.test.ts. BoringSSL's SSL_set_session abort()s once the handshake has started. +// Each door named on the command line reaches setSession() through a different entry point; the +// fixture prints one JSON object, door -> result. +import { once } from "node:events"; +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { Duplex } from "node:stream"; +import tls from "node:tls"; + +type Result = Record; + +const keys = path.join(import.meta.dirname, "fixtures"); +const key = fs.readFileSync(path.join(keys, "agent1-key.pem")); +const cert = fs.readFileSync(path.join(keys, "agent1-cert.pem")); + +// TLS 1.2: a TLS 1.3 getSession() taken at secureConnect has no ticket, so it is never offered. +const serverOptions = { key, cert, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" } as const; +const server = tls.createServer(serverOptions, socket => { + socket.on("error", () => {}); + socket.on("data", chunk => socket.write(chunk)); +}); +await once(server.listen(0, "127.0.0.1"), "listening"); +const port = (server.address() as net.AddressInfo).port; +const clientOptions = { host: "127.0.0.1", port, rejectUnauthorized: false } as const; + +const first = tls.connect(clientOptions); +await once(first, "secureConnect"); +const session = first.getSession()!; +first.destroy(); +await once(first, "close"); + +function attempt(call: () => void): { threw: string | null } { + try { + call(); + return { threw: null }; + } catch (e) { + return { threw: (e as Error).message }; + } +} + +/** A Bun socket's setServername() throws once the handshake has finished: separates "finished" from "in flight". */ +function handshakeFinished(socket: { setServername(name: string): void }): boolean { + try { + socket.setServername("probe.test"); + return false; + } catch { + return true; + } +} + +/** The connection must still carry data after the refused offer. */ +async function lateOnClient( + client: tls.TLSSocket, + result = attempt(() => client.setSession(session)), +): Promise { + const echoed = new Promise(resolve => client.once("data", d => resolve(String(d)))); + client.write("ping"); + const echo = await Promise.race([echoed, once(client, "close").then(() => "")]); + const offered = client.isSessionReused(); + client.destroy(); + return { ...result, echo, reused: offered }; +} + +function poke(serverPort: number) { + const client = tls.connect({ ...clientOptions, port: serverPort }); + client.on("error", () => {}); +} + +const bunTls = { rejectUnauthorized: false } as const; + +const doors: Record Promise> = { + // node:tls client, in its own secureConnect handler. + async "node-client"() { + const client = tls.connect(clientOptions); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client); + }, + + // TLS over a user Duplex: a separate SSL owner (the Rust SSLWrapper). + async "node-duplex"() { + const client = tls.connect({ ...clientOptions, socket: await duplexTo(port) }); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client); + }, + + // The wrapper hands over its ClientHello: the handshake is in flight. + async "node-duplex-in-flight"() { + let result: { threw: string | null } | undefined; + let client: tls.TLSSocket | undefined; + const socket = await duplexTo(port, () => client && (result ??= attempt(() => client!.setSession(session)))); + client = tls.connect({ ...clientOptions, socket }); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client, result); + }, + + // From callbacks that run while BoringSSL is still inside the handshake or a read. + "node-keylog": () => fromEvent("keylog", {}), + "node-keylog-duplex": async () => fromEvent("keylog", { socket: await duplexTo(port) }), + "node-session-event": () => fromEvent("session", {}), + "node-session-event-duplex": async () => fromEvent("session", { socket: await duplexTo(port) }), + async "node-check-server-identity"() { + let result: { threw: string | null } | undefined; + const client: tls.TLSSocket = tls.connect({ + ...clientOptions, + ca: fs.readFileSync(path.join(keys, "ca1-cert.pem")), + rejectUnauthorized: true, + checkServerIdentity() { + result = attempt(() => client.setSession(session)); + return undefined; + }, + }); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client, result); + }, + + async "node-client-tls13"() { + const own = tls.createServer({ key, cert, minVersion: "TLSv1.3" }, socket => { + socket.on("error", () => {}); + socket.on("data", chunk => socket.write(chunk)); + }); + await once(own.listen(0, "127.0.0.1"), "listening"); + const client = tls.connect({ ...clientOptions, port: (own.address() as net.AddressInfo).port }); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client); + }, + + // tls.connect({ socket }) over a connected net.Socket: the adopt-TLS path. + async "node-wrap"() { + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const client = tls.connect({ ...clientOptions, socket: raw }); + client.on("error", () => {}); + await once(client, "secureConnect"); + return lateOnClient(client); + }, + + // node:tls server, in the connection handler. + async "node-server"() { + const { promise, resolve } = Promise.withResolvers(); + const own = tls.createServer(serverOptions, socket => { + socket.on("error", () => {}); + resolve({ ...attempt(() => socket.setSession(session)), side: "server" }); + }); + await once(own.listen(0, "127.0.0.1"), "listening"); + poke((own.address() as net.AddressInfo).port); + return promise; + }, + + // new tls.TLSSocket(socket, { isServer: true }): the adopt-TLS path as a server. + async "node-server-wrap"() { + const { promise, resolve } = Promise.withResolvers(); + const plain = net.createServer(raw => { + const secure = new tls.TLSSocket(raw, { + isServer: true, + secureContext: tls.createSecureContext(serverOptions), + }); + secure.on("error", () => {}); + secure.on("secure", () => resolve({ ...attempt(() => secure.setSession(session)), side: "server" })); + }); + await once(plain.listen(0, "127.0.0.1"), "listening"); + poke((plain.address() as net.AddressInfo).port); + return promise; + }, + + // Bun.connect, from the handshake handler. + "bun-connect-handshake"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + handshake(socket) { + const finished = handshakeFinished(socket); + resolve({ ...attempt(() => socket.setSession(session)), finished, reused: socket.isSessionReused() }); + }, + data() {}, + error() {}, + }, + }); + return promise; + }, + + // Bun.connect with no handshake handler: open() fires after the handshake. + "bun-connect-open-late"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + open(socket) { + const finished = handshakeFinished(socket); + resolve({ ...attempt(() => socket.setSession(session)), finished, reused: socket.isSessionReused() }); + }, + data() {}, + error() {}, + }, + }); + return promise; + }, + + // Bun.listen, from the server's handshake handler. + "bun-listen-handshake"() { + const { promise, resolve } = Promise.withResolvers(); + const listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: key.toString(), cert: cert.toString() }, + socket: { + handshake(socket) { + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }); + }, + data() {}, + error() {}, + }, + }); + poke(listener.port); + return promise; + }, + + // The peer's chain is refused: the handshake started but SSL_is_init_finished() stays 0. + "bun-connect-failed-handshake"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + // No CA for the self-signed chain, and rejectUnauthorized stays on. + tls: true, + socket: { + handshake(socket, success) { + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket), success }); + }, + data() {}, + error() {}, + close() {}, + }, + }).catch(() => {}); + return promise; + }, + + // A write in the otherwise legal window starts the handshake from inside SSL_write. + "bun-connect-open-after-write"() { + const { promise, resolve } = Promise.withResolvers(); + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + open(socket) { + socket.write("x"); + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }); + }, + handshake() {}, + data() {}, + error() {}, + }, + }); + return promise; + }, + + // socket.upgradeTLS() returns [raw, tls]. Both halves reach the one SSL. + "bun-upgrade-tls-half": () => upgraded(1), + "bun-upgrade-raw-half": () => upgraded(0), + + // The legal window: with both open and handshake handlers, open() runs before the ClientHello. + "bun-connect-open-legal"() { + const { promise, resolve } = Promise.withResolvers(); + let offer: Result = {}; + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: bunTls, + socket: { + open(socket) { + offer = { ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }; + }, + handshake(socket) { + resolve({ ...offer, reused: socket.isSessionReused() }); + socket.end(); + }, + data() {}, + error() {}, + }, + }); + return promise; + }, +}; + +async function duplexTo(port: number, onWrite?: () => void): Promise { + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const proxy = new Duplex({ + read() {}, + write(chunk, _enc, cb) { + onWrite?.(); + raw.write(chunk, cb); + }, + }); + raw.on("data", chunk => proxy.push(chunk)); + raw.on("end", () => proxy.push(null)); + return proxy; +} + +async function fromEvent(event: "keylog" | "session", options: tls.ConnectionOptions): Promise { + const client = tls.connect({ ...clientOptions, ...options }); + client.on("error", () => {}); + const { promise, resolve } = Promise.withResolvers<{ threw: string | null }>(); + client.once(event, () => resolve(attempt(() => client.setSession(session)))); + const [result] = await Promise.all([promise, once(client, "secureConnect")]); + return lateOnClient(client, result); +} + +async function upgraded(half: 0 | 1): Promise { + const { promise, resolve } = Promise.withResolvers(); + const plain = await Bun.connect({ + hostname: "127.0.0.1", + port, + socket: { open() {}, data() {}, error() {} }, + }); + const halves = plain.upgradeTLS({ + tls: bunTls, + socket: { + handshake() { + const socket = halves[half]; + resolve({ ...attempt(() => socket.setSession(session)), finished: handshakeFinished(socket) }); + }, + data() {}, + error() {}, + }, + }); + return promise; +} + +const names = process.argv.slice(2); +for (const name of names) if (!doors[name]) throw new Error(`unknown door ${name}`); +const results = await Promise.all(names.map(async name => [name, await doors[name]()] as const)); +console.log(JSON.stringify(Object.fromEntries(results))); +process.exit(0); From 9e6115170bdb0a1b1a06fd9322009e6eb60e8138 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:08:35 +0000 Subject: [PATCH 003/174] tls: apply sessionTimeout to TLS 1.3 sessions too (#38145) BoringSSL's SSL_CTX_set_timeout only covers TLS <= 1.2; TLS 1.3 reads session_psk_dhe_timeout, so a server with sessionTimeout: 7 issued tickets valid for two days. Set both. --- packages/bun-usockets/src/crypto/openssl.c | 1 + test/js/node/tls/node-tls-context.test.ts | 99 ++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 22d8caa9ca23..ed021db5e0f5 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1404,6 +1404,7 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, if (options.session_timeout > 0) { SSL_CTX_set_timeout(ssl_context, options.session_timeout); + SSL_CTX_set_session_psk_dhe_timeout(ssl_context, options.session_timeout); } if (options.allow_partial_trust_chain) { diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 3c865a222081..7d2c8f4f9f89 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; import { X509Certificate } from "node:crypto"; +import { once } from "node:events"; import { readFileSync } from "node:fs"; import net, { AddressInfo } from "node:net"; import { join } from "node:path"; @@ -1055,3 +1056,101 @@ it("validates sigalgs on every secure context like Node's configSecureContext", expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), ); }); + +describe("sessionTimeout", () => { + // SSLSession ::= SEQUENCE { ..., timeout [2] INTEGER, ..., ticketLifetimeHint [9] INTEGER OPTIONAL, ... } + // BoringSSL folds a TLS 1.3 server's ticket lifetime into `timeout`; OpenSSL keeps it in the hint. + function sessionLifetime(der: Uint8Array) { + let pos = 0; + function readHeader() { + const tag = der[pos++]; + let length = der[pos++]; + if (length & 0x80) { + const lengthBytes = length & 0x7f; + length = 0; + for (let i = 0; i < lengthBytes; i++) length = length * 256 + der[pos++]; + } + return { tag, end: pos + length }; + } + function readInteger(end: number) { + const integer = readHeader(); + expect(integer.tag).toBe(0x02); + expect(integer.end).toBe(end); + let value = 0; + for (; pos < integer.end; pos++) value = value * 256 + der[pos]; + return value; + } + const sequence = readHeader(); + expect(sequence.tag).toBe(0x30); + let timeout: number | undefined; + let ticketLifetimeHint: number | undefined; + while (pos < sequence.end) { + const element = readHeader(); + if (element.tag === 0xa2) timeout = readInteger(element.end); + else if (element.tag === 0xa9) ticketLifetimeHint = readInteger(element.end); + pos = element.end; + } + return Math.min(timeout!, ticketLifetimeHint ?? Infinity); + } + + // BoringSSL holds TLS 1.3 tickets back until the server's first write; `request` provokes it. + async function firstSessionFrom(port: number, clientOptions: tls.ConnectionOptions = {}, request?: string) { + const startedAt = Date.now(); + const socket = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ...clientOptions }); + try { + socket.resume(); + if (request !== undefined) { + await once(socket, "secureConnect"); + socket.write(request); + } + const session = await new Promise((resolve, reject) => { + socket.once("session", resolve); + socket.once("error", reject); + socket.once("close", () => reject(new Error("connection closed before the server issued a session"))); + }); + // A lifetime counts from ticket issuance: each second boundary crossed since then takes one off. + const skew = Math.ceil((Date.now() - startedAt) / 1000) + 1; + return { protocol: socket.getProtocol(), lifetime: sessionLifetime(session), skew }; + } finally { + socket.destroy(); + } + } + + async function withTlsServer(options: tls.TlsOptions, clientOptions?: tls.ConnectionOptions) { + const server = tls.createServer({ key: agent1Key, cert: agent1Cert, ...options }, socket => socket.end("x")); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + return await firstSessionFrom((server.address() as AddressInfo).port, clientOptions); + } finally { + server.close(); + } + } + + it("tls.createServer({ sessionTimeout }) is the lifetime of the tickets it issues", async () => { + for (const maxVersion of ["TLSv1.3", "TLSv1.2"] as const) { + const { protocol, lifetime, skew } = await withTlsServer({ sessionTimeout: 7, maxVersion }); + expect(protocol).toBe(maxVersion); + expect(lifetime).toBeWithin(7 - skew, 8); + } + }); + + it("tls.connect({ sessionTimeout }) caps the lifetime of the TLS 1.3 sessions the client keeps", async () => { + const { protocol, lifetime, skew } = await withTlsServer({}, { sessionTimeout: 9 } as tls.ConnectionOptions); + expect(protocol).toBe("TLSv1.3"); + expect(lifetime).toBeWithin(9 - skew, 10); + }); + + it("Bun.serve({ tls: { sessionTimeout } }) is the lifetime of the TLS 1.3 tickets it issues", async () => { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: { key: agent1Key, cert: agent1Cert, sessionTimeout: 7 } as Bun.TLSOptions, + fetch: () => new Response("ok"), + }); + const request = "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + const { protocol, lifetime, skew } = await firstSessionFrom(server.port!, {}, request); + expect(protocol).toBe("TLSv1.3"); + expect(lifetime).toBeWithin(7 - skew, 8); + }); +}); From 19c771d03deff3559fa8307842913c59ea2e701a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:13:26 +0000 Subject: [PATCH 004/174] tls: report the real getCipher().version and getEphemeralKeyInfo() (#32824) getCipher().version was BoringSSL's constant "TLSv1/SSLv3". It is now the cipher's minimum protocol version, with OpenSSL's strings. getEphemeralKeyInfo() read SSL_get_privatekey, the client's own long-term key: {} without a client certificate, the certificate's key with one, and an EC_KEY reference leaked per call. It now reports the negotiated group, on TLS 1.3 as well (Node does, for the groups it can name), and nothing for a resumed TLS <= 1.2 session, which has no key exchange. --- src/runtime/socket/tls_socket_functions.rs | 126 ++++++------------ test/js/node/test/common/boringssl.js | 16 +-- .../test-tls-client-getephemeralkeyinfo.js | 2 +- .../node/test/parallel/test-tls-getcipher.js | 8 +- test/js/node/tls/node-tls-connect.test.ts | 90 ++++++++++++- 5 files changed, 136 insertions(+), 106 deletions(-) diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index f5e4d033ceec..77e19e8f0263 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -29,22 +29,18 @@ pub(super) mod ffi { bun_opaque::opaque_ffi! { pub(crate) struct SSL_SESSION; pub(crate) struct SSL_CIPHER; - pub(crate) struct EVP_PKEY; - pub(crate) struct EC_KEY; - pub(crate) struct EC_GROUP; } // ssl.h pub(crate) const TLSEXT_NAMETYPE_host_name: c_int = 0; + pub(crate) const TLS1_2_VERSION: u16 = 0x0303; + pub(crate) use super::boringssl::TLS1_3_VERSION; // evp.h key types (NID values) pub(crate) const EVP_PKEY_RSA: c_int = 6; pub(crate) const EVP_PKEY_RSA_PSS: c_int = 912; pub(crate) const EVP_PKEY_DSA: c_int = 116; pub(crate) const EVP_PKEY_EC: c_int = 408; - pub(crate) const EVP_PKEY_DH: c_int = 28; - pub(crate) const EVP_PKEY_X25519: c_int = 948; - pub(crate) const EVP_PKEY_X448: c_int = 961; // obj_mac.h pub(crate) const NID_ED25519: c_int = 949; @@ -52,9 +48,14 @@ pub(super) mod ffi { pub(crate) const NID_id_GostR3410_2001: c_int = 811; pub(crate) const NID_id_GostR3410_2012_256: c_int = 979; pub(crate) const NID_id_GostR3410_2012_512: c_int = 980; + pub(crate) const NID_X9_62_prime256v1: c_int = 415; + pub(crate) const NID_secp384r1: c_int = 715; + pub(crate) const NID_secp521r1: c_int = 716; + pub(crate) const NID_X25519: c_int = 948; + pub(crate) const NID_kx_ecdhe: c_int = 952; // ffi-safe-fn: every handle type below (`SSL`, `X509`, `SSL_CIPHER`, - // `EVP_PKEY`, `EC_KEY`, `EC_GROUP`) is an `opaque_ffi!` ZST — `&T` + // `SSL_SESSION`) is an `opaque_ffi!` ZST — `&T` // dereferences zero bytes, carries no `dereferenceable`/`noalias` // obligation, and the `UnsafeCell` body lets BoringSSL mutate through a // shared ref. Functions whose *only* pointer arguments are such handles @@ -66,6 +67,8 @@ pub(super) mod ffi { unsafe extern "C" { // ── SSL session/handshake info ─────────────────────────────────── pub(crate) safe fn SSL_get_version(ssl: &SSL) -> *const c_char; + pub(crate) safe fn SSL_version(ssl: &SSL) -> c_int; + pub(crate) safe fn SSL_get_negotiated_group(ssl: &SSL) -> c_int; pub(crate) safe fn SSL_get_peer_certificate(ssl: &SSL) -> *mut X509; pub(crate) safe fn SSL_get_certificate(ssl: &SSL) -> *mut X509; pub(crate) safe fn SSL_set_max_send_fragment(ssl: &SSL, max_send_fragment: usize) -> c_int; @@ -107,7 +110,6 @@ pub(super) mod ffi { out: &mut [u8; 32], max_out: usize, ) -> usize; - pub(crate) safe fn SSL_get_privatekey(ssl: &SSL) -> *mut EVP_PKEY; // ── SSL_SESSION ─────────────────────────────────────────────────── pub(crate) safe fn SSL_get_session(ssl: &SSL) -> *mut SSL_SESSION; @@ -137,7 +139,8 @@ pub(super) mod ffi { pub(crate) safe fn SSL_get_current_cipher(ssl: &SSL) -> *const SSL_CIPHER; pub(crate) safe fn SSL_CIPHER_get_name(cipher: &SSL_CIPHER) -> *const c_char; pub(crate) safe fn SSL_CIPHER_standard_name(cipher: &SSL_CIPHER) -> *const c_char; - pub(crate) safe fn SSL_CIPHER_get_version(cipher: &SSL_CIPHER) -> *const c_char; + pub(crate) safe fn SSL_CIPHER_get_min_version(cipher: &SSL_CIPHER) -> u16; + pub(crate) safe fn SSL_CIPHER_get_kx_nid(cipher: &SSL_CIPHER) -> c_int; // ── X509 ───────────────────────────────────────────────────────── pub(crate) safe fn X509_up_ref(x: &X509) -> c_int; @@ -153,17 +156,6 @@ pub(super) mod ffi { #[link_name = "sk_value"] pub(crate) safe fn sk_X509_value(sk: &struct_stack_st_X509, i: usize) -> *mut X509; - // ── EVP / EC ────────────────────────────────────────────────────── - pub(crate) safe fn EVP_PKEY_id(pkey: &EVP_PKEY) -> c_int; - pub(crate) safe fn EVP_PKEY_bits(pkey: &EVP_PKEY) -> c_int; - // Returns a +1 `EC_KEY*` (caller owns; the sole call site - // intentionally leaks it). The only pointer arg is an - // opaque-ZST `&EVP_PKEY`, so the call itself has no precondition. - pub(crate) safe fn EVP_PKEY_get1_EC_KEY(pkey: &EVP_PKEY) -> *mut EC_KEY; - // Result is borrowed from `key`; opaque-ZST ref ⇒ no caller precondition. - pub(crate) safe fn EC_KEY_get0_group(key: &EC_KEY) -> *const EC_GROUP; - pub(crate) safe fn EC_GROUP_get_curve_name(group: &EC_GROUP) -> c_int; - // ── OBJ ────────────────────────────────────────────────────────── // Pure NID→short-name lookup; takes a by-value int and returns a // pointer into BoringSSL's static OID table (or null). No pointer @@ -823,18 +815,18 @@ pub(super) fn get_cipher( ); } - let version = ffi::SSL_CIPHER_get_version(cipher); - if version.is_null() { - result.put(global, b"version", JSValue::NULL); - } else { - // SAFETY: SSL_CIPHER_get_version returns a static NUL-terminated C string. - let s = unsafe { bun_core::ffi::cstr(version) }.to_bytes(); - result.put( - global, - b"version", - bun_string_jsc::create_utf8_for_js(global, s)?, - ); - } + // BoringSSL's SSL_CIPHER_get_version is a constant; these are OpenSSL's per-cipher minimum versions. + let version = match ffi::SSL_CIPHER_get_min_version(cipher) { + ffi::TLS1_3_VERSION => "TLSv1.3", + ffi::TLS1_2_VERSION => "TLSv1.2", + _ if ffi::SSL_CIPHER_get_kx_nid(cipher) == ffi::NID_kx_ecdhe => "TLSv1.0", + _ => "SSLv3", + }; + result.put( + global, + b"version", + BunString::static_(version).to_js(global)?, + ); Ok(result) } @@ -1013,67 +1005,27 @@ pub(super) fn get_ephemeral_key_info( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::NULL); }; - if ffi::SSL_is_server(boringssl::SSL::opaque_ref(ssl_ptr)) != 0 { + let ssl = boringssl::SSL::opaque_ref(ssl_ptr); + if ffi::SSL_is_server(ssl) != 0 { return Ok(JSValue::NULL); } let result = JSValue::create_empty_object(global, 0); - // TODO: investigate better option or compatible way to get the key - // this implementation follows nodejs but for BoringSSL SSL_get_server_tmp_key will always return 0 - // wich will result in a empty object - // let mut raw_key: *mut boringssl::EVP_PKEY = core::ptr::null_mut(); - // if unsafe { boringssl::SSL_get_server_tmp_key(ssl_ptr, &mut raw_key) } == 0 { - // return Ok(result); - // } - let raw_key: *mut ffi::EVP_PKEY = ffi::SSL_get_privatekey(boringssl::SSL::opaque_ref(ssl_ptr)); - if raw_key.is_null() { + // A resumed TLS <= 1.2 handshake has no key exchange, but its session still names the original group. + if ffi::SSL_session_reused(ssl) != 0 && ffi::SSL_version(ssl) < c_int::from(ffi::TLS1_3_VERSION) + { return Ok(result); } - let pkey = ffi::EVP_PKEY::opaque_ref(raw_key); - - let kid = ffi::EVP_PKEY_id(pkey); - let bits = ffi::EVP_PKEY_bits(pkey); - - match kid { - ffi::EVP_PKEY_DH => { - result.put(global, b"type", BunString::static_("DH").to_js(global)?); - result.put(global, b"size", JSValue::js_number(f64::from(bits))); - } - ffi::EVP_PKEY_EC | ffi::EVP_PKEY_X25519 | ffi::EVP_PKEY_X448 => { - let curve_name: &[u8]; - if kid == ffi::EVP_PKEY_EC { - // `pkey` is non-null (guarded above) and `kid == EVP_PKEY_EC`, so - // BoringSSL guarantees a non-null EC_KEY with a group set; the - // `opaque_ref` chain panics (not UB) if that invariant ever broke. - let ec = ffi::EVP_PKEY_get1_EC_KEY(pkey); - let group = ffi::EC_KEY_get0_group(ffi::EC_KEY::opaque_ref(ec)); - let nid = ffi::EC_GROUP_get_curve_name(ffi::EC_GROUP::opaque_ref(group)); - let nid_str = ffi::OBJ_nid2sn(nid); - if !nid_str.is_null() { - // SAFETY: OBJ_nid2sn returns a static NUL-terminated C string. - curve_name = unsafe { bun_core::ffi::cstr(nid_str) }.to_bytes(); - } else { - curve_name = b""; - } - } else { - let kid_str = ffi::OBJ_nid2sn(kid); - if !kid_str.is_null() { - // SAFETY: OBJ_nid2sn returns a static NUL-terminated C string. - curve_name = unsafe { bun_core::ffi::cstr(kid_str) }.to_bytes(); - } else { - curve_name = b""; - } - } - result.put(global, b"type", BunString::static_("ECDH").to_js(global)?); - result.put( - global, - b"name", - bun_string_jsc::create_utf8_for_js(global, curve_name)?, - ); - result.put(global, b"size", JSValue::js_number(f64::from(bits))); - } - _ => {} - } + let (name, size) = match ffi::SSL_get_negotiated_group(ssl) { + ffi::NID_X25519 => ("X25519", 253), + ffi::NID_X9_62_prime256v1 => ("prime256v1", 256), + ffi::NID_secp384r1 => ("secp384r1", 384), + ffi::NID_secp521r1 => ("secp521r1", 521), + _ => return Ok(result), + }; + result.put(global, b"type", BunString::static_("ECDH").to_js(global)?); + result.put(global, b"name", BunString::static_(name).to_js(global)?); + result.put(global, b"size", JSValue::js_number(f64::from(size))); Ok(result) } diff --git a/test/js/node/test/common/boringssl.js b/test/js/node/test/common/boringssl.js index 46e0738d596b..857365565163 100644 --- a/test/js/node/test/common/boringssl.js +++ b/test/js/node/test/common/boringssl.js @@ -137,12 +137,10 @@ function testRenegotiationUnsupported() { } /** - * OpenSSL exposes the negotiated ephemeral key type, name, and size for TLS - * clients. With BoringSSL the same ECDHE TLS 1.2 handshake succeeds, but - * getEphemeralKeyInfo() returns null on the server side and an object whose - * fields are undefined on the client side. + * BoringSSL has no DHE cipher suites and no X448, so of the original test only + * an ECDHE case can run. */ -function testEphemeralKeyInfoUnsupported() { +function testEphemeralKeyInfoEcdheOnly() { const server = tls.createServer({ key: fixtures.readKey('agent2-key.pem'), cert: fixtures.readKey('agent2-cert.pem'), @@ -161,9 +159,9 @@ function testEphemeralKeyInfoUnsupported() { maxVersion: 'TLSv1.2', }, common.mustCall(() => { assert.deepStrictEqual(client.getEphemeralKeyInfo(), { - type: undefined, - name: undefined, - size: undefined, + type: 'ECDH', + name: 'prime256v1', + size: 256, }); server.close(); })); @@ -337,7 +335,7 @@ module.exports = { assertMultiKeyUnsupported, assertNoCipherMatch, assertOpenSSLSecurityLevelsUnsupported, - testEphemeralKeyInfoUnsupported, + testEphemeralKeyInfoEcdheOnly, testLegacyProtocolUnsupported, testMultiPfxSelectionDifference, testPskTls13Unsupported, diff --git a/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js index 2107d024012c..d1c01842aef7 100644 --- a/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js +++ b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js @@ -4,7 +4,7 @@ if (!common.hasCrypto) common.skip('missing crypto'); if (process.features.openssl_is_boringssl) { - require('../common/boringssl').testEphemeralKeyInfoUnsupported(); + require('../common/boringssl').testEphemeralKeyInfoEcdheOnly(); return; } diff --git a/test/js/node/test/parallel/test-tls-getcipher.js b/test/js/node/test/parallel/test-tls-getcipher.js index 2d4de5639afb..3eb7b4590b31 100644 --- a/test/js/node/test/parallel/test-tls-getcipher.js +++ b/test/js/node/test/parallel/test-tls-getcipher.js @@ -85,9 +85,7 @@ server.listen(0, '127.0.0.1', common.mustCall(function() { assert.strictEqual(cipher.name, 'ECDHE-RSA-AES256-GCM-SHA384'); assert.strictEqual(cipher.standardName, 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384'); - assert.strictEqual(cipher.version, isBoringSSL ? - 'TLSv1/SSLv3' : - 'TLSv1.2'); + assert.strictEqual(cipher.version, 'TLSv1.2'); this.end(); })); })); @@ -112,9 +110,7 @@ tls.createServer({ 'TLS_AES_256_GCM_SHA384'; assert.strictEqual(cipher.name, expectedCipher); assert.strictEqual(cipher.standardName, cipher.name); - assert.strictEqual(cipher.version, isBoringSSL ? - 'TLSv1/SSLv3' : - 'TLSv1.3'); + assert.strictEqual(cipher.version, 'TLSv1.3'); client.end(); })); })); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 574c94c47fce..97c2388fa668 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -397,17 +397,17 @@ for (const { name, connect } of tests) { { name: "TLS_AES_128_GCM_SHA256", standardName: "TLS_AES_128_GCM_SHA256", - version: "TLSv1/SSLv3", + version: "TLSv1.3", }, { name: "TLS_AES_256_GCM_SHA384", standardName: "TLS_AES_256_GCM_SHA384", - version: "TLSv1/SSLv3", + version: "TLSv1.3", }, { name: "TLS_CHACHA20_POLY1305_SHA256", standardName: "TLS_CHACHA20_POLY1305_SHA256", - version: "TLSv1/SSLv3", + version: "TLSv1.3", }, ]; const socket = (await new Promise((resolve, reject) => { @@ -4096,3 +4096,87 @@ describe("new tls.TLSSocket(socket) on the client side", () => { }); }); }); + +describe("negotiated parameters", () => { + const agent1 = { + key: readFileSync(join(import.meta.dirname, "fixtures", "agent1-key.pem")), + cert: readFileSync(join(import.meta.dirname, "fixtures", "agent1-cert.pem")), + }; + const ecClientCert = { + key: readFileSync(join(import.meta.dirname, "fixtures", "ec10-key.pem")), + cert: readFileSync(join(import.meta.dirname, "fixtures", "ec10-cert.pem")), + }; + + async function listen(options: tls.TlsOptions) { + const accepted: TLSSocket[] = []; + const server = tls.createServer({ ...agent1, ...options }, socket => { + accepted.push(socket); + socket.on("error", () => {}); + socket.write("x"); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { accepted, port: (server.address() as AddressInfo).port, [Symbol.dispose]: () => void server.close() }; + } + + /** Resolves once the handshake has finished and, unless one was offered, a session has arrived. */ + async function connectTo(port: number, engine: "fd" | "Duplex", options: tls.ConnectionOptions) { + let transport: tls.ConnectionOptions = { port, host: "127.0.0.1" }; + if (engine === "Duplex") { + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + transport = { socket: new SocketProxy(raw) }; + } + const client = tlsConnect({ ...transport, rejectUnauthorized: false, ...options }); + const events = [once(client, "secureConnect")]; + if (!options.session) events.push(once(client, "session")); + const [, session] = await Promise.all(events); + return { client, session: session?.[0] as Buffer, [Symbol.dispose]: () => void client.destroy() }; + } + + it.each([ + ["TLSv1.3", "TLSv1.3", undefined], + ["TLSv1.2", "TLSv1.2", "ECDHE-RSA-AES128-GCM-SHA256"], + ["TLSv1.0", "TLSv1.2", "ECDHE-RSA-AES128-SHA"], + ["SSLv3", "TLSv1.2", "AES128-SHA"], + ] as const)("getCipher().version is %s", async (version, maxVersion, ciphers) => { + using server = await listen({ maxVersion, ciphers }); + for (const engine of ["fd", "Duplex"] as const) { + using connection = await connectTo(server.port, engine, { ciphers }); + expect(connection.client.getProtocol()).toBe(maxVersion); + if (ciphers) expect(connection.client.getCipher().name).toBe(ciphers); + expect(connection.client.getCipher().version).toBe(version); + expect(server.accepted.at(-1)!.getCipher()).toEqual(connection.client.getCipher()); + } + }); + + const none = { type: undefined, name: undefined, size: undefined }; + const x25519 = { type: "ECDH", name: "X25519", size: 253 }; + const p256 = { type: "ECDH", name: "prime256v1", size: 256 }; + const p384 = { type: "ECDH", name: "secp384r1", size: 384 }; + + // The client holds an EC certificate: its long-term key is not the ephemeral key. + describe.each(["fd", "Duplex"] as const)("getEphemeralKeyInfo() over a %s", engine => { + it.each([ + ["TLSv1.2", { ecdhCurve: "X25519" }, x25519, none], + ["TLSv1.2", { ecdhCurve: "prime256v1" }, p256, none], + ["TLSv1.2", { ecdhCurve: "secp384r1" }, p384, none], + ["TLSv1.2", { ciphers: "AES128-SHA" }, none, none], + ["TLSv1.3", { ecdhCurve: "X25519" }, x25519, x25519], + ["TLSv1.3", { ecdhCurve: "secp384r1" }, p384, p384], + // X25519MLKEM768: not a key type Node maps. + ["TLSv1.3", {}, none, none], + ] as const)("%s %j", async (maxVersion, options, full, resumed) => { + using server = await listen({ maxVersion, ...options }); + const clientOptions = { ...ecClientCert, ciphers: (options as tls.TlsOptions).ciphers }; + using first = await connectTo(server.port, engine, clientOptions); + expect(first.client.getProtocol()).toBe(maxVersion); + expect(first.client.isSessionReused()).toBe(false); + expect(first.client.getEphemeralKeyInfo()).toStrictEqual(full); + expect(server.accepted[0].getEphemeralKeyInfo()).toBeNull(); + + using second = await connectTo(server.port, engine, { ...clientOptions, session: first.session }); + expect(second.client.isSessionReused()).toBe(true); + expect(second.client.getEphemeralKeyInfo()).toStrictEqual(resumed); + }); + }); +}); From 7f8d82cf1309a57a497a253e8faeb49f8b8a1848 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:17:38 +0000 Subject: [PATCH 005/174] node: Node's text for four ERR_* codes, throw ERR_TLS_PROTOCOL_VERSION_CONFLICT (#43594) secureProtocol together with minVersion/maxVersion was accepted and secureProtocol silently won; Node throws ERR_TLS_PROTOCOL_VERSION_CONFLICT from createSecureContext, tls.Server, https.Server and tls.connect. tls.Server and https.Server treat a falsy value as absent, as Node does. ERR_TLS_INVALID_PROTOCOL_VERSION formats the value with %j, ERR_HTTP_SOCKET_ASSIGNED and ERR_IPC_CHANNEL_CLOSED use Node's messages, and ChildProcess#send() on a closed channel reports ERR_IPC_CHANNEL_CLOSED. --- src/js/builtins.d.ts | 2 + src/js/internal/tls.ts | 9 +- src/js/node/_http_server.ts | 17 ++-- src/js/node/child_process.ts | 10 +++ src/js/node/tls.ts | 20 ++++- src/jsc/bindings/ErrorCode.cpp | 4 +- src/runtime/ipc_host.rs | 2 +- .../node/errors/error-code-messages.test.ts | 84 ++++++++++++++++++- test/js/node/http/node-http.test.ts | 2 +- .../test-child-process-send-after-close.js | 2 +- 10 files changed, 133 insertions(+), 19 deletions(-) diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index 0bbd12346182..bb108a82c74c 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -451,6 +451,7 @@ declare function $ERR_MISSING_ARGS(...args: [string, ...string[]]): TypeError; declare function $ERR_MISSING_ARGS(oneOf: string[]): TypeError; declare function $ERR_INVALID_RETURN_VALUE(expected_type: string, name: string, actual_value: any): TypeError; declare function $ERR_TLS_INVALID_PROTOCOL_VERSION(a: string, b: string): TypeError; +declare function $ERR_TLS_PROTOCOL_VERSION_CONFLICT(version: string, secureProtocol: string): TypeError; declare function $ERR_INVALID_IP_ADDRESS(ip: any): TypeError; declare function $ERR_INVALID_ADDRESS_FAMILY(addressType, host, port): RangeError; declare function $ERR_OUT_OF_RANGE(name: string, reason: string, value): RangeError; @@ -490,6 +491,7 @@ declare function $ERR_AMBIGUOUS_ARGUMENT(arg, message): TypeError; declare function $ERR_INVALID_FD_TYPE(type): TypeError; declare function $ERR_IP_BLOCKED(ip): Error; +declare function $ERR_IPC_CHANNEL_CLOSED(): Error; declare function $ERR_IPC_DISCONNECTED(): Error; declare function $ERR_INSPECTOR_ALREADY_ACTIVATED(message?: string): Error; declare function $ERR_INSPECTOR_NOT_ACTIVE(): Error; diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index 7da572c1cae2..d87fb5f7657e 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -78,8 +78,15 @@ function tlsStringToProtocolVersion(v) { // unknown method (THROW_ERR_TLS_INVALID_PROTOCOL_METHOD in // src/crypto/crypto_context.cc SecureContext::Init). let _SECURE_PROTOCOL_METHODS: Set | undefined; -function validateSecureProtocol(secureProtocol) { +function validateSecureProtocol(secureProtocol, minVersion, maxVersion) { if (secureProtocol === undefined || secureProtocol === null) return; + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/common.js#L78 + if (secureProtocol) { + if (minVersion != null) + throw $ERR_TLS_PROTOCOL_VERSION_CONFLICT(JSON.stringify(minVersion), JSON.stringify(secureProtocol)); + if (maxVersion != null) + throw $ERR_TLS_PROTOCOL_VERSION_CONFLICT(JSON.stringify(maxVersion), JSON.stringify(secureProtocol)); + } if (typeof secureProtocol !== "string") { throw $ERR_INVALID_ARG_TYPE("options.secureProtocol", "string", secureProtocol); } diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index fae709e723b6..3523d8c90fff 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -383,18 +383,21 @@ function Server(options, callback): void { if (this[isTlsSymbol]) { const { validateSecureProtocol, secureProtocolToVersionRange, tlsStringToProtocolVersion } = tlsHelpers; - // Translate minVersion/maxVersion/secureProtocol into the integer - // protocol range the native layer applies (secureProtocol wins, like - // Node's SecureContext::Init); 0 keeps the native defaults. - validateSecureProtocol(options.secureProtocol); + // Translate secureProtocol, or else minVersion/maxVersion, into the + // integer protocol range the native layer applies; 0 keeps the native + // defaults. A falsy value counts as absent, like in tls.Server. + const secureProtocol = options.secureProtocol || undefined; + const minVersionOption = options.minVersion || undefined; + const maxVersionOption = options.maxVersion || undefined; + validateSecureProtocol(secureProtocol, minVersionOption, maxVersionOption); let minVersion, maxVersion; - const range = secureProtocolToVersionRange(options.secureProtocol); + const range = secureProtocolToVersionRange(secureProtocol); if (range) { minVersion = range[0]; maxVersion = range[1]; } else { - minVersion = tlsStringToProtocolVersion(options.minVersion); - maxVersion = tlsStringToProtocolVersion(options.maxVersion); + minVersion = tlsStringToProtocolVersion(minVersionOption); + maxVersion = tlsStringToProtocolVersion(maxVersionOption); } this[tlsSymbol] = normalizeServerTls({ serverName, diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 6395dda0e408..feb593027e59 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -1590,6 +1590,16 @@ class ChildProcess extends EventEmitter { return false; } + if (!this.connected) { + const ex = $ERR_IPC_CHANNEL_CLOSED(); + if (callback) { + process.nextTick(callback, ex); + } else { + process.nextTick(() => this.emit("error", ex)); + } + return false; + } + // We still need this send function because return this.#handle.send(message, handle, options, err => { // node does process.nextTick() to emit or call the callback diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 34dd02fc3c79..fdc2d1dc76b0 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -226,7 +226,7 @@ function validateSecureContextOptions(options) { dhparam, secureProtocol, } = options; - validateSecureProtocol(secureProtocol); + validateSecureProtocol(secureProtocol, minVersion, maxVersion); if (ciphers !== undefined && ciphers !== null) validateString(ciphers, "options.ciphers"); if (passphrase !== undefined && passphrase !== null) validateString(passphrase, "options.passphrase"); if (sigalgs !== undefined && sigalgs !== null) { @@ -264,9 +264,9 @@ function validateSecureContextOptions(options) { throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); } if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(minVersion), "minimum"); + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(minVersion), "minimum"); if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(maxVersion), "maximum"); + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(maxVersion), "maximum"); if (ticketKeys !== undefined && ticketKeys !== null) { validateBuffer(ticketKeys, "options.ticketKeys"); const ticketKeysByteLength = ticketKeys.byteLength; @@ -1289,6 +1289,20 @@ function Server(options, secureConnectionListener): void { options = options.context; } if (options) { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1456: falsy counts as absent + const { minVersion, maxVersion, secureProtocol } = options; + if ( + (minVersion != null && !minVersion) || + (maxVersion != null && !maxVersion) || + (secureProtocol != null && !secureProtocol) + ) { + options = { + ...options, + minVersion: minVersion || undefined, + maxVersion: maxVersion || undefined, + secureProtocol: secureProtocol || undefined, + }; + } validateSecureContextOptions(options); options = processPfxOptions(options); diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index d3afabf7d224..fe4b6c4d73c7 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2304,7 +2304,7 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Bun::jsFunctionMakeErrorWithCode, __att case ErrorCode::ERR_SERVER_NOT_RUNNING: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_SERVER_NOT_RUNNING, "Server is not running."_s)); case ErrorCode::ERR_IPC_CHANNEL_CLOSED: - return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_IPC_CHANNEL_CLOSED, "Channel closed."_s)); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_IPC_CHANNEL_CLOSED, "Channel closed"_s)); case ErrorCode::ERR_SOCKET_BAD_TYPE: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_SOCKET_BAD_TYPE, "Bad socket type specified. Valid types are: udp4, udp6"_s)); case ErrorCode::ERR_ZLIB_INITIALIZATION_FAILED: @@ -2433,7 +2433,7 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Bun::jsFunctionMakeErrorWithCode, __att case ErrorCode::ERR_HTTP_TRAILER_INVALID: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_TRAILER_INVALID, "Trailers are invalid with this transfer encoding"_s)); case ErrorCode::ERR_HTTP_SOCKET_ASSIGNED: - return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_SOCKET_ASSIGNED, "Socket already assigned"_s)); + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_HTTP_SOCKET_ASSIGNED, "ServerResponse has an already assigned socket"_s)); case ErrorCode::ERR_STREAM_RELEASE_LOCK: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_STREAM_RELEASE_LOCK, "Stream reader cancelled via releaseLock()"_s)); case ErrorCode::ERR_SOCKET_CONNECTION_TIMEOUT: diff --git a/src/runtime/ipc_host.rs b/src/runtime/ipc_host.rs index 60482323e23f..ca0646ae5805 100644 --- a/src/runtime/ipc_host.rs +++ b/src/runtime/ipc_host.rs @@ -116,7 +116,7 @@ pub(crate) fn do_send( let connected = ipc.as_ref().is_some_and(|i| i.is_connected()); if !connected { let msg = match from { - FromEnum::Process => "process.send() can only be used if the IPC channel is open.", + FromEnum::Process => "Channel closed", FromEnum::Subprocess => "Subprocess.send() can only be used if an IPC channel is open.", FromEnum::SubprocessExited => { "Subprocess.send() cannot be used after the process has exited." diff --git a/test/js/node/errors/error-code-messages.test.ts b/test/js/node/errors/error-code-messages.test.ts index 5a76491fba4c..ca69ceacd6bf 100644 --- a/test/js/node/errors/error-code-messages.test.ts +++ b/test/js/node/errors/error-code-messages.test.ts @@ -1,9 +1,10 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, tempDir, tls as tlsCert } from "harness"; import child_process from "node:child_process"; -import { once } from "node:events"; +import { EventEmitter, once } from "node:events"; import fs from "node:fs"; import http from "node:http"; +import https from "node:https"; import net, { type AddressInfo } from "node:net"; import path from "node:path"; import { Readable } from "node:stream"; @@ -36,7 +37,14 @@ test("table-driven ERR_* codes keep their exact messages", () => { 'ERR_INVALID_HTTP_TOKEN | TypeError | Header name must be a valid HTTP token ["bad header"]', ); expect(capture(() => tls.createSecureContext({ minVersion: "TLSv9" as any }))).toBe( - "ERR_TLS_INVALID_PROTOCOL_VERSION | TypeError | TLSv9 is not a valid minimum TLS protocol version", + 'ERR_TLS_INVALID_PROTOCOL_VERSION | TypeError | "TLSv9" is not a valid minimum TLS protocol version', + ); + // Node formats the value with %j: strings are quoted and escaped, numbers are bare. + expect(capture(() => tls.createSecureContext({ minVersion: 'a"b' as any }))).toBe( + 'ERR_TLS_INVALID_PROTOCOL_VERSION | TypeError | "a\\"b" is not a valid minimum TLS protocol version', + ); + expect(capture(() => tls.createSecureContext({ minVersion: 0 as any }))).toBe( + "ERR_TLS_INVALID_PROTOCOL_VERSION | TypeError | 0 is not a valid minimum TLS protocol version", ); expect(capture(() => child_process.fork("x", { stdio: ["pipe", "pipe", "pipe"] }))).toBe( "ERR_CHILD_PROCESS_IPC_REQUIRED | Error | Forked processes must have an IPC channel, missing value 'ipc' in options.stdio", @@ -231,3 +239,73 @@ test( }, replTimeout, ); + +test("secureProtocol conflicts with minVersion and maxVersion", () => { + expect(capture(() => tls.createSecureContext({ minVersion: "TLSv1.2", secureProtocol: "TLSv1_2_method" }))).toBe( + 'ERR_TLS_PROTOCOL_VERSION_CONFLICT | TypeError | TLS protocol version "TLSv1.2" conflicts with secureProtocol "TLSv1_2_method"', + ); + expect(capture(() => tls.createSecureContext({ maxVersion: "TLSv1.3", secureProtocol: "TLS_method" }))).toBe( + 'ERR_TLS_PROTOCOL_VERSION_CONFLICT | TypeError | TLS protocol version "TLSv1.3" conflicts with secureProtocol "TLS_method"', + ); + expect(capture(() => new tls.Server({ minVersion: "TLSv1.2", secureProtocol: "TLS_method" }))).toBe( + 'ERR_TLS_PROTOCOL_VERSION_CONFLICT | TypeError | TLS protocol version "TLSv1.2" conflicts with secureProtocol "TLS_method"', + ); + expect(capture(() => https.createServer({ ...tlsCert, maxVersion: "TLSv1.2", secureProtocol: "TLS_method" }))).toBe( + 'ERR_TLS_PROTOCOL_VERSION_CONFLICT | TypeError | TLS protocol version "TLSv1.2" conflicts with secureProtocol "TLS_method"', + ); + expect(capture(() => tls.connect({ port: 1, minVersion: "TLSv1.2", secureProtocol: "TLS_method" }))).toBe( + 'ERR_TLS_PROTOCOL_VERSION_CONFLICT | TypeError | TLS protocol version "TLSv1.2" conflicts with secureProtocol "TLS_method"', + ); + expect(capture(() => tls.createSecureContext({ minVersion: 5 as any, secureProtocol: "TLS_method" }))).toBe( + 'ERR_TLS_PROTOCOL_VERSION_CONFLICT | TypeError | TLS protocol version 5 conflicts with secureProtocol "TLS_method"', + ); + expect(capture(() => tls.createSecureContext({ minVersion: null as any, secureProtocol: "TLS_method" }))).toBe( + "no throw", + ); + // tls.Server drops a falsy minVersion/maxVersion before it builds the context. + expect(capture(() => new tls.Server({ minVersion: "" as any, secureProtocol: "TLS_method" }))).toBe("no throw"); + expect(capture(() => new tls.Server({ maxVersion: "" as any, secureProtocol: "TLS_method" }))).toBe("no throw"); + expect(capture(() => https.createServer({ ...tlsCert, minVersion: "" as any, secureProtocol: "TLS_method" }))).toBe( + "no throw", + ); +}); + +test("ERR_HTTP_SOCKET_ASSIGNED message", () => { + const socket = new EventEmitter(); + new http.ServerResponse({} as any).assignSocket(socket as any); + expect(capture(() => new http.ServerResponse({} as any).assignSocket(socket as any))).toBe( + "ERR_HTTP_SOCKET_ASSIGNED | Error | ServerResponse has an already assigned socket", + ); +}); + +test("ERR_IPC_CHANNEL_CLOSED message", async () => { + using dir = tempDir("ipc-channel-closed", { "child.js": "// exits at once\n" }); + const child = child_process.fork(`${dir}/child.js`); + const exited = new Promise(resolve => child.once("exit", resolve)); + child.disconnect(); + const { promise, resolve } = Promise.withResolvers<(Error & { code?: string }) | null>(); + expect(child.send("x", resolve)).toBe(false); + const err = await promise; + expect(`${err?.code} | ${err?.name} | ${err?.message}`).toBe("ERR_IPC_CHANNEL_CLOSED | Error | Channel closed"); + await exited; +}); + +test("ERR_IPC_CHANNEL_CLOSED message from process.send() in the child", async () => { + using dir = tempDir("ipc-channel-closed-child", { + "child.js": ` + process.disconnect(); + process.send("x", e => console.log(e.code + " | " + e.name + " | " + e.message)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "child.js"], + env: bunEnv, + cwd: String(dir), + stdio: ["ignore", "pipe", "pipe", "ipc"], + ipc() {}, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("ERR_IPC_CHANNEL_CLOSED | Error | Channel closed\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 6cf22fc9f2d4..8cfef21339eb 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1667,7 +1667,7 @@ describe("server.address should be valid IP", () => { expect(res.socket).toBe(socket); expect(socket._httpMessage).toBe(res); - expect(() => res.assignSocket(socket)).toThrow("Socket already assigned"); + expect(() => res.assignSocket(socket)).toThrow("ServerResponse has an already assigned socket"); socket.emit("close"); doneSocket(); } catch (err: any) { diff --git a/test/js/node/test/parallel/test-child-process-send-after-close.js b/test/js/node/test/parallel/test-child-process-send-after-close.js index 01e4d34d1f7b..62128b7b1b98 100644 --- a/test/js/node/test/parallel/test-child-process-send-after-close.js +++ b/test/js/node/test/parallel/test-child-process-send-after-close.js @@ -13,7 +13,7 @@ child.on('close', common.mustCall((code, signal) => { const testError = common.expectsError({ name: 'Error', - // message: 'Channel closed', + message: 'Channel closed', code: 'ERR_IPC_CHANNEL_CLOSED' }, 2); From 004fd6c08d66306b6b7b4400a9b7a591c9cc98b6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:17:40 +0000 Subject: [PATCH 006/174] tls: refuse privateKeyEngine/privateKeyIdentifier instead of ignoring them (#39997) createSecureContext({ privateKeyEngine, privateKeyIdentifier }) returned a context with no key. BoringSSL has no ENGINE support, so throw ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED as clientCertEngine already does, with Node's checks in Node's order. The checks move into validateSecureContextOptions so tls.Server runs them at construction. --- src/js/node/tls.ts | 37 ++++++++++++------- ...ode-tls-create-secure-context-args.test.ts | 25 +++++++++++++ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index fdc2d1dc76b0..30d13d2cddc8 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -215,10 +215,13 @@ const SUPPORTED_ECDH_GROUPS = new Set([ function validateSecureContextOptions(options) { const { ciphers, + key, passphrase, ecdhCurve, minVersion, maxVersion, + privateKeyIdentifier, + privateKeyEngine, sessionTimeout, sigalgs, ticketKeys, @@ -233,6 +236,26 @@ function validateSecureContextOptions(options) { validateString(sigalgs, "options.sigalgs"); if (sigalgs === "") throw $ERR_INVALID_ARG_VALUE("options.sigalgs", sigalgs); } + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/secure-context.js#L222 (BoringSSL has no ENGINE support) + if (privateKeyIdentifier !== undefined && privateKeyIdentifier !== null) { + if (privateKeyEngine === undefined || privateKeyEngine === null) { + throw $ERR_INVALID_ARG_VALUE("options.privateKeyEngine", privateKeyEngine); + } + if (key) { + throw $ERR_INVALID_ARG_VALUE("options.privateKeyIdentifier", privateKeyIdentifier); + } + if (typeof privateKeyIdentifier !== "string") { + throw $ERR_INVALID_ARG_TYPE( + "options.privateKeyIdentifier", + ["string", "null", "undefined"], + privateKeyIdentifier, + ); + } + if (typeof privateKeyEngine !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.privateKeyEngine", ["string", "null", "undefined"], privateKeyEngine); + } + throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); + } if (ecdhCurve !== undefined) { validateString(ecdhCurve, "options.ecdhCurve"); if (ecdhCurve !== "auto") { @@ -676,20 +699,6 @@ var InternalSecureContext = class SecureContext { throw new TypeError("servername argument must be an string"); if (options.secureOptions != null && typeof options.secureOptions !== "number") throw new TypeError("secureOptions argument must be an number"); - const privateKeyIdentifier = options.privateKeyIdentifier; - if (!$isUndefinedOrNull(privateKeyIdentifier)) { - const privateKeyEngine = options.privateKeyEngine; - if ($isUndefinedOrNull(privateKeyEngine)) - throw $ERR_INVALID_ARG_VALUE("options.privateKeyEngine", privateKeyEngine); - if (typeof privateKeyEngine !== "string") - throw $ERR_INVALID_ARG_TYPE("options.privateKeyEngine", ["string", "null", "undefined"], privateKeyEngine); - if (typeof privateKeyIdentifier !== "string") - throw $ERR_INVALID_ARG_TYPE( - "options.privateKeyIdentifier", - ["string", "null", "undefined"], - privateKeyIdentifier, - ); - } } const requestedCiphers = options?.ciphers; if (requestedCiphers && StringPrototypeIncludes.$call(requestedCiphers, "TLS_")) { diff --git a/test/js/node/tls/node-tls-create-secure-context-args.test.ts b/test/js/node/tls/node-tls-create-secure-context-args.test.ts index e7d49bf753a4..d94a737488fe 100644 --- a/test/js/node/tls/node-tls-create-secure-context-args.test.ts +++ b/test/js/node/tls/node-tls-create-secure-context-args.test.ts @@ -70,6 +70,31 @@ describe("tls.createSecureContext extra arguments test", () => { "The property 'options.privateKeyEngine' is invalid. Received undefined", ); }); + + it.each([ + ["tls.createSecureContext", tls.createSecureContext], + ["tls.createServer", tls.createServer], + ] as const)("%s refuses an engine key instead of building a context with no key", (_, create) => { + const engineKey = { privateKeyIdentifier: "key", privateKeyEngine: "engine" }; + // Node v26.3.0 has ENGINE support and fails to load "engine" instead. + expect(() => create(engineKey)).toThrow( + process.versions.bun ? expect.objectContaining({ code: "ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED" }) : undefined, + ); + const key = readFileSync(join(import.meta.dirname, "fixtures", "agent1-key.pem")); + expect(() => create({ ...engineKey, key })).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_VALUE", + message: "The property 'options.privateKeyIdentifier' is invalid. Received 'key'", + }), + ); + // @ts-expect-error + expect(() => create({ privateKeyIdentifier: 0, privateKeyEngine: 0 })).toThrow( + expect.objectContaining({ + code: "ERR_INVALID_ARG_TYPE", + message: expect.stringContaining('"options.privateKeyIdentifier"'), + }), + ); + }); }); describe("tls.createSecureContext pfx argument", () => { From 849ccf2b2845f49bd4b98a1b6f5d9bb709bfe29b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:18:28 +0000 Subject: [PATCH 007/174] tls: list supported ciphers in getCiphers(), make tls.SecureContext the class (#41696) getCiphers() returned the DEFAULT_CIPHERS policy string split on ":", upper-case and with "!aNULL"-style tokens. It now returns the names a context accepts, lower-cased and sorted, as documented. tls.SecureContext had no prototype, so `x instanceof tls.SecureContext` threw. It now shares the prototype of the objects createSecureContext() returns. --- src/js/node/tls.ts | 8 +++++- test/js/node/tls/node-tls-context.test.ts | 30 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 30d13d2cddc8..8670fc09af46 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -331,6 +331,7 @@ const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; const StringPrototypeEndsWith = String.prototype.endsWith; const StringFromCharCode = String.fromCharCode; const StringPrototypeCharCodeAt = String.prototype.charCodeAt; +const StringPrototypeToLowerCase = String.prototype.toLowerCase; const ArrayPrototypeIncludes = Array.prototype.includes; const ArrayPrototypeJoin = Array.prototype.join; @@ -340,6 +341,7 @@ const ArrayPrototypeSome = Array.prototype.some; const ArrayPrototypeReduce = Array.prototype.reduce; const ArrayPrototypeFilter = Array.prototype.filter; const ArrayPrototypeMap = Array.prototype.map; +const ArrayPrototypeSort = Array.prototype.sort; const ObjectFreeze = Object.freeze; @@ -717,6 +719,8 @@ var InternalSecureContext = class SecureContext { function SecureContext(options): void { return createSecureContext(options) as never; } +SecureContext.prototype = InternalSecureContext.prototype; +InternalSecureContext.prototype.constructor = SecureContext; function createSecureContext(options) { if (options instanceof InternalSecureContext) return options; @@ -1703,7 +1707,9 @@ function onConnectStart() { } function getCiphers() { - return getDefaultCiphers().split(":"); + const ciphers = ["tls_aes_128_gcm_sha256", "tls_aes_256_gcm_sha384", "tls_chacha20_poly1305_sha256"]; + for (const name of getValidCiphersSet()) ArrayPrototypePush.$call(ciphers, StringPrototypeToLowerCase.$call(name)); + return ArrayPrototypeSort.$call(ciphers); } // Convert protocols array into valid OpenSSL protocols list diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 7d2c8f4f9f89..18bce0baa7fd 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -1154,3 +1154,33 @@ describe("sessionTimeout", () => { expect(lifetime).toBeWithin(7 - skew, 8); }); }); + +it("getCiphers() lists the supported cipher names, lower-cased and sorted", () => { + const ciphers = tls.getCiphers(); + expect(ciphers).toEqual([...new Set(ciphers)].sort()); + expect(ciphers).toEqual(ciphers.map(name => name.toLowerCase())); + expect(ciphers).toEqual( + expect.arrayContaining([ + "aes256-sha", + "ecdhe-rsa-aes128-gcm-sha256", + "tls_aes_128_gcm_sha256", + "tls_aes_256_gcm_sha384", + "tls_chacha20_poly1305_sha256", + ]), + ); + for (const name of ciphers) expect(() => tls.createSecureContext({ ciphers: name.toUpperCase() })).not.toThrow(); + + tls.getCiphers().length = 0; + expect(tls.getCiphers()).toEqual(ciphers); +}); + +it("tls.SecureContext is the class of what createSecureContext() returns", () => { + const context = tls.createSecureContext({}); + expect(context).toBeInstanceOf(tls.SecureContext); + expect(context.constructor).toBe(tls.SecureContext); + expect(Object.keys(tls.SecureContext.prototype)).toEqual([]); + expect({}).not.toBeInstanceOf(tls.SecureContext); + expect(new tls.SecureContext()).toBeInstanceOf(tls.SecureContext); + // @ts-expect-error the types only admit `new` + expect(tls.SecureContext()).toBeInstanceOf(tls.SecureContext); +}); From 2edcd639edb7febeca98a13767873cf65391d75b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:21:49 +0000 Subject: [PATCH 008/174] tls: make tls.rootCertificates non-configurable (#34748) Node defines the accessor with configurable: false; Bun's could be redefined or deleted. --- src/js/node/tls.ts | 7 ++-- ...ode-tls-rootcertificates-immutable.test.ts | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 8670fc09af46..4a85306cfc19 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1919,7 +1919,7 @@ function getDefaultCiphers() { return `TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256${ciphers ? ":" + ciphers : ""}`; } -export default { +const tlsExports = { CLIENT_RENEG_LIMIT, CLIENT_RENEG_WINDOW, connect, @@ -1969,7 +1969,10 @@ export default { return cacheBundledRootCertificates(); }, getCACertificates, -} as any as typeof import("node:tls") & { +}; +Object.defineProperty(tlsExports, "rootCertificates", { configurable: false }); + +export default tlsExports as any as typeof import("node:tls") & { SecureContext: typeof SecureContext; convertALPNProtocols: typeof convertALPNProtocols; }; diff --git a/test/js/node/tls/node-tls-rootcertificates-immutable.test.ts b/test/js/node/tls/node-tls-rootcertificates-immutable.test.ts index 3a4021c9caa3..174097d62069 100644 --- a/test/js/node/tls/node-tls-rootcertificates-immutable.test.ts +++ b/test/js/node/tls/node-tls-rootcertificates-immutable.test.ts @@ -64,4 +64,36 @@ describe("tls", () => { // Check if the array is actually frozen expect(Object.isFrozen(tls.rootCertificates)).toBe(true); }); + + test("rootCertificates property is non-configurable", () => { + const tls = require("tls"); + + const descriptor = Object.getOwnPropertyDescriptor(tls, "rootCertificates")!; + expect(descriptor.configurable).toBe(false); + expect(descriptor.enumerable).toBe(true); + expect(typeof descriptor.get).toBe("function"); + expect(descriptor.set).toBeUndefined(); + + const originalLength = tls.rootCertificates.length; + + expect(() => { + Object.defineProperty(tls, "rootCertificates", { + value: ["-----BEGIN CERTIFICATE-----\nFAKE\n-----END CERTIFICATE-----"], + }); + }).toThrow(TypeError); + + expect(() => { + Object.defineProperty(tls, "rootCertificates", { + configurable: true, + get() { + return ["injected"]; + }, + }); + }).toThrow(TypeError); + + expect(Reflect.deleteProperty(tls, "rootCertificates")).toBe(false); + + expect(tls.rootCertificates.length).toBe(originalLength); + expect(tls.rootCertificates[0]).toStartWith("-----BEGIN CERTIFICATE-----"); + }); }); From fc8806c8011628241905b8884e37b594b6848c4d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:21:50 +0000 Subject: [PATCH 009/174] tls: print one warning for a NODE_EXTRA_CA_CERTS file that cannot be loaded (#42991) node:tls predicted the failure with its own fs.accessSync probe and printed Node's line; the native loader then printed a second `warn:` line. The probe had per-VM state (each Worker warned again), read process.env while the loader reads the C environment, and said nothing for a file that exists but does not parse. fetch and Bun.connect only got the native wording, buffered until exit. The once-per-process native load is now the only emitter: it uses Node's wording and flushes, and node:tls triggers it at the first secure context. --- src/js/node/tls.ts | 28 +---- src/jsc/bindings/NodeTLS.cpp | 6 + src/jsc/bindings/NodeTLS.h | 1 + src/uws/lib.rs | 7 +- test/js/node/tls/node-tls-cert.test.ts | 2 +- .../node/tls/test-node-extra-ca-certs.test.ts | 106 ++++++++++++++++++ test/js/web/fetch/fetch.tls.test.ts | 2 +- 7 files changed, 122 insertions(+), 30 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 4a85306cfc19..d48ec6433776 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -36,6 +36,8 @@ const { const getBundledRootCertificates = $newCppFunction("NodeTLS.cpp", "getBundledRootCertificates", 1); const getExtraCACertificates = $newCppFunction("NodeTLS.cpp", "getExtraCACertificates", 1); +// Node warns at the first secure context; the once-per-process native load is what prints it. +const maybeWarnAboutExtraCACerts = $newCppFunction("NodeTLS.cpp", "loadExtraCACertificates", 0); const getSystemCACertificates = $newCppFunction("NodeTLS.cpp", "getSystemCACertificates", 1); const canonicalizeIP = $newCppFunction("NodeTLS.cpp", "Bun__canonicalizeIP", 1); const parseCACertificates = $newCppFunction("NodeTLS.cpp", "parseCACertificates", 1); @@ -1807,32 +1809,6 @@ function cacheExtraCACertificates(): string[] { return extraCACertificates; } -let warnedAboutExtraCACerts = false; -/** - * Match Node's crypto_context.cc: a NODE_EXTRA_CA_CERTS file that cannot be - * loaded is ignored with a one-time warning on stderr - emitted when the - * first secure context is created, not at startup - rather than failing the - * process. The reason text mirrors the strerror()-derived string Node prints. - */ -function maybeWarnAboutExtraCACerts() { - if (warnedAboutExtraCACerts) return; - warnedAboutExtraCACerts = true; - const extraPath = process.env.NODE_EXTRA_CA_CERTS; - if (!extraPath) return; - try { - require("node:fs").accessSync(extraPath); - } catch (err: any) { - // Node prints this with a raw fprintf(stderr, ...) from - // crypto_context.cc, not through process.emitWarning - no pid prefix and - // no colorization. - process.stderr.write( - `Warning: Ignoring extra certs from \`${extraPath}\`, load failed: ${ - err?.code === "ENOENT" ? "No such file or directory" : err?.message - }\n`, - ); - } -} - // Runtime override for the "default" CA certificate set, installed by // tls.setDefaultCACertificates(). undefined = no override (use the real // bundled/system default). Only affects type "default"/implicit — "bundled", diff --git a/src/jsc/bindings/NodeTLS.cpp b/src/jsc/bindings/NodeTLS.cpp index f1e2aca878b6..fe4503aa9b5c 100644 --- a/src/jsc/bindings/NodeTLS.cpp +++ b/src/jsc/bindings/NodeTLS.cpp @@ -65,6 +65,12 @@ JSC_DEFINE_HOST_FUNCTION(getBundledRootCertificates, (JSC::JSGlobalObject * glob RELEASE_AND_RETURN(scope, JSValue::encode(JSC::objectConstructorFreeze(globalObject, rootCertificates))); } +JSC_DEFINE_HOST_FUNCTION(loadExtraCACertificates, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + us_get_root_extra_cert_instances(); + return JSValue::encode(jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(getExtraCACertificates, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); diff --git a/src/jsc/bindings/NodeTLS.h b/src/jsc/bindings/NodeTLS.h index a5a829044477..8c35c58beb7b 100644 --- a/src/jsc/bindings/NodeTLS.h +++ b/src/jsc/bindings/NodeTLS.h @@ -5,6 +5,7 @@ namespace Bun { BUN_DECLARE_HOST_FUNCTION(Bun__canonicalizeIP); JSC_DECLARE_HOST_FUNCTION(getBundledRootCertificates); +JSC_DECLARE_HOST_FUNCTION(loadExtraCACertificates); JSC_DECLARE_HOST_FUNCTION(getExtraCACertificates); JSC_DECLARE_HOST_FUNCTION(getSystemCACertificates); JSC_DECLARE_HOST_FUNCTION(parseCACertificates); diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 31ee23f5b94f..00fa2745f1c1 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -116,11 +116,14 @@ unsafe extern "C" fn BUN__warn__extra_ca_load_failed( let filename = unsafe { bun_core::ffi::cstr(filename) }; // SAFETY: caller contract guarantees valid NUL-terminated strings. let error_msg = unsafe { bun_core::ffi::cstr(error_msg) }; - bun_core::warn!( - "ignoring extra certs from {}, load failed: {}", + // Node's wording: test-tls-env-bad-extra-ca.js matches on it. + bun_core::pretty_errorln!( + "Warning: Ignoring extra certs from `{}`, load failed: {}", bstr::BStr::new(filename.to_bytes()), bstr::BStr::new(error_msg.to_bytes()), ); + // stderr is buffered per thread: without this a Worker drops the line and the main thread holds it until exit. + bun_core::Output::flush(); } pub use bun_uws_sys::LIBUS_SOCKET_DESCRIPTOR; diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index 89076838e820..4baea2f83d76 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -684,7 +684,7 @@ it("tls.connect should ignore NODE_EXTRA_CA_CERTS if it contains invalid cert", ); for (const { stderr, exitCode } of results) { - expect(stderr).toContain("ignoring extra certs"); + expect(stderr).toContain("Warning: Ignoring extra certs from"); expect(exitCode).toBe(1); } }); diff --git a/test/js/node/tls/test-node-extra-ca-certs.test.ts b/test/js/node/tls/test-node-extra-ca-certs.test.ts index b703e7f9a089..1a77b5eb1060 100644 --- a/test/js/node/tls/test-node-extra-ca-certs.test.ts +++ b/test/js/node/tls/test-node-extra-ca-certs.test.ts @@ -91,6 +91,112 @@ aWRnaXRzIFB0eSBMdGQwHhcNMTgwNDEwMDgwNzQ4WhcNMjgwNDA3MDgwNzQ4WjBF expect(exitCode).toBe(0); expect(stdout.trim()).toBe("OK"); }); + + describe("a file that cannot be loaded warns once", () => { + const missingWarning = "Warning: Ignoring extra certs from `missing.pem`, load failed: No such file or directory\n"; + + // Accepts one connection, drops it and closes, so a TLS client fails its handshake without leaving the process. + const deadEnd = ` + function deadEnd(connect) { + const server = require("node:net").createServer(socket => { + socket.destroy(); + server.close(); + }); + server.listen(0, "127.0.0.1", () => connect(server.address().port)); + } + `; + const users = { + "tls.connect": `${deadEnd} + deadEnd(port => require("node:tls").connect(port, "127.0.0.1").on("error", () => {}));`, + "tls.createServer, then tls.createSecureContext": ` + require("node:tls").createServer({}); + require("node:tls").createSecureContext();`, + "fetch": `${deadEnd} + deadEnd(port => fetch("https://127.0.0.1:" + port).catch(() => {}));`, + "fetch, then node:tls": `${deadEnd} + deadEnd(async port => { + await fetch("https://127.0.0.1:" + port).catch(() => {}); + require("node:tls").createSecureContext(); + });`, + }; + + // NODE_EXTRA_CA_CERTS is relative to the child's cwd, so the warning names the same path on every platform. + async function run(extraCACerts: string, files: Record, code: string) { + using dir = tempDir("extra-ca-warning", files); + await using proc = spawn({ + cmd: [bunExe(), "-e", code], + env: { ...bunEnv, NODE_EXTRA_CA_CERTS: extraCACerts }, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + return { stderr, exitCode }; + } + + for (const [user, code] of Object.entries(users)) { + test.concurrent(user, async () => { + expect(await run("missing.pem", {}, code)).toEqual({ stderr: missingWarning, exitCode: 0 }); + }); + } + + // Not concurrent: two VMs that each load node:tls make this the slow case on a debug build. + test("node:tls in a Worker, then on the main thread", async () => { + const code = ` + new (require("node:worker_threads").Worker)('require("node:tls").createSecureContext()', { eval: true }) + .on("exit", () => require("node:tls").createSecureContext());`; + expect(await run("missing.pem", {}, code)).toEqual({ stderr: missingWarning, exitCode: 0 }); + }); + + // Node warns for this file too. The reason is the TLS library's error string, which BoringSSL words differently. + test.concurrent("a certificate that does not parse", async () => { + const files = { + "malformed.pem": "-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydGlmaWNhdGU=\n-----END CERTIFICATE-----\n", + }; + expect(await run("malformed.pem", files, `require("node:tls").createSecureContext();`)).toEqual({ + stderr: expect.stringMatching( + /^Warning: Ignoring extra certs from `malformed\.pem`, load failed: error:[^\n]+\n$/, + ), + exitCode: 0, + }); + }); + + test.concurrent("when the load fails, not when the process exits", async () => { + using dir = tempDir("extra-ca-warning", {}); + const stderrPath = join(String(dir), "stderr.txt"); + // Bun.connect reaches the loader without node:tls. The child reports the attempt, then lives until stdin closes. + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + `${deadEnd} + deadEnd(port => + Bun.connect({ hostname: "127.0.0.1", port, tls: true, socket: { data() {}, error() {}, connectError() {} } }) + .catch(() => {}) + .finally(() => console.log("attempted")), + ); + process.stdin.on("data", () => {});`, + ], + env: { ...bunEnv, NODE_EXTRA_CA_CERTS: "missing.pem" }, + cwd: String(dir), + stdin: "pipe", + stdout: "pipe", + stderr: Bun.file(stderrPath), + }); + const decoder = new TextDecoder(); + let stdout = ""; + for await (const chunk of proc.stdout) { + stdout += decoder.decode(chunk, { stream: true }); + if (stdout.includes("\n")) break; + } + + expect({ stdout, stderr: await Bun.file(stderrPath).text(), exitCode: proc.exitCode }).toEqual({ + stdout: "attempted\n", + stderr: missingWarning, + exitCode: null, + }); + }); + }); }); test("explicit ca option replaces the default trust store instead of appending to it", async () => { diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index f1d77f495747..7f396ef9ed0e 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1664,7 +1664,7 @@ describe.concurrent("fetch-tls", () => { expect(await proc.exited).toBe(1); const stderr = await proc.stderr.text(); expect(stderr).toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); - expect(stderr).toContain("ignoring extra certs"); + expect(stderr).toContain("Warning: Ignoring extra certs from"); } }); }); From 6b1699ceeb6e235f76c3be9acd2d8550a33b42c7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:23:59 +0000 Subject: [PATCH 010/174] tls: read a pipe or FIFO in SSL_CERT_FILE once for both CA stores (#42996) On Linux the default store and the system store each opened $SSL_CERT_FILE. A pipe yields its bytes once, so the second loader got nothing (its CAs were missing from one store) and a FIFO blocked forever in the second open(2): `SSL_CERT_FILE= bun --use-system-ca` hung where Node connects. One shared reader now serves both. It keeps the bytes only when the file is not regular, and holds its lock across open(2) so two threads cannot split one FIFO's contents. --- src/runtime/socket/cert_files.rs | 66 +++++++-- src/runtime/socket/system_certs.rs | 23 ++-- test/js/node/tls/test-use-system-ca.test.ts | 144 +++++++++++++++++++- 3 files changed, 211 insertions(+), 22 deletions(-) diff --git a/src/runtime/socket/cert_files.rs b/src/runtime/socket/cert_files.rs index 624891b5dd2b..adae5b481ab6 100644 --- a/src/runtime/socket/cert_files.rs +++ b/src/runtime/socket/cert_files.rs @@ -20,9 +20,13 @@ pub(crate) fn read_file_for(path: &ZStr, ctx: *mut c_void, on_file: OnFile) -> c if let Err(err) = file.read_to_end_into(&mut bytes) { return err.get_errno() as c_int; } + hand_over(&bytes, ctx, on_file); + 0 +} + +fn hand_over(bytes: &[u8], ctx: *mut c_void, on_file: OnFile) { // SAFETY: the C++ caller keeps `ctx` valid for the duration of this call. unsafe { on_file(ctx, bytes.as_ptr(), bytes.len()) }; - 0 } /// `NODE_EXTRA_CA_CERTS` and friends: one named file. @@ -39,8 +43,51 @@ unsafe extern "C" fn Bun__readCertificateFile( read_file_for(unsafe { ZStr::from_c_ptr(path) }, ctx, on_file) } -/// The file half of OpenSSL's `X509_STORE_set_default_paths`: `$SSL_CERT_FILE`, else `default_path` -/// (`X509_get_default_cert_file()`). A variable that is set but empty names no file. Failures are silent, as there. +/// A pipe or FIFO reads only once, so the first loader keeps its bytes for the other. Locked across `open(2)`. +static STREAMED_DEFAULT_CERT_FILE: bun_threading::Guarded> = + bun_threading::Guarded::new(None); + +/// Reads `$SSL_CERT_FILE`, else `default_path`, with no file-type check (as Node). Only a regular file has a `stat`. +pub(crate) fn read_openssl_default_cert_file( + default_path: &ZStr, + on_file: impl FnOnce(&[u8], Option<&bun_sys::Stat>), +) { + let from_env; + let path = match env_var::SSL_CERT_FILE::get() { + Some(b"") => return, + Some(path) => { + from_env = ZBox::from_bytes(path); + from_env.as_zstr() + } + None => default_path, + }; + + let streamed: &'static [u8] = { + let mut kept = STREAMED_DEFAULT_CERT_FILE.lock(); + if let Some(bytes) = *kept { + bytes + } else { + let Ok(file) = bun_sys::File::open(path, O::RDONLY | O::CLOEXEC, 0) else { + return; + }; + let stat = file.stat().ok(); + let mut bytes = Vec::new(); + if file.read_to_end_into(&mut bytes).is_err() { + return; + } + if let Some(stat) = + stat.filter(|stat| bun_sys::is_regular_file(stat.st_mode as bun_sys::Mode)) + { + drop(kept); + return on_file(&bytes, Some(&stat)); + } + *kept.insert(Box::leak(bytes.into_boxed_slice())) + } + }; + on_file(streamed, None); +} + +/// The file half of OpenSSL's `X509_STORE_set_default_paths`, for the default store. /// /// # Safety /// `default_path` must be a valid NUL-terminated C string; `ctx` whatever `on_file` expects. @@ -50,14 +97,7 @@ unsafe extern "C" fn Bun__readOpenSSLDefaultCertFile( ctx: *mut c_void, on_file: OnFile, ) { - match env_var::SSL_CERT_FILE::get() { - Some(b"") => {} - Some(path) => { - let _ = read_file_for(ZBox::from_bytes(path).as_zstr(), ctx, on_file); - } - None => { - // SAFETY: caller contract. - let _ = read_file_for(unsafe { ZStr::from_c_ptr(default_path) }, ctx, on_file); - } - } + // SAFETY: caller contract. + let default_path = unsafe { ZStr::from_c_ptr(default_path) }; + read_openssl_default_cert_file(default_path, |bytes, _| hand_over(bytes, ctx, on_file)); } diff --git a/src/runtime/socket/system_certs.rs b/src/runtime/socket/system_certs.rs index 325cb4b27ec0..a2c9c3c43f5c 100644 --- a/src/runtime/socket/system_certs.rs +++ b/src/runtime/socket/system_certs.rs @@ -16,6 +16,8 @@ use bun_boringssl_sys as boringssl; use bun_core::{ZBox, ZStr, env_var, strings}; use bun_sys::O; +use super::cert_files; + #[cfg(not(target_os = "android"))] const WELL_KNOWN_BUNDLES: &[&[u8]] = &[ b"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo/Arch/NixOS @@ -127,7 +129,6 @@ impl Loader { .is_none() } - /// No file-type check, as in Node, so `SSL_CERT_FILE` may name a pipe. fn load_file(&mut self, path: &ZStr) { let Ok(file) = bun_sys::File::open(path, O::RDONLY | O::CLOEXEC, 0) else { return; @@ -204,13 +205,19 @@ extern "C" fn us_load_system_certificates_posix() -> *mut boringssl::X509_LAZY_C buf: Vec::new(), }; - match env_var::SSL_CERT_FILE::get() { - Some(b"") => {} - Some(path) => loader.load_file(ZBox::from_bytes(path).as_zstr()), - None => { - // SAFETY: BoringSSL returns a static NUL-terminated string. - loader.load_file(unsafe { ZStr::from_c_ptr(boringssl::X509_get_default_cert_file()) }); - for path in WELL_KNOWN_BUNDLES { + // The default store reads this file too, so `cert_files` reads it for both. + // SAFETY: BoringSSL returns a static NUL-terminated string. + let default_file = unsafe { ZStr::from_c_ptr(boringssl::X509_get_default_cert_file()) }; + cert_files::read_openssl_default_cert_file(default_file, |pem, stat| { + if let Some(stat) = stat { + loader.first_visit(stat); + } + loader.load_pem(pem); + }); + if env_var::SSL_CERT_FILE::get().is_none() { + for path in WELL_KNOWN_BUNDLES { + // `default_file` is in this list, and a FIFO there cannot be opened a second time. + if *path != default_file.as_bytes() { loader.load_file(ZBox::from_bytes(path).as_zstr()); } } diff --git a/test/js/node/tls/test-use-system-ca.test.ts b/test/js/node/tls/test-use-system-ca.test.ts index 253c805f4cf6..c56acd91bb46 100644 --- a/test/js/node/tls/test-use-system-ca.test.ts +++ b/test/js/node/tls/test-use-system-ca.test.ts @@ -1,8 +1,9 @@ import { spawn } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isWindows, tempDir } from "harness"; +import { mkfifo } from "mkfifo"; import { X509Certificate } from "node:crypto"; -import { existsSync, readFileSync, symlinkSync } from "node:fs"; +import { closeSync, constants, existsSync, openSync, readFileSync, symlinkSync, writeSync } from "node:fs"; import { join } from "node:path"; import { rootCertificates } from "node:tls"; @@ -173,6 +174,147 @@ describe.skipIf(!isLinux)("tls.getCACertificates('system')", () => { }); }); +// On Linux the default store and the system store both read $SSL_CERT_FILE, in either order. +describe.concurrent.skipIf(!isLinux)("SSL_CERT_FILE that can be read only once", () => { + const keys = join(import.meta.dir, "../test/fixtures/keys"); + const ca1 = join(keys, "ca1-cert.pem"); + const expected = { connected: "authorized", listed: [new X509Certificate(readFileSync(ca1)).fingerprint256] }; + + // Connects with the default store to a server whose certificate ca1 signed, and lists the system store. + const script = (first: "connect" | "list") => + `const tls = require("tls"), fs = require("fs"), { X509Certificate } = require("crypto"); + const list = () => tls.getCACertificates("system").map(pem => new X509Certificate(pem).fingerprint256); + const connect = () => new Promise(resolve => { + const server = tls.createServer({ key: fs.readFileSync(${JSON.stringify(join(keys, "agent1-key.pem"))}), cert: fs.readFileSync(${JSON.stringify(join(keys, "agent1-cert.pem"))}) }, s => s.end()); + server.listen(0, () => { + const socket = tls.connect({ port: server.address().port, host: "127.0.0.1", checkServerIdentity: () => undefined }, () => { + resolve("authorized"); + socket.destroy(); + server.close(); + }); + socket.on("error", e => { resolve(e.code); server.close(); }); + }); + }); + let connected, listed; + if (${first === "list"}) { listed = list(); connected = await connect(); } + else { connected = await connect(); listed = list(); } + console.log(JSON.stringify({ connected, listed }));`; + + // The shell pipeline makes stdin a real pipe. A Blob stdin is a memfd, which every open reads from the start. + test.each(["connect", "list"] as const)("a pipe, %s first", async first => { + await using proc = spawn({ + cmd: ["sh", "-c", 'cat "$CA" | "$BUN" -e "$SCRIPT"'], + env: { + ...bunEnv, + SSL_CERT_FILE: "/dev/stdin", + SSL_CERT_DIR: "", + NODE_USE_SYSTEM_CA: undefined, + CA: ca1, + BUN: bunExe(), + SCRIPT: script(first), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + }); + + // --use-system-ca puts the system store into the default store, so the first connection builds both. + test("a FIFO that one writer serves, with --use-system-ca", async () => { + using dir = tempDir("ssl-cert-file-fifo", {}); + const fifo = join(String(dir), "ca.fifo"); + mkfifo(fifo); + await using writer = spawn({ + cmd: ["sh", "-c", 'cat "$CA" > "$FIFO"'], + env: { ...bunEnv, CA: ca1, FIFO: fifo }, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + await using proc = spawn({ + cmd: [bunExe(), "--use-system-ca", "-e", script("connect")], + env: { ...bunEnv, SSL_CERT_FILE: fifo, SSL_CERT_DIR: "" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual(expected); + expect(exitCode).toBe(0); + expect(await writer.exited).toBe(0); + }); + + // fetch builds the default store on the HTTP thread; the JS thread must wait for that read, not start a second one. + test("a FIFO that one writer serves, read from two threads at once", async () => { + using dir = tempDir("ssl-cert-file-fifo-threads", {}); + const fifo = join(String(dir), "ca.fifo"); + mkfifo(fifo); + // The server is in this process, because a TLS server with no `ca` builds the default store too. + await using server = Bun.serve({ + port: 0, + tls: { + key: readFileSync(join(keys, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(keys, "agent1-cert.pem"), "utf8"), + }, + fetch: () => new Response("ok"), + }); + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + `const tls = require("tls"), fs = require("fs"), { X509Certificate } = require("crypto"); + const fetched = fetch("https://127.0.0.1:${server.port}/", { tls: { checkServerIdentity: () => undefined } }).then(r => r.text(), e => e.code); + await new Promise(resolve => process.stdin.once("data", resolve)); + fs.writeSync(1, "listing\\n"); + const listed = tls.getCACertificates("system").map(pem => new X509Certificate(pem).fingerprint256); + console.log(JSON.stringify({ fetched: await fetched, listed }));`, + ], + env: { ...bunEnv, SSL_CERT_FILE: fifo, SSL_CERT_DIR: "", NODE_USE_SYSTEM_CA: undefined }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + // A non-blocking open for writing fails with ENXIO until a reader (here the HTTP thread) waits in open(2). + let fd: number; + while (true) { + try { + fd = openSync(fifo, constants.O_WRONLY | constants.O_NONBLOCK); + break; + } catch (e: any) { + if (e.code !== "ENXIO" || proc.exitCode !== null) throw e; + await Bun.sleep(1); + } + } + // The HTTP thread now waits in read(2). Let the JS thread ask, and write only after it says it is about to. + proc.stdin.write("go\n"); + await proc.stdin.end(); + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let stdout = ""; + while (!stdout.includes("listing\n")) { + const { value, done } = await reader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + writeSync(fd, readFileSync(ca1)); + closeSync(fd); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("listing\n" + JSON.stringify({ fetched: "ok", listed: expected.listed }) + "\n"); + expect(exitCode).toBe(0); + }); +}); + // The default store also trusts what X509_STORE_set_default_paths would: $SSL_CERT_FILE (else /etc/ssl/cert.pem) and // the $SSL_CERT_DIR (else /etc/ssl/certs) hash directory. The file is read lazily and deduplicated against the bundled // roots, so these pin that the same certificates are still trusted. From eb26fe7c48fcae38dbabc995889b9fbe659da1c0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:24:06 +0000 Subject: [PATCH 011/174] docs: document SSL_CERT_FILE and SSL_CERT_DIR (#42970) Both add trust anchors to every connection that uses the default CA store and neither was documented. --- docs/runtime/environment-variables.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index bff70123a229..1ef65508bcd3 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -208,6 +208,8 @@ Bun reads these environment variables to configure aspects of its behavior. | Name | Description | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NODE_TLS_REJECT_UNAUTHORIZED` | `NODE_TLS_REJECT_UNAUTHORIZED=0` disables SSL certificate validation. Useful for testing and debugging, but be very hesitant to use it in production. Node.js introduced this variable; Bun keeps the name for compatibility. | +| `SSL_CERT_FILE` | Path to a PEM file of CA certificates that Bun trusts in addition to its bundled root certificates. If unset, Bun reads `/etc/ssl/cert.pem`. If set to an empty string, Bun reads no file. | +| `SSL_CERT_DIR` | Directories of CA certificates that Bun trusts in addition to its bundled root certificates, each file named by its subject hash (`.0`, as `openssl rehash` creates). Separate directories with `:` (`;` on Windows). If unset, Bun reads `/etc/ssl/certs`. | | `BUN_CONFIG_VERBOSE_FETCH` | If `BUN_CONFIG_VERBOSE_FETCH=curl`, then fetch requests log the URL, method, request headers and response headers to the console. This also works with `node:http`. `BUN_CONFIG_VERBOSE_FETCH=1` is equivalent to `BUN_CONFIG_VERBOSE_FETCH=curl` except without the `curl` output. | | `BUN_RUNTIME_TRANSPILER_CACHE_PATH` | The runtime transpiler caches the transpiled output of source files larger than 4 KB, which makes CLIs using Bun load faster. If `BUN_RUNTIME_TRANSPILER_CACHE_PATH` is set, Bun writes the cache to that directory. If it is set to an empty string or the string `"0"`, caching is disabled. If it is unset, Bun writes the cache to the platform-specific cache directory. | | `TMPDIR` | Bun occasionally requires a directory to store intermediate assets during bundling or other operations. If unset, defaults to the platform-specific temporary directory: `/tmp` on Linux, `/private/tmp` on macOS. | From 7e67ad7e6f57f50ae9b44d2188dbd2b5f40e97d2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:24:30 +0000 Subject: [PATCH 012/174] test(tls): tie the bundled roots to certdata.txt root_certs.der is a binary blob, so a swapped root does not show in review and passed every test: they compare the export with the blob and the count with certdata.txt. Compare each bundled root's SHA-1 with the entries certdata.txt trusts for server auth. --- test/js/node/tls/node-tls-internals.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/js/node/tls/node-tls-internals.test.ts b/test/js/node/tls/node-tls-internals.test.ts index 7ec3a73eb061..9c0041919dab 100644 --- a/test/js/node/tls/node-tls-internals.test.ts +++ b/test/js/node/tls/node-tls-internals.test.ts @@ -1,6 +1,6 @@ import { canonicalizeIP } from "bun:internal-for-testing"; import { createTest } from "node-harness"; -import { X509Certificate } from "node:crypto"; +import { createHash, X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { getCACertificates, rootCertificates } from "tls"; @@ -82,4 +82,22 @@ describe("NodeTLS.cpp", () => { expect(cert.issuer).toBe(cert.subject); } }); + + // root_certs.der is binary, so a diff cannot be reviewed; certdata.txt (Mozilla's list) can. + test("every bundled root is a certificate certdata.txt trusts for server auth", () => { + const lines = readFileSync(join(import.meta.dir, "../../../../packages/bun-usockets/certdata.txt"), "latin1").split( + "\n", + ); + const trusted: string[] = []; + let sha1 = ""; + for (let i = 0; i < lines.length; i++) { + if (lines[i] === "CKA_CERT_SHA1_HASH MULTILINE_OCTAL") { + sha1 = ""; + while (lines[++i] !== "END") + for (const octal of lines[i].split("\\").slice(1)) sha1 += parseInt(octal, 8).toString(16).padStart(2, "0"); + } else if (lines[i] === "CKA_TRUST_SERVER_AUTH CK_TRUST CKT_NSS_TRUSTED_DELEGATOR") trusted.push(sha1); + } + const bundled = rootCertificates.map(pem => createHash("sha1").update(new X509Certificate(pem).raw).digest("hex")); + expect(bundled.toSorted()).toEqual(trusted.toSorted()); + }); }); From d48f967c20bf2b395d3270a7412f8f5248995ce0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:35:33 +0000 Subject: [PATCH 013/174] tls: serve every identity from the array form of key/cert (#33534) BoringSSL's legacy certificate slot holds one identity, so a context built from key/cert (or pfx) arrays only kept the last pair: with [rsa, ec] an RSA-only client failed its handshake, and arrays in different orders or of different lengths threw KEY_TYPE_MISMATCH where Node accepts them. Each identity is still loaded into the legacy slot with the two existing loaders, then copied into BoringSSL's credential list: one identity per key type, the last certificate of a type wins, a key or certificate without a partner is ignored, and ECDSA/EdDSA are preferred to RSA. A single key and certificate take the old path. The JS pre-check that predicted KEY_TYPE_MISMATCH is gone. us_ssl_ctx_build_raw also builds the contexts of Bun.serve, Bun.listen and Bun.connect, so two pairs of one key type keep serving the last pair, as before, where Node throws KEY_VALUES_MISMATCH: a key of the right type that does not fit is skipped. If no key fits the certificate and at least one of its type did not, the context still fails with KEY_VALUES_MISMATCH, and a key that does not parse or decrypt stays fatal. Known cost: `cert: [c1], key: [k1, foreignKeyOfTheSameType]` is now accepted, where Node and earlier versions of Bun refuse it. test-tls-multi-pfx.js is upstream's again, and test-tls-multi-key.js skips only its two PKCS#12-chain cases. --- packages/bun-usockets/src/crypto/openssl.c | 82 +++++-- src/js/node/tls.ts | 34 --- test/js/bun/http/bun-serve-ssl.test.ts | 60 +++++ test/js/node/test/common/boringssl.js | 71 ------ .../node/test/parallel/test-tls-multi-key.js | 14 +- .../node/test/parallel/test-tls-multi-pfx.js | 5 - test/js/node/tls/node-tls-server.test.ts | 211 ++++++++++++++++++ 7 files changed, 342 insertions(+), 135 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index ed021db5e0f5..368d2c1c32f7 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1070,6 +1070,65 @@ static int us_ssl_ctx_use_certificate_chain(SSL_CTX *ctx, const char *content) { return ret; } +static SSL_CREDENTIAL *us_ssl_ctx_copy_legacy_identity(SSL_CTX *ctx) { + const STACK_OF(CRYPTO_BUFFER) *chain = SSL_CTX_get0_chain(ctx); + size_t count = sk_CRYPTO_BUFFER_num(chain); + CRYPTO_BUFFER **certs = us_calloc(count, sizeof(*certs)); + if (certs == NULL) return NULL; + for (size_t i = 0; i < count; i++) certs[i] = sk_CRYPTO_BUFFER_value(chain, i); + SSL_CREDENTIAL *identity = SSL_CREDENTIAL_new_x509(); + if (identity != NULL && (!SSL_CREDENTIAL_set1_cert_chain(identity, certs, count) || + !SSL_CREDENTIAL_set1_private_key(identity, SSL_CTX_get0_privatekey(ctx)))) { + SSL_CREDENTIAL_free(identity); + identity = NULL; + } + us_free(certs); + return identity; +} + +/* One identity per key type, as in OpenSSL: the last certificate of a type wins, with whichever key fits it. */ +static int us_ssl_ctx_use_identities(SSL_CTX *ctx, const char *const *cert, unsigned int cert_count, + const char *const *key, unsigned int key_count) { + /* In OpenSSL's order of preference: BoringSSL serves the first usable credential. */ + enum { TYPE_ECDSA, TYPE_EDDSA, TYPE_RSA, KEY_TYPES }; + const char *leaf[KEY_TYPES] = {NULL}; + SSL_CREDENTIAL *identity[KEY_TYPES] = {NULL}; + int ok = 1; + for (unsigned int i = 0; i < cert_count; i++) { + if (us_ssl_ctx_use_certificate_chain(ctx, cert[i]) != 1) return 0; + int id = EVP_PKEY_id(X509_get0_pubkey(SSL_CTX_get0_certificate(ctx))); + leaf[id == EVP_PKEY_EC ? TYPE_ECDSA : id == EVP_PKEY_ED25519 ? TYPE_EDDSA : TYPE_RSA] = cert[i]; + } + /* Least preferred first: the legacy slot, which getCertificate() reads, is left holding the most preferred. */ + for (int type = KEY_TYPES; ok && type-- > 0;) { + if (leaf[type] == NULL) continue; + ok = us_ssl_ctx_use_certificate_chain(ctx, leaf[type]) == 1; + int has_key = 0, mismatched = 0; + for (unsigned int i = 0; ok && i < key_count; i++) { + if (us_ssl_ctx_use_privatekey_content(ctx, key[i], SSL_FILETYPE_PEM) == 1) { + has_key = 1; + continue; + } + uint32_t err = ERR_peek_last_error(); + int reason = ERR_GET_LIB(err) == ERR_LIB_X509 ? ERR_GET_REASON(err) : 0; + mismatched |= reason == X509_R_KEY_VALUES_MISMATCH; + ok = reason == X509_R_KEY_TYPE_MISMATCH || reason == X509_R_KEY_VALUES_MISMATCH; + if (ok) ERR_clear_error(); + } + if (ok && has_key) { + ok = (identity[type] = us_ssl_ctx_copy_legacy_identity(ctx)) != NULL; + } else if (ok && mismatched) { + OPENSSL_PUT_ERROR(X509, X509_R_KEY_VALUES_MISMATCH); + ok = 0; + } + } + for (int type = 0; type < KEY_TYPES; type++) { + if (ok && identity[type] != NULL) ok = SSL_CTX_add1_credential(ctx, identity[type]); + SSL_CREDENTIAL_free(identity[type]); + } + return ok; +} + static int us_verify_callback(int preverify_ok, X509_STORE_CTX *ctx) { /* Always continue; the user inspects via us_socket_verify_error after * on_handshake. Returning 1 defers the decision to JS without aborting @@ -1207,24 +1266,11 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, * (see passphrase_cb), matching Node's key-decryption error shape. */ SSL_CTX_set_default_passwd_cb(ssl_context, passphrase_cb); - /* Multiple identities (e.g. an RSA and an EC pair, the way Node accepts - * arrays of key/cert or several pfx entries) must be loaded pair-wise: - * loading every certificate first and then every key makes BoringSSL check - * each key against the last certificate loaded and fail with - * KEY_TYPE_MISMATCH on a mixed configuration. With pair-wise loading the - * later identity replaces the earlier one in the legacy slot, which is the - * documented BoringSSL behaviour the adapted tests expect. */ - int interleave_identities = !options.cert_file_name && !options.key_file_name && - options.cert && options.key && - options.cert_count == options.key_count && - options.cert_count > 1; - if (interleave_identities) { - for (unsigned int i = 0; i < options.cert_count; i++) { - if (us_ssl_ctx_use_certificate_chain(ssl_context, options.cert[i]) != 1 || - us_ssl_ctx_use_privatekey_content(ssl_context, options.key[i], SSL_FILETYPE_PEM) != 1) { - ssl_ctx_build_fail(ssl_context); - return NULL; - } + if (!options.cert_file_name && !options.key_file_name && options.cert && options.key && + (options.cert_count > 1 || options.key_count > 1)) { + if (!us_ssl_ctx_use_identities(ssl_context, options.cert, options.cert_count, options.key, options.key_count)) { + ssl_ctx_build_fail(ssl_context); + return NULL; } } else { if (options.cert_file_name) { diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index d48ec6433776..299346f1c33b 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1336,40 +1336,6 @@ function Server(options, secureConnectionListener): void { } next.key = key; - // BoringSSL rejects a mixed EC/RSA multi-identity configuration while - // loading the chain. The native context is built lazily at listen time, - // so surface the most common mismatch synchronously here: a key whose - // type differs from its own index-paired certificate. This is a - // best-effort check - the native loader at listen time remains the - // authority and still rejects configurations that pass it. - const keyLength = Array.isArray(key) ? key.length : 0; - if (keyLength > 1 && cert) { - const certs = Array.isArray(cert) ? cert : [cert]; - try { - const { createPrivateKey, X509Certificate } = require("node:crypto"); - for (let i = 0; i < keyLength; i++) { - const k = key[i]; - if (typeof k !== "string" && !$isTypedArrayView(k)) continue; - const pairedCert = certs[i < certs.length ? i : certs.length - 1]; - const certType = new X509Certificate(pairedCert).publicKey.asymmetricKeyType; - if (createPrivateKey(k).asymmetricKeyType !== certType) { - const err = new Error( - "error:0b000074:X.509 certificate routines:OPENSSL_internal:KEY_TYPE_MISMATCH", - ) as Error & { code: string; library: string; function: string; reason: string }; - err.code = "ERR_OSSL_X509_KEY_TYPE_MISMATCH"; - err.library = "X.509 certificate routines"; - err.function = "OPENSSL_internal"; - err.reason = "KEY_TYPE_MISMATCH"; - throw err; - } - } - } catch (e: any) { - if (e?.code === "ERR_OSSL_X509_KEY_TYPE_MISMATCH") throw e; - // An unparseable key or certificate falls through to the native - // load, which produces its own error. - } - } - let ca = options.ca; // The process-wide default-CA override (tls.setDefaultCACertificates) // applies here too when no explicit `ca` was given: this path hands raw diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 04cdcefe3c01..34bf709b0e59 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -281,3 +281,63 @@ describe("Bun.serve per-serverName client certificate policy", () => { }); }); }); + +test.each(["Bun.serve", "Bun.listen"] as const)("%s serves every identity of key/cert arrays", async api => { + const read = (name: string) => readFileSync(join(import.meta.dir, "../../node/tls/fixtures", name), "utf8"); + const identities = { + key: [read("agent1-key.pem"), read("ec10-key.pem")], + cert: [read("agent1-cert.pem"), read("ec10-cert.pem")], + }; + using server = + api === "Bun.serve" + ? Bun.serve({ port: 0, hostname: "127.0.0.1", tls: identities, fetch: () => new Response("ok") }) + : Bun.listen({ port: 0, hostname: "127.0.0.1", tls: identities, socket: { data() {} } }); + + function served(options: tls.ConnectionOptions) { + const { promise, resolve } = Promise.withResolvers(); + const socket = tls.connect({ port: server.port, host: "127.0.0.1", rejectUnauthorized: false, ...options }, () => { + resolve(socket.getPeerCertificate().subject.CN); + socket.destroy(); + }); + socket.on("error", e => resolve((e as NodeJS.ErrnoException).code!)); + return promise; + } + expect({ + "TLS 1.2, ECDSA only": await served({ maxVersion: "TLSv1.2", ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256" }), + "TLS 1.2, RSA only": await served({ maxVersion: "TLSv1.2", ciphers: "ECDHE-RSA-AES128-GCM-SHA256" }), + "TLS 1.3": await served({}), + "TLS 1.3, RSA only": await served({ sigalgs: "rsa_pss_rsae_sha256" }), + }).toEqual({ + "TLS 1.2, ECDSA only": "agent10.example.com", + "TLS 1.2, RSA only": "agent1", + "TLS 1.3": "agent10.example.com", + "TLS 1.3, RSA only": "agent1", + }); +}); + +test.each(["Bun.serve", "Bun.listen"] as const)( + "%s serves the last of two key/cert pairs of one key type", + async api => { + const read = (name: string) => readFileSync(join(import.meta.dir, "../../node/tls/fixtures", name), "utf8"); + for (const key of [ + [read("agent1-key.pem"), read("agent2-key.pem")], + [read("agent2-key.pem"), read("agent1-key.pem")], + ]) { + const identities = { key, cert: [read("agent1-cert.pem"), read("agent2-cert.pem")] }; + using server = + api === "Bun.serve" + ? Bun.serve({ port: 0, hostname: "127.0.0.1", tls: identities, fetch: () => new Response("ok") }) + : Bun.listen({ port: 0, hostname: "127.0.0.1", tls: identities, socket: { data() {} } }); + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect({ port: server.port, host: "127.0.0.1", rejectUnauthorized: false }, () => + resolve(socket.getPeerCertificate().subject.CN), + ); + socket.on("error", reject); + try { + expect(await promise).toBe("agent2"); + } finally { + socket.destroy(); + } + } + }, +); diff --git a/test/js/node/test/common/boringssl.js b/test/js/node/test/common/boringssl.js index 857365565163..6201e5a612d8 100644 --- a/test/js/node/test/common/boringssl.js +++ b/test/js/node/test/common/boringssl.js @@ -76,32 +76,6 @@ function assertOpenSSLSecurityLevelsUnsupported() { assertInvalidCommand(() => tls.createServer(options)); } -/** - * Node's multi-key tests rely on OpenSSL accepting an array of private keys and - * matching them with an array of certificates. BoringSSL rejects this mixed - * EC/RSA identity configuration while configuring the certificate chain, before - * a client can negotiate either identity. - */ -function assertMultiKeyUnsupported() { - assert.throws(() => { - tls.createServer({ - key: [ - fixtures.readKey('ec10-key.pem'), - fixtures.readKey('agent1-key.pem'), - ], - cert: [ - fixtures.readKey('agent1-cert.pem'), - fixtures.readKey('ec10-cert.pem'), - ], - }); - }, { - code: 'ERR_OSSL_X509_KEY_TYPE_MISMATCH', - library: 'X.509 certificate routines', - function: 'OPENSSL_internal', - reason: 'KEY_TYPE_MISMATCH', - }); -} - /** * BoringSSL does not support caller-initiated renegotiation. Even on a TLS 1.2 * connection, TLSSocket#renegotiate() returns false and the callback receives @@ -195,49 +169,6 @@ function testLegacyProtocolUnsupported() { })); } -/** - * BoringSSL can load a multi-PFX option well enough to serve the ECDSA - * identity, but it does not provide the same OpenSSL multi-identity selection - * behavior. After the ECDSA handshake succeeds, an RSA-only client fails with - * no shared cipher instead of selecting the RSA identity from the same PFX list. - */ -function testMultiPfxSelectionDifference() { - const server = tls.createServer({ - pfx: [ - { - buf: fixtures.readKey('agent1.pfx'), - passphrase: 'sample', - }, - fixtures.readKey('ec.pfx'), - ], - }, common.mustCallAtLeast((socket) => socket.end(), 1)); - - server.listen(0, common.mustCall(() => { - const ecdsa = tls.connect(server.address().port, { - ciphers: 'ECDHE-ECDSA-AES256-GCM-SHA384', - maxVersion: 'TLSv1.2', - rejectUnauthorized: false, - }, common.mustCall(() => { - assert.strictEqual(ecdsa.getCipher().name, - 'ECDHE-ECDSA-AES256-GCM-SHA384'); - ecdsa.end(); - - server.once('tlsClientError', common.mustCall((err) => { - assert.strictEqual(err.code, 'ERR_SSL_NO_SHARED_CIPHER'); - })); - const rsa = tls.connect(server.address().port, { - ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', - maxVersion: 'TLSv1.2', - rejectUnauthorized: false, - }, common.mustNotCall()); - rsa.on('error', common.mustCall((err) => { - assert.strictEqual(err.code, 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'); - server.close(); - })); - })); - })); -} - /** * PSK works for TLS 1.2 in BoringSSL, but Node's PSK tests also cover the * default TLS 1.3 path. In that path BoringSSL does not complete a certificate- @@ -332,12 +263,10 @@ function testTls13SessionTicketSemanticsDiffer() { module.exports = { assertFiniteFieldDheUnsupported, - assertMultiKeyUnsupported, assertNoCipherMatch, assertOpenSSLSecurityLevelsUnsupported, testEphemeralKeyInfoEcdheOnly, testLegacyProtocolUnsupported, - testMultiPfxSelectionDifference, testPskTls13Unsupported, testRenegotiationUnsupported, testTls13SessionTicketSemanticsDiffer, diff --git a/test/js/node/test/parallel/test-tls-multi-key.js b/test/js/node/test/parallel/test-tls-multi-key.js index 0a9c6f108bf6..77c72b897d77 100644 --- a/test/js/node/test/parallel/test-tls-multi-key.js +++ b/test/js/node/test/parallel/test-tls-multi-key.js @@ -27,11 +27,6 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -if (process.features.openssl_is_boringssl) { - require('../common/boringssl').assertMultiKeyUnsupported(); - return; -} - const fixtures = require('../common/fixtures'); const assert = require('assert'); const tls = require('tls'); @@ -106,7 +101,7 @@ test({ // Key and cert with mixed algorithms, and cert chains with intermediate CAs, // using PFX for EC. -test({ +testPfxChain({ key: [ fixtures.readKey('agent10-key.pem'), ], @@ -125,7 +120,7 @@ test({ // Key and cert with mixed algorithms, and cert chains with intermediate CAs, // using PFX for RSA. -test({ +testPfxChain({ key: [ fixtures.readKey('ec10-key.pem'), ], @@ -142,6 +137,11 @@ test({ ] }, }); +// Bun trusts the extra certificates of a PKCS#12 archive but does not send them as the chain. +function testPfxChain(options) { + if (!process.versions.bun) test(options); +} + function test(options) { const rsaCN = options.rsaCN || 'agent1'; const eccCN = options.eccCN || 'agent2'; diff --git a/test/js/node/test/parallel/test-tls-multi-pfx.js b/test/js/node/test/parallel/test-tls-multi-pfx.js index fec697cd3b70..526b77b1484c 100644 --- a/test/js/node/test/parallel/test-tls-multi-pfx.js +++ b/test/js/node/test/parallel/test-tls-multi-pfx.js @@ -3,11 +3,6 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -if (process.features.openssl_is_boringssl) { - require('../common/boringssl').testMultiPfxSelectionDifference(); - return; -} - const assert = require('assert'); const tls = require('tls'); const fixtures = require('../common/fixtures'); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 79ef5fb827e3..ebb7e0331b38 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -9,6 +9,7 @@ import https from "https"; import net, { AddressInfo } from "net"; import { createTest } from "node-harness"; import { once } from "node:events"; +import { Duplex } from "node:stream"; import { tmpdir } from "os"; import { join } from "path"; import type { PeerCertificate } from "tls"; @@ -3300,3 +3301,213 @@ it("an accepted socket emits 'close' when a write is the first to see the peer's expect(proc.signalCode).toBeNull(); expect(exitCode).toBe(0); }); + +describe("key/cert arrays", () => { + const read = (name: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", name), "utf8"); + const encrypt = (pem: string, passphrase: string) => + crypto.createPrivateKey(pem).export({ type: "pkcs8", format: "pem", cipher: "aes-256-cbc", passphrase }) as string; + const rsa = { key: read("agent1-key.pem"), cert: read("agent1-cert.pem") }; + const ec = { key: read("ec10-key.pem"), cert: read("ec10-cert.pem") }; + const otherRsa = { key: read("agent2-key.pem"), cert: read("agent2-cert.pem") }; + const thirdRsa = { key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }; + const both = { key: [rsa.key, ec.key], cert: [rsa.cert, ec.cert] }; + + /** "/", or the error code. */ + function served(port: number, options: tls.ConnectionOptions) { + const { promise, resolve } = Promise.withResolvers(); + const socket = connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ...options }, () => { + let sent = 0; + const seen = new Set(); + for (let c = socket.getPeerCertificate(true); c && !seen.has(c.fingerprint256); c = c.issuerCertificate) { + seen.add(c.fingerprint256); + sent++; + } + resolve(`${socket.getPeerCertificate().subject.CN}/${sent}`); + socket.destroy(); + }); + socket.on("error", e => resolve((e as NodeJS.ErrnoException).code!.replace("SSL/TLS_ALERT", "SSLV3_ALERT"))); + socket.on("close", () => resolve("closed")); + return promise; + } + + async function servedToEachKindOfClient(port: number, options: tls.ConnectionOptions = {}) { + return { + "TLS 1.2, ECDSA only": await served(port, { + ...options, + maxVersion: "TLSv1.2", + ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256", + }), + "TLS 1.2, RSA only": await served(port, { + ...options, + maxVersion: "TLSv1.2", + ciphers: "ECDHE-RSA-AES128-GCM-SHA256", + }), + "TLS 1.3": await served(port, options), + "TLS 1.3, RSA only": await served(port, { ...options, sigalgs: "rsa_pss_rsae_sha256" }), + }; + } + + async function listen(server: net.Server) { + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { port: (server.address() as AddressInfo).port, [Symbol.dispose]: () => void server.close() }; + } + + const each = (ecdsa: string, rsa: string, tls13 = ecdsa) => ({ + "TLS 1.2, ECDSA only": ecdsa, + "TLS 1.2, RSA only": rsa, + "TLS 1.3": tls13, + "TLS 1.3, RSA only": rsa, + }); + const refused = "ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE"; + + it.each([ + ["RSA, then ECDSA", both, each("agent10.example.com/2", "agent1/1")], + [ + "ECDSA, then RSA", + { key: [ec.key, rsa.key], cert: [ec.cert, rsa.cert] }, + each("agent10.example.com/2", "agent1/1"), + ], + [ + "keys and certificates in opposite orders", + { key: [ec.key, rsa.key], cert: both.cert }, + each("agent10.example.com/2", "agent1/1"), + ], + [ + "a chain in each entry", + { key: both.key, cert: [rsa.cert + read("ca1-cert.pem"), ec.cert + read("ca5-cert.pem")] }, + each("agent10.example.com/3", "agent1/2"), + ], + [ + "encrypted keys", + { key: [encrypt(rsa.key, "secret"), encrypt(ec.key, "secret")], cert: both.cert, passphrase: "secret" }, + each("agent10.example.com/2", "agent1/1"), + ], + ["a key with no certificate", { key: [ec.key, rsa.key], cert: [rsa.cert] }, each(refused, "agent1/1", "agent1/1")], + ["a certificate with no key", { key: [rsa.key], cert: both.cert }, each(refused, "agent1/1", "agent1/1")], + [ + "a pfx array", + { + pfx: [ + { buf: readFileSync(join(import.meta.dir, "../test/fixtures/keys/agent1.pfx")), passphrase: "sample" }, + readFileSync(join(import.meta.dir, "../test/fixtures/keys/ec.pfx")), + ], + }, + // Bun does not send the extra certificates of a PKCS#12 archive as the chain; Node v26.3.0 serves "agent1/2". + each("agent2/1", process.versions.bun ? "agent1/1" : "agent1/2"), + ], + ] as const)("every identity is served to the clients that can use it: %s", async (_, options, expected) => { + using server = await listen(createServer(options as tls.TlsOptions, socket => socket.on("error", () => {}))); + expect(await servedToEachKindOfClient(server.port)).toEqual(expected); + }); + + it.each([ + ["in the order of the certificates", [rsa.key, otherRsa.key]], + ["in the opposite order", [otherRsa.key, rsa.key]], + ])("of two certificates of one key type the last is served, with its own key: keys %s", async (_, key) => { + const options = { key: [...key, ec.key], cert: [rsa.cert, otherRsa.cert, ec.cert] }; + // Node v26.3.0 refuses these: there every key of a type has to match the last certificate of that type. + if (!process.versions.bun) { + expect(() => createServer(options)).toThrow( + expect.objectContaining({ code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH" }), + ); + return; + } + using server = await listen(createServer(options, socket => socket.on("error", () => {}))); + expect(await servedToEachKindOfClient(server.port)).toEqual(each("agent10.example.com/2", "agent2/1")); + }); + + it("two pairs of one key type serve the last pair", async () => { + // Node v26.3.0 throws ERR_OSSL_X509_KEY_VALUES_MISMATCH for the first of these. + if (process.versions.bun) { + using pairs = await listen(createServer({ key: [rsa.key, otherRsa.key], cert: [rsa.cert, otherRsa.cert] })); + expect(await served(pairs.port, {})).toBe("agent2/1"); + } + using server = await listen(createServer({ key: [otherRsa.key], cert: [rsa.cert, otherRsa.cert] })); + expect(await served(server.port, {})).toBe("agent2/1"); + }); + + it("a certificate whose only same-type keys do not fit is refused", () => { + for (const options of [ + { key: [otherRsa.key, ec.key], cert: both.cert }, + { key: [rsa.key], cert: [rsa.cert, otherRsa.cert] }, + { key: [rsa.key, read("ec-key.pem")], cert: both.cert }, + ]) { + expect(() => tls.createSecureContext(options)).toThrow( + expect.objectContaining({ code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH" }), + ); + } + expect(() => tls.createSecureContext({ key: [rsa.key, "not a key"], cert: [rsa.cert] })).toThrow(); + expect(() => tls.createSecureContext({ key: [rsa.key], cert: [rsa.cert, "not a certificate"] })).toThrow(); + expect(() => + tls.createSecureContext({ ...both, key: [encrypt(rsa.key, "secret"), ec.key], passphrase: "no" }), + ).toThrow(); + }); + + // Node v26.3.0 copies one identity out of an SNI context, so its RSA-only clients get the default context's certificate. + const sni = each("agent10.example.com/2", process.versions.bun ? "agent1/1" : "agent3/1"); + + it("addContext() serves every identity of its context", async () => { + const tlsServer = createServer(thirdRsa, socket => socket.on("error", () => {})); + tlsServer.addContext("a.example", both); + using server = await listen(tlsServer); + expect(await servedToEachKindOfClient(server.port, { servername: "a.example" })).toEqual(sni); + expect(await servedToEachKindOfClient(server.port, { servername: "b.example" })).toEqual( + each(refused, "agent3/1", "agent3/1"), + ); + }); + + it("SNICallback serves every identity of the context it returns", async () => { + const context = tls.createSecureContext(both); + const SNICallback = (_: string, callback: (err: Error | null, ctx?: tls.SecureContext) => void) => + callback(null, context); + using server = await listen(createServer({ ...thirdRsa, SNICallback }, socket => socket.on("error", () => {}))); + expect(await servedToEachKindOfClient(server.port, { servername: "a.example" })).toEqual(sni); + }); + + it("a server over a Duplex serves every identity", async () => { + const secureContext = tls.createSecureContext(both); + using server = await listen( + net.createServer(raw => { + const duplex = new Duplex({ + read() {}, + write(chunk, _, callback) { + raw.write(chunk, callback); + }, + }); + raw.on("data", chunk => duplex.push(chunk)); + raw.on("end", () => duplex.push(null)); + raw.on("error", () => {}); + new TLSSocket(duplex, { isServer: true, secureContext }).on("error", () => raw.destroy()); + }), + ); + expect(await servedToEachKindOfClient(server.port)).toEqual(each("agent10.example.com/2", "agent1/1")); + }); + + it.each([ + ["TLSv1.3", "agent1"], + // On TLS 1.2 Node v26.3.0 only considers the last identity, and sends no certificate here. + ["TLSv1.2", process.versions.bun ? "agent1" : undefined], + ] as const)("a %s client sends the identity the server can verify", async (version, expected) => { + const { promise, resolve, reject } = Promise.withResolvers(); + const tlsServer = createServer( + { + ...thirdRsa, + requestCert: true, + rejectUnauthorized: false, + sigalgs: "rsa_pss_rsae_sha256", + maxVersion: version, + }, + socket => resolve(socket.getPeerCertificate().subject?.CN), + ); + tlsServer.on("tlsClientError", reject); + using server = await listen(tlsServer); + const client = connect({ port: server.port, host: "127.0.0.1", rejectUnauthorized: false, ...both }); + client.on("error", reject); + try { + expect(await promise).toBe(expected); + } finally { + client.destroy(); + } + }); +}); From c55e4fe5e4c354b35609c83e417bb9b16e8abc59 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:13:58 +0000 Subject: [PATCH 014/174] tls: free a stream TLS engine's SSL after its pass has unwound (#44462) ALPNCallback runs inside SSL_do_handshake. Over a Duplex, destroy() there reached SSLWrapper::deinit(), which freed the SSL below BoringSSL's frames: heap-use-after-free on TLS 1.2 and 1.3, through server.emit("connection", duplex) and new TLSSocket(duplex, { isServer: true }). deinit() still takes the SSL out of the wrapper at once, so every guard sees a closed wrapper. Inside a handle_traffic pass the outermost pass frees it once it has returned. The handshake is only driven from a pass. --- src/uws/lib.rs | 20 ++++-- test/js/node/tls/node-tls-server.test.ts | 27 +++++++ .../tls-alpn-callback-over-duplex-fixture.mjs | 72 +++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 test/js/node/tls/tls-alpn-callback-over-duplex-fixture.mjs diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 00fa2745f1c1..458845c68504 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -926,13 +926,21 @@ pub mod ssl_wrapper { pub fn deinit(&self) { self.flags.set_closed_notified(true); - if let Some(ssl) = self.ssl.take() { - // SAFETY: ssl was created by SSL_new and is owned by self; SSL_free also frees the input and output BIOs. - unsafe { boring_sys::SSL_free(ssl.as_ptr()) }; + let ssl = self.ssl.take(); + // A running pass can have BoringSSL on the stack (ALPNCallback): `handle_traffic` frees it. + if self.traffic.get() == Traffic::Idle { + Self::free(ssl); } self.ctx.set(None); } + fn free(ssl: Option>) { + if let Some(ssl) = ssl { + // SAFETY: ssl was created by SSL_new and the caller took it out of `self.ssl`; SSL_free also frees the input and output BIOs. + unsafe { boring_sys::SSL_free(ssl.as_ptr()) }; + } + } + fn trigger_handshake_callback(&self, outcome: HandshakeOutcome) { if self.flags.closed_notified() { return; @@ -1306,6 +1314,7 @@ pub mod ssl_wrapper { self.traffic.set(Traffic::RerunRequested); return; } + let ssl = self.ssl.get(); loop { self.traffic.set(Traffic::Running); self.traffic_pass(); @@ -1314,6 +1323,9 @@ pub mod ssl_wrapper { } } self.traffic.set(Traffic::Idle); + if self.ssl.get().is_none() { + Self::free(ssl); + } } fn traffic_pass(&self) { @@ -1375,7 +1387,7 @@ pub mod ssl_wrapper { impl Drop for SSLWrapper { fn drop(&mut self) { - self.deinit(); + Self::free(self.ssl.take()); } } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index ebb7e0331b38..a53d6375292e 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -759,6 +759,33 @@ it("destroying the socket from inside SNICallback or ALPNCallback does not crash expect(true).toBe(true); }); +describe.each(["TLSv1.3", "TLSv1.2"])("an ALPNCallback over a Duplex can drop its connection (%s)", maxVersion => { + it.concurrent.each([ + ["emit", 'server.emit("connection", duplex)'], + ["wrap", "new TLSSocket(duplex, { isServer: true })"], + ["staged", "new TLSSocket(duplex, { isServer: true }) after the ClientHello"], + ])("%s: %s", async door => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "tls-alpn-callback-over-duplex-fixture.mjs"), door, maxVersion], + env: { ...bunEnv, TLS_KEY: cert1.key, TLS_CERT: cert1.cert }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "destroy()": ["client error ECONNRESET"], + "destroy() and refuse": ["client error ECONNRESET"], + "destroy(err)": ["server error boom", "client error ECONNRESET"], + "destroy() the client": [], + "destroy() the transport": ["client error ECONNRESET"], + "throw": ["server error boom", "client error ECONNRESET"], + "select": ["client secureConnect h2"], + }); + expect(exitCode).toBe(0); + }); +}); + it("writing to the socket from inside SNICallback or ALPNCallback delivers the data after the handshake", async () => { // Same callbacks as above: they run from inside the native read that is // processing the ClientHello. A write issued there has to be held until that diff --git a/test/js/node/tls/tls-alpn-callback-over-duplex-fixture.mjs b/test/js/node/tls/tls-alpn-callback-over-duplex-fixture.mjs new file mode 100644 index 000000000000..a573ef16663c --- /dev/null +++ b/test/js/node/tls/tls-alpn-callback-over-duplex-fixture.mjs @@ -0,0 +1,72 @@ +// What an ALPNCallback may do to the connection it is choosing for, when the TLS +// engine runs over a stream. The callback runs inside the engine's handshake call. +// One report per run. Runs on bun and on node. +import { duplexPair } from "node:stream"; +import tls from "node:tls"; + +const [door, maxVersion] = process.argv.slice(2); +const { TLS_KEY: key, TLS_CERT: cert } = process.env; + +const actions = { + "destroy()"() { + this.destroy(); + return "h2"; + }, + "destroy() and refuse"() { + this.destroy(); + }, + "destroy(err)"() { + this.destroy(new Error("boom")); + return "h2"; + }, + "destroy() the client"(current) { + current.client.destroy(); + return "h2"; + }, + "destroy() the transport"(current) { + current.transport.destroy(); + return "h2"; + }, + "throw"() { + throw new Error("boom"); + }, + "select"() { + return "h2"; + }, +}; + +let current; +const options = { + key, + cert, + maxVersion, + ALPNCallback() { + return current.action.call(this, current); + }, +}; +// node also reports a bare destroy() as ECONNRESET to 'tlsClientError'. +const onServerError = err => err.code === "ECONNRESET" || current.events.push(`server error ${err.message}`); +const server = tls.createServer(options).on("tlsClientError", onServerError); + +const report = {}; +for (const [name, action] of Object.entries(actions)) { + const [transport, clientTransport] = duplexPair(); + const events = (report[name] = []); + current = { action, transport, events }; + const connect = () => { + const client = tls.connect({ socket: clientTransport, rejectUnauthorized: false, ALPNProtocols: ["h2"] }); + client.on("error", err => events.push(`client error ${err.code}`)); + client.on("secureConnect", () => { + events.push(`client secureConnect ${client.alpnProtocol}`); + client.destroy(); + }); + current.client = client; + }; + // "staged": the ClientHello is there before the server's engine starts. + if (door === "staged") connect(); + if (door === "emit") server.emit("connection", transport); + else new tls.TLSSocket(transport, { isServer: true, ...options }).on("error", onServerError); + if (door !== "staged") connect(); + await new Promise(resolve => current.client.on("close", resolve)); +} +console.log(JSON.stringify(report)); From 177f7afaee00ab26471084b57b0692cfd62a265f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:30:15 +0000 Subject: [PATCH 015/174] http2: keep frames whole and sent once over a TLS socket that wraps a Duplex (#43529) tls.connect({ socket: duplex }) is a native socket to the session, but each write to it runs the Duplex's _write. A frame issued there (ping(), settings(), request()) landed inside the outer frame's payload or between HEADERS and CONTINUATION, and a flush issued there sent the connection preface again. write() sends every transport whose write runs JS through write_to_js_transport, which only writes at a frame boundary. generic_flush and generic_write take the pending bytes out of write_buffer before such a write. --- src/runtime/api/bun/h2_frame_parser.rs | 50 +++++- test/js/node/http2/node-http2.test.js | 209 +++++++++++++++++++------ 2 files changed, 208 insertions(+), 51 deletions(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 804203207077..eb060cc0399a 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -2516,9 +2516,47 @@ impl H2FrameParser { CORK_OFFSET.with(|c| c.set(0)); } + /// Takes the pending bytes out for the write: JS re-entering under it would send them again. + fn write_pending_through_js( + &self, + mut socket: S, + bytes: &[u8], + ) -> (usize, usize) { + let offset = self.write_buffer_offset.replace(0); + let mut pending = self.write_buffer.take(); + let _ = pending.write(bytes); + let total = pending.len() - offset; + let result: i32 = socket.write_maybe_corked(&pending[offset..]); + let written: usize = if result < 0 { + if Self::is_transport_fatal_write_result(result) { + self.note_transport_write_fatal(); + } + 0 + } else { + usize::try_from(result).expect("int cast") + }; + if written < total { + // The rest goes back in front of whatever a re-entrant write buffered meanwhile. + let reentrant_offset = self.write_buffer_offset.replace(offset + written); + let reentrant = self.write_buffer.replace(pending); + let _ = self + .write_buffer + .with_mut(|wb| wb.write(&reentrant[reentrant_offset..])); + self.global() + .vm() + .deprecated_report_extra_memory(bytes.len().min(total - written)); + } + (written, total) + } + pub(crate) fn generic_flush(&self, mut socket: S) -> usize { let buffer_len = self.write_buffer.get().slice()[self.write_buffer_offset.get()..].len(); if buffer_len > 0 { + if self.transport_write_runs_js() { + let (written, _) = self.write_pending_through_js(socket, b""); + bun_output::scoped_log!(H2FrameParser, "_genericFlush {}", written); + return written; + } let result: i32 = socket.write_maybe_corked( &self.write_buffer.get().slice()[self.write_buffer_offset.get()..], ); @@ -2560,6 +2598,10 @@ impl H2FrameParser { let global = self.global(); let buffered_len = self.write_buffer.get().slice()[self.write_buffer_offset.get()..].len(); if buffered_len > 0 { + if self.transport_write_runs_js() { + let (written, total) = self.write_pending_through_js(socket, bytes); + return written == total; + } { let result: i32 = socket.write_maybe_corked( &self.write_buffer.get().slice()[self.write_buffer_offset.get()..], @@ -3167,7 +3209,7 @@ impl H2FrameParser { /// Flush the cork buffer's current contents without releasing cork state, so a /// fill-to-boundary write can keep accumulating the remainder. The corked bytes are - /// moved out before _write — it can re-enter JS (JS-stream-backed sockets) and no + /// moved out before _write — a socket that closes inside the write runs JS, and no /// thread-local borrow may be held across it. fn flush_cork_buffer(&self) -> bool { let mut data = BATCH_BUFFER.with_borrow_mut(core::mem::take); @@ -3197,7 +3239,7 @@ impl H2FrameParser { return self._write(bytes); } self.cork(); - if matches!(self.native_socket.get(), BunSocket::None) { + if self.transport_write_runs_js() { return self.write_to_js_transport(bytes); } let mut ok = true; @@ -3226,14 +3268,14 @@ impl H2FrameParser { }); CORK_OFFSET.with(|c| c.set(H2_CORK_BUFFER_SIZE as u16)); ok = self.flush_cork_buffer() && ok; - // The flush's _write can re-enter JS and re-cork a different parser; + // A close inside the flush's _write runs JS, which can re-cork a different parser; // re-assert ownership before touching the shared cork state again. self.cork(); bytes = &bytes[avail..]; } } - /// `write()` for a session with no native socket, whose bytes reach the wire through the + /// `write()` when `transport_write_runs_js`, e.g. when the bytes reach the wire through the /// `onWrite` handler (`socket.write()` on a JS stream). That call runs the transport's /// `_write` synchronously, and user code there can serialize another frame (ping(), /// settings(), goaway(), request()) or flush before it returns. Bytes are therefore only diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index a7d2c4d33238..472ab6f93cde 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -3,6 +3,7 @@ import { bunEnv, bunExe, isASAN, isCI, isDebug, nodeExe } from "harness"; import { createTest } from "node-harness"; import { AsyncLocalStorage } from "node:async_hooks"; import dc from "node:diagnostics_channel"; +import { once } from "node:events"; import fs from "node:fs"; import http2 from "node:http2"; import https from "node:https"; @@ -6447,13 +6448,62 @@ describe("frames issued from inside a user-supplied Duplex transport's _write", issue: sess => { const r = sess.request({ ":method": "POST", ":path": "/nested" }, { endStream: false }); r.on("error", () => {}); - r.end(nestedBody); + return r.end(nestedBody); }, }, }; // outer "data": the nested call fires while the 16374-byte body's DATA frame overflows the cork // behind the corked ~11 KiB HEADERS. outer "continuation": it fires while a header block larger // than one frame (HEADERS + CONTINUATION) is being handed to the transport. + const body = Buffer.alloc(16374, 0x41); + // arm() runs right before the write the nested call has to fire in. Resolves once the session + // has serialized the body (the stream is corked until the tick after request()). + function sendOuterRequest(sess, outer, arm) { + // 15000 'p's HPACK-encode to ~11 KiB (one HEADERS frame); 30000 to ~22 KiB, which needs a + // CONTINUATION frame after a full 16384-byte HEADERS frame. + const pad = Buffer.alloc(outer === "continuation" ? 30000 : 15000, 0x70).toString(); + if (outer === "continuation") arm(); + const req = sess.request({ ":method": "POST", ":path": "/", "x-pad": pad }, { endStream: false }); + req.on("error", () => {}); + arm(); + return new Promise(resolve => req.write(body, resolve)); + } + function expectNestedAfterOuterUnit(parsed, kind, outer) { + // Every byte the transport received belongs to exactly one complete frame, and header + // blocks are never interleaved with other frames. + expect(parsed.complete).toBe(true); + expect(parsed.headerBlocksContiguous).toBe(true); + // One connection preface + SETTINGS (a second one mid-stream would not even parse), one + // request header block on stream 1, one DATA frame carrying exactly the body. + expect(parsed.frames.filter(f => f.type === FRAME.SETTINGS).length).toBe(kind === "settings" ? 2 : 1); + expect(parsed.frames.filter(f => f.type === FRAME.HEADERS && f.streamId === 1).length).toBe(1); + expect(parsed.frames.some(f => f.type === FRAME.CONTINUATION && f.streamId === 1)).toBe(outer === "continuation"); + const dataFrames = parsed.frames.filter(f => f.type === FRAME.DATA && f.streamId === 1); + expect(dataFrames.length).toBe(1); + expect(dataFrames[0].payload.equals(body)).toBe(true); + // The nested frames are well-formed frames of their own, after the unit they were issued + // from: the stream-1 header block, and (when issued during the body write) its DATA frame. + const blockEnd = parsed.frames.findIndex( + f => [FRAME.HEADERS, FRAME.CONTINUATION].includes(f.type) && f.streamId === 1 && f.flags & END_HEADERS, + ); + expect(blockEnd).toBeGreaterThanOrEqual(0); + const issuedAfter = outer === "continuation" ? blockEnd : parsed.frames.indexOf(dataFrames[0]); + const after = parsed.frames.slice(issuedAfter + 1); + const before = parsed.frames.slice(0, issuedAfter + 1); + expect(before.filter(f => f.streamId === 3).length).toBe(0); + if (kind === "request" || kind === "data") { + expect(after.filter(f => f.type === FRAME.HEADERS && f.streamId === 3).length).toBe(1); + } else { + expect(before.slice(1).filter(f => f.type === nested[kind].type).length).toBe(0); + expect(after.filter(f => f.type === nested[kind].type).length).toBe(1); + } + if (kind === "data") { + const nestedData = Buffer.concat( + after.filter(f => f.type === FRAME.DATA && f.streamId === 3).map(f => f.payload), + ); + expect(nestedData.equals(nestedBody)).toBe(true); + } + } const cases = []; for (const kind of Object.keys(nested)) { cases.push( @@ -6490,16 +6540,7 @@ describe("frames issued from inside a user-supplied Duplex transport's _write", sess.on("error", () => {}); try { await new Promise(resolve => sess.once("connect", resolve)); - - const body = Buffer.alloc(16374, 0x41); - // 15000 'p's HPACK-encode to ~11 KiB (one HEADERS frame); 30000 to ~22 KiB, which needs a - // CONTINUATION frame after a full 16384-byte HEADERS frame. - const pad = Buffer.alloc(outer === "continuation" ? 30000 : 15000, 0x70).toString(); - if (outer === "continuation") armed = true; - const req = sess.request({ ":method": "POST", ":path": "/", "x-pad": pad }, { endStream: false }); - req.on("error", () => {}); - armed = true; - req.write(body); + sendOuterRequest(sess, outer, () => (armed = true)); // Wait (by condition, not time) until the outer frames and the nested frames are all out. const done = parsed => { @@ -6524,47 +6565,121 @@ describe("frames issued from inside a user-supplied Duplex transport's _write", } expect(issued).toBe(true); - // Every byte the transport received belongs to exactly one complete frame, and header - // blocks are never interleaved with other frames. - expect(parsed.complete).toBe(true); - expect(parsed.headerBlocksContiguous).toBe(true); - // One connection preface + SETTINGS (a second one mid-stream would not even parse), one - // request header block on stream 1, one DATA frame carrying exactly the body. - expect(parsed.frames.filter(f => f.type === FRAME.SETTINGS).length).toBe(kind === "settings" ? 2 : 1); - expect(parsed.frames.filter(f => f.type === FRAME.HEADERS && f.streamId === 1).length).toBe(1); - expect(parsed.frames.some(f => f.type === FRAME.CONTINUATION && f.streamId === 1)).toBe( - outer === "continuation", - ); - const dataFrames = parsed.frames.filter(f => f.type === FRAME.DATA && f.streamId === 1); - expect(dataFrames.length).toBe(1); - expect(dataFrames[0].payload.equals(body)).toBe(true); - // The nested frames are well-formed frames of their own, after the unit they were issued - // from: the stream-1 header block, and (when issued during the body write) its DATA frame. - const blockEnd = parsed.frames.findIndex( - f => [FRAME.HEADERS, FRAME.CONTINUATION].includes(f.type) && f.streamId === 1 && f.flags & END_HEADERS, - ); - expect(blockEnd).toBeGreaterThanOrEqual(0); - const issuedAfter = outer === "continuation" ? blockEnd : parsed.frames.indexOf(dataFrames[0]); - const after = parsed.frames.slice(issuedAfter + 1); - const before = parsed.frames.slice(0, issuedAfter + 1); - expect(before.filter(f => f.streamId === 3).length).toBe(0); - if (kind === "request" || kind === "data") { - expect(after.filter(f => f.type === FRAME.HEADERS && f.streamId === 3).length).toBe(1); - } else { - expect(before.slice(1).filter(f => f.type === nested[kind].type).length).toBe(0); - expect(after.filter(f => f.type === nested[kind].type).length).toBe(1); - } - if (kind === "data") { - const nestedData = Buffer.concat( - after.filter(f => f.type === FRAME.DATA && f.streamId === 3).map(f => f.payload), - ); - expect(nestedData.equals(nestedBody)).toBe(true); - } + expectNestedAfterOuterUnit(parsed, kind, outer); } finally { sess.destroy(); } }, ); + + // The same transport under a TLS socket. tls.connect({ socket: duplex }) is a native socket to + // the session, but every write to it hands a TLS record to the Duplex, so it runs _write just + // as synchronously. The frames are read from what a TLS server decrypts. + // outer "connect": the nested call fires while the session hands the transport the bytes that + // waited for the connect (the connection preface and SETTINGS). The session used to keep them + // pending until that write returned, so a frame or a flush issued from inside it sent them again. + function expectNestedAfterConnectFlush(bytes, parsed, kind) { + expect(bytes.indexOf(PREFACE)).toBe(0); + expect(bytes.indexOf(PREFACE, 1)).toBe(-1); + expect(parsed.complete).toBe(true); + expect(parsed.headerBlocksContiguous).toBe(true); + expect(parsed.frames[0].type).toBe(FRAME.SETTINGS); + expect(parsed.frames.filter(f => f.type === FRAME.SETTINGS).length).toBe(kind === "settings" ? 2 : 1); + // The nested request is the session's first stream here. + const after = parsed.frames.slice(1); + if (kind === "request") { + expect(after.filter(f => f.type === FRAME.HEADERS && f.streamId === 1).length).toBe(1); + } else { + expect(after.filter(f => f.type === nested[kind].type).length).toBe(1); + } + } + const tlsCases = [ + ...["ping", "settings", "request", "data"].flatMap(kind => [ + [kind, "data"], + [kind, "continuation"], + ]), + ["settings", "connect"], + ["request", "connect"], + ["settings", "connect behind a corked frame"], + ]; + it.each(tlsCases)( + "nested %s() issued during the outer %s write (transport under a TLSSocket)", + async (kind, outer) => { + const received = []; + // Sent once every other frame is serialized, so it is the session's last frame: when the + // server has it, it has them all. Nothing is held back, because this transport reports no + // backpressure and both bodies fit the default flow-control windows. + const lastPing = Buffer.from("LASTPING"); + const receivedAll = Promise.withResolvers(); + receivedAll.promise.catch(() => {}); + const server = tls.createServer({ ...TLS_CERT, ALPNProtocols: ["h2"] }, socket => { + socket.on("error", () => {}); + socket.on("data", chunk => { + received.push(chunk); + if (Buffer.concat(received).includes(lastPing)) receivedAll.resolve(); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const raw = net.connect(server.address().port, "127.0.0.1"); + raw.on("error", () => {}); + let armed = false; + let issued = false; + let nestedResult; + let sess; + let socket; + const transport = new Duplex({ + read() {}, + write(chunk, enc, cb) { + if (armed && !issued) { + issued = true; + nestedResult = nested[kind].issue(sess); + } + raw.write(chunk, cb); + }, + }); + transport.on("error", () => {}); + raw.on("data", chunk => transport.push(chunk)); + try { + await once(raw, "connect"); + socket = tls.connect({ socket: transport, ALPNProtocols: ["h2"], ...TLS_OPTIONS }); + socket.on("error", () => {}); + if (outer.startsWith("connect")) { + // Runs right before the session's own listener attaches the socket and flushes. Armed + // for that tick only: the nested call has to fire from inside that flush. + socket.once("secureConnect", () => { + // The flush then writes this frame, with the waiting bytes ahead of it. + if (outer !== "connect") sess.ping(Buffer.from("CORKPING"), () => {}); + armed = true; + process.nextTick(() => (armed = false)); + }); + } + sess = http2.connect("https://localhost", { createConnection: () => socket }); + sess.on("error", () => {}); + sess.once("close", () => receivedAll.reject(new Error("the session closed before the server had every frame"))); + await once(sess, "connect"); + expect(socket._handle).toBeTruthy(); + + if (!outer.startsWith("connect")) await sendOuterRequest(sess, outer, () => (armed = true)); + expect(issued).toBe(true); + // node sends a PING ahead of DATA frames that are still queued. + if (kind === "data" && !nestedResult.writableFinished) await once(nestedResult, "finish"); + sess.ping(lastPing, () => {}); + await receivedAll.promise; + + const bytes = Buffer.concat(received); + const parsed = parseFrames(bytes); + parsed.frames = parsed.frames.filter(f => !(f.type === FRAME.PING && f.payload.equals(lastPing))); + if (outer.startsWith("connect")) expectNestedAfterConnectFlush(bytes, parsed, kind); + else expectNestedAfterOuterUnit(parsed, kind, outer); + } finally { + sess?.destroy(); + socket?.destroy(); + transport.destroy(); + raw.destroy(); + server.close(); + } + }, + ); }); it("originSet is undefined on a destroyed TLS session that never read it", async () => { From 16475ca01d5fbd7e9d190605b26ddb235d1169b1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:30:16 +0000 Subject: [PATCH 016/174] tls: pause and resume a wrapped Duplex with the TLS socket's reads (#42332) pause_stream/resume_stream were a TODO for a TLS socket over a Duplex, so readStop() did nothing: a paused reader of 1 MiB held all of it and the transport never paused. They now call pause()/resume() on the wrapped stream, like node's JSStreamSocket. A paused transport holds its 'end' event back, so the check that drops a close_notify answer after the transport's EOF reads _readableState.ended in place of a flag set by 'end'. The close resumes the transport: left paused, a net.Socket transport never reads its peer's FIN. --- src/runtime/socket/UpgradedDuplex.rs | 62 ++++++- src/uws_sys/lib.rs | 10 + src/uws_sys/socket.rs | 4 +- test/js/node/tls/node-tls-connect.test.ts | 212 ++++++++++++++++++++++ 4 files changed, 279 insertions(+), 9 deletions(-) diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 88f0656d7e09..985aeae06814 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -62,9 +62,6 @@ pub(crate) struct UpgradedDuplex { pub pending_close: Cell, /// [`Self::shutdown`] arrived before the engine existed. [`Self::drain_pending`] replays it. pub pending_shutdown: Cell, - /// The transport delivered EOF (its 'end' event fired). Teardown payloads - /// (close_notify) are dropped after this; see [`Self::call_write_or_end`]. - pub transport_eof: Cell, } bun_event_loop::impl_timer_owner!(UpgradedDuplex; from_timer_ptr => event_loop_timer); @@ -220,6 +217,8 @@ impl UpgradedDuplex { js_wrapper.ensure_still_alive(); (self.handlers.on_close)(self.handlers.ctx); + // Left paused, a net.Socket transport never reads its peer's FIN and stays open. + self.call_origin("resume"); // closes the underlying duplex self.call_write_or_end(None, false); @@ -228,6 +227,41 @@ impl UpgradedDuplex { js_wrapper.ensure_still_alive(); } + /// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L125 + #[uws_callback(export = "UpgradedDuplex__pause_stream")] + pub(crate) fn pause_stream(&self) -> bool { + // Like a connecting fd: `on_open` forgets the owner's pause, so nothing would resume this one. + self.wrapper_ref().is_some() && self.call_origin("pause") + } + + #[uws_callback(export = "UpgradedDuplex__resume_stream")] + pub(crate) fn resume_stream(&self) -> bool { + self.call_origin("resume") + } + + /// `origin[name]()`. A throw goes to `on_error`. False when the call did not complete. + fn call_origin(&self, name: &str) -> bool { + let duplex = self.origin.get(); + if duplex.is_empty() { + return false; + } + let Some(global) = self.global else { + return false; + }; + let result = match duplex.get(&global, name) { + Ok(Some(method)) if method.is_callable() => method.call(&global, duplex, &[]), + Ok(_) => return false, + Err(err) => Err(err), + }; + match result { + Ok(_) => true, + Err(err) => { + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + false + } + } + } + fn call_write_or_end(&self, data: Option<&[u8]>, msg_more: bool) { // No JS duplex to talk to: the zeroed placeholder, or the owning // socket's finalizer abandoned it (`abandon_js_side`). @@ -250,7 +284,7 @@ impl UpgradedDuplex { // throws writeAfterFIN (EPIPE). The trailing end() is not a write // and still goes through the writableEnded probe below, so a // half-open transport sees our FIN. - if data.is_some() && self.transport_eof.get() { + if data.is_some() && Self::transport_got_eof(duplex, &global) { return; } // Node ends no destroyed stream. @@ -294,6 +328,23 @@ impl UpgradedDuplex { } } + /// Not the 'end' event, which a paused transport holds back. No public property tells. + fn transport_got_eof(duplex: JSValue, global: &JSGlobalObject) -> bool { + let ended = duplex + .get(global, "_readableState") + .and_then(|state| match state { + Some(state) if state.is_object() => state.get(global, "ended"), + _ => Ok(None), + }); + match ended { + Ok(ended) => ended.is_some_and(|ended| ended.to_boolean()), + Err(err) => { + let _ = global.take_exception(err); + false + } + } + } + fn internal_write(this: *mut Self, encoded_data: &[u8]) { // SAFETY: see handler note above. unsafe { &*this }.write_encrypted(encoded_data); @@ -417,7 +468,6 @@ impl UpgradedDuplex { pending_data: JsCell::new(Vec::new()), pending_close: Cell::new(false), pending_shutdown: Cell::new(false), - transport_eof: Cell::new(false), } } @@ -697,7 +747,6 @@ impl UpgradedDuplex { self.pending_data.set(Vec::new()); self.pending_close.set(false); self.pending_shutdown.set(false); - self.transport_eof.set(false); } } @@ -756,7 +805,6 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { // SAFETY: see host-fn note above. let this = unsafe { &*self_ptr.cast::() }; - this.transport_eof.set(true); // Like node's JSStreamSocket. Ahead of staged bytes too: no handshake can complete after it. (this.handlers.on_end)(this.handlers.ctx); } diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 4deb071c8632..5272edc476f5 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -227,6 +227,8 @@ unsafe extern "C" { safe fn UpgradedDuplex__shutdown_read(this: &mut UpgradedDuplex); safe fn UpgradedDuplex__close(this: &mut UpgradedDuplex); safe fn UpgradedDuplex__abandon_js_side(this: &mut UpgradedDuplex); + safe fn UpgradedDuplex__pause_stream(this: &mut UpgradedDuplex) -> bool; + safe fn UpgradedDuplex__resume_stream(this: &mut UpgradedDuplex) -> bool; } impl UpgradedDuplex { #[inline] @@ -296,6 +298,14 @@ impl UpgradedDuplex { pub(crate) fn abandon_js_side(&mut self) { UpgradedDuplex__abandon_js_side(self) } + #[inline] + pub(crate) fn pause_stream(&mut self) -> bool { + UpgradedDuplex__pause_stream(self) + } + #[inline] + pub(crate) fn resume_stream(&mut self) -> bool { + UpgradedDuplex__resume_stream(self) + } } // ── WindowsNamedPipe (cycle-break shim) ───────────────────────────────────── diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 32d37ee3bae6..4051f10d6f8d 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -492,7 +492,7 @@ impl NewSocketHandler { connected s => if s.is_established() { s.pause(); true } else { false }, connecting _c => false, detached => true, - duplex _d => false, // TODO: pause/resume upgraded duplex + duplex d => d.pause_stream(), pipe p => p.pause_stream(), ) } @@ -502,7 +502,7 @@ impl NewSocketHandler { connected s => if s.is_established() { s.resume(); true } else { false }, connecting _c => false, detached => true, - duplex _d => false, // TODO: pause/resume upgraded duplex + duplex d => d.resume_stream(), pipe p => p.resume_stream(), ) } diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 97c2388fa668..7a5c9abbe3fd 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -887,6 +887,218 @@ it("a client and a server TLSSocket connected through a synchronous in-memory du }); }); +describe("a TLS socket over a Duplex transport reads it with backpressure", () => { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L125 + // Each side's _write pushes straight into the other side and never waits, so + // only the reading TLS socket can slow its transport down. + function inMemoryPair(onWrite?: (side: Duplex) => Error | undefined) { + const gotEOF = new WeakSet(); + const makeSide = (peer: () => Duplex) => + new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + const err = gotEOF.has(this) ? onWrite?.(this) : undefined; + if (err) return callback(err); + peer().push(chunk); + callback(); + }, + final(callback) { + gotEOF.add(peer()); + peer().push(null); + callback(); + }, + }); + const clientSide: Duplex = makeSide(() => serverSide); + const serverSide: Duplex = makeSide(() => clientSide); + return { clientSide, serverSide }; + } + const serverOptions = () => ({ isServer: true, secureContext: tls.createSecureContext(COMMON_CERT_) }); + const piece = Buffer.alloc(64 * 1024, "x"); + const pieces = 16; + const total = piece.length * pieces; + // An engine takes in a whole transport chunk once it has it, and node hands the writes queued + // behind a pending one over as one chunk. So: one write at a time. + async function writePieces(socket: TLSSocket, end = false) { + for (let i = 1; i <= pieces; i++) { + const { promise, resolve, reject } = Promise.withResolvers(); + const done = (err?: Error | null) => (err ? reject(err) : resolve()); + if (end && i === pieces) socket.end(piece, done); + else socket.write(piece, done); + await promise; + } + } + // Rejects with the first 'error' any of `sockets` emits. Meant to be raced. + function firstErrorOf(...sockets: TLSSocket[]) { + const { promise, reject } = Promise.withResolvers(); + promise.catch(() => {}); + for (const socket of sockets) socket.on("error", reject); + return promise; + } + function destroyedOnExit(sockets: { destroy(): unknown }[]) { + return { + [Symbol.dispose]() { + for (const socket of sockets) socket.destroy(); + }, + }; + } + async function securePair(reader: "client" | "server", pair = inMemoryPair()) { + const { clientSide, serverSide } = pair; + const server = new TLSSocket(serverSide, serverOptions()); + const client = tls.connect({ socket: clientSide, rejectUnauthorized: false }); + const failed = firstErrorOf(server, client); + const cleanup = destroyedOnExit([client, server]); + try { + await Promise.race([Promise.all([once(server, "secure"), once(client, "secureConnect")]), failed]); + } catch (err) { + cleanup[Symbol.dispose](); + throw err; + } + const [readerSocket, transport, writerSocket] = + reader === "client" ? [client, clientSide, server] : [server, serverSide, client]; + return { readerSocket, transport, writerSocket, failed, ...cleanup }; + } + + describe.each(["client", "server"] as const)("%s reader", reader => { + it("a paused socket pauses the transport once its buffer is full", async () => { + using pair = await securePair(reader); + const { readerSocket, transport, writerSocket, failed } = pair; + readerSocket.pause(); + await Promise.race([writePieces(writerSocket), failed]); + expect({ + transportFlowing: transport.readableFlowing, + socketIsFull: readerSocket.readableLength >= readerSocket.readableHighWaterMark, + mostOfItWaitsInTheTransport: transport.readableLength > total / 2, + }).toEqual({ transportFlowing: false, socketIsFull: true, mostOfItWaitsInTheTransport: true }); + + let received = 0; + const receivedAll = Promise.withResolvers(); + readerSocket.on("data", (chunk: Buffer) => { + if ((received += chunk.length) >= total) receivedAll.resolve(); + }); + readerSocket.resume(); + await Promise.race([receivedAll.promise, failed]); + expect(received).toBe(total); + }); + + it("a slow reader gets everything while the transport is paused and resumed under it", async () => { + using pair = await securePair(reader); + const { readerSocket, transport, writerSocket, failed } = pair; + let pauses = 0; + transport.on("pause", () => pauses++); + await Promise.race([writePieces(writerSocket), failed]); + + const read = (async () => { + let received = 0; + let mostHeld = 0; + for await (const chunk of readerSocket) { + received += chunk.length; + mostHeld = Math.max(mostHeld, readerSocket.readableLength); + if (received >= total) break; + } + return { received, heldLessThanHalfAtAnyTime: mostHeld < total / 2 }; + })(); + expect({ ...(await Promise.race([read, failed])), pausedMoreThanOnce: pauses > 1 }).toEqual({ + received: total, + heldLessThanHalfAtAnyTime: true, + pausedMoreThanOnce: true, + }); + }); + + it("a destroy() from the transport's 'pause' listener closes the socket", async () => { + using pair = await securePair(reader); + const { readerSocket, transport, writerSocket, failed } = pair; + readerSocket.pause(); + let destroyedFromPause = false; + transport.once("pause", () => { + destroyedFromPause = true; + readerSocket.destroy(); + }); + const closed = once(readerSocket, "close"); + await Promise.race([writePieces(writerSocket), failed]); + expect(destroyedFromPause).toBe(true); + await Promise.race([closed, failed]); + }); + }); + + it("does not answer a close_notify that it reads after the transport's EOF", async () => { + // A net.Socket that has read its peer's FIN fails a later write with EPIPE, and so does this + // transport. A paused reader makes the engine read the close_notify long after that EOF, and + // the transport's 'end' event, held back by the pause, comes later still. + let writesAfterEOF = 0; + const transports = inMemoryPair(() => { + writesAfterEOF++; + return Object.assign(new Error("write after the peer's FIN"), { code: "EPIPE" }); + }); + using pair = await securePair("client", transports); + const { readerSocket: client, writerSocket: server, failed } = pair; + client.pause(); + // Like a TLS server over TCP: the payload, the close_notify, then the FIN. + await Promise.race([writePieces(server, true), failed]); + transports.serverSide.end(); + + let received = 0; + client.on("data", (chunk: Buffer) => (received += chunk.length)); + const ended = once(client, "end"); + client.resume(); + await Promise.race([ended, failed]); + expect({ received, writesAfterEOF }).toEqual({ received: total, writesAfterEOF: 0 }); + }); + + it("a pause() issued before the engine exists does not stall the handshake", async () => { + const { clientSide, serverSide } = inMemoryPair(); + const server = new TLSSocket(serverSide, serverOptions()); + const client = tls.connect({ + socket: clientSide, + rejectUnauthorized: false, + // Only an onread socket stops its handle from pause(). + onread: { buffer: Buffer.alloc(64), callback: () => {} }, + }); + using _ = destroyedOnExit([client, server]); + client.pause(); + await Promise.race([ + Promise.all([once(server, "secure"), once(client, "secureConnect")]), + firstErrorOf(server, client), + ]); + expect(client.getProtocol()).toBe("TLSv1.3"); + }); + + it("destroying a paused server wrap over a net.Socket with unflushed writes closes that net.Socket", async () => { + // Such a wrap cannot take the fd over, so it reads the net.Socket like any other Duplex. + const sockets: { destroy(): unknown }[] = []; + using _ = destroyedOnExit(sockets); + const accepted = Promise.withResolvers<{ transport: net.Socket; wrapped: TLSSocket }>(); + await using listener = net.createServer(transport => { + transport.cork(); + transport.write("!"); + const wrapped = new TLSSocket(transport, serverOptions()); + transport.uncork(); + sockets.push(wrapped, transport); + accepted.resolve({ transport, wrapped }); + }); + await once(listener.listen(0, "127.0.0.1"), "listening"); + const clientTransport = net.connect((listener.address() as AddressInfo).port, "127.0.0.1"); + sockets.push(clientTransport); + const [{ transport, wrapped }] = await Promise.all([accepted.promise, once(clientTransport, "data")]); + const client = tls.connect({ socket: clientTransport, rejectUnauthorized: false }); + sockets.push(client); + // node resets the connection under the client. + client.on("error", () => {}); + const failed = firstErrorOf(wrapped); + await Promise.race([Promise.all([once(wrapped, "secure"), once(client, "secureConnect")]), failed]); + + wrapped.pause(); + for (let i = 0; i < pieces; i++) client.write(piece); + // A full buffer is what stops the reads. + while (wrapped.readableLength < wrapped.readableHighWaterMark) { + await Promise.race([new Promise(resolve => setImmediate(resolve)), failed]); + } + const closed = once(transport, "close"); + wrapped.destroy(); + await closed; + expect(transport.destroyed).toBe(true); + }); +}); + it("the last 'data' event fires before the close_notify reply is written to a duplex transport (tls.connect({ socket }))", async () => { // The peer's last application data and its close_notify reach the engine in // one chunk. The engine used to answer the close_notify before it emitted From 33e5f04e7814fab4f3cd44d214b32aa75dd897a7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:09:06 +0000 Subject: [PATCH 017/174] node:https: https.Server always speaks TLS (#41672, #33539) https.Server was http.Server, which decides between TLS and cleartext by looking for key/cert/ca/pfx in its options. https.createServer() with none of them (or with undefined values) therefore answered plain HTTP on a port the caller believes is encrypted. https.Server is now its own class and marks its prototype, so the listener is TLS whatever the options hold: with no certificate every handshake fails, as in Node, and a cleartext request gets no response. The ALPN defaults move from createServer() into the constructor so new https.Server() gets them too. http.createServer({ key, cert }) keeps serving TLS. --- src/js/node/_http_server.ts | 13 +-- src/js/node/https.ts | 25 +++-- test/js/node/http/node-https-server.test.ts | 114 ++++++++++++++++++++ 3 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 test/js/node/http/node-https-server.test.ts diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 3523d8c90fff..2e48f2a9f7a9 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -331,26 +331,26 @@ function Server(options, callback): void { } else { validateObject(options, "options"); options = { ...options }; - const tlsHelpers = options.pfx || options.cert || options.key || options.ca ? require("internal/tls") : undefined; + const tlsHelpers = + this[isTlsSymbol] || options.pfx || options.cert || options.key || options.ca + ? require("internal/tls") + : undefined; // Node's https.Server accepts PKCS#12 bundles (pfx [+ passphrase]); fold // them into plain key/cert/ca so the native TLS config sees PEM material. let tlsOptions = options; if (options.pfx) { tlsOptions = tlsHelpers.processPfxOptions(options); - this[isTlsSymbol] = true; } let cert = tlsOptions.cert; if (cert) { tlsHelpers.throwOnInvalidTLSArray("options.cert", cert); - this[isTlsSymbol] = true; } let key = tlsOptions.key; if (key) { tlsHelpers.throwOnInvalidTLSArray("options.key", key); - this[isTlsSymbol] = true; } let ca = tlsOptions.ca; @@ -363,7 +363,6 @@ function Server(options, callback): void { } if (ca) { tlsHelpers.throwOnInvalidTLSArray("options.ca", ca); - this[isTlsSymbol] = true; } let passphrase = options.passphrase; @@ -381,7 +380,7 @@ function Server(options, callback): void { throw $ERR_INVALID_ARG_TYPE("options.secureOptions", "number", secureOptions); } - if (this[isTlsSymbol]) { + if (tlsHelpers) { const { validateSecureProtocol, secureProtocolToVersionRange, tlsStringToProtocolVersion } = tlsHelpers; // Translate secureProtocol, or else minVersion/maxVersion, into the // integer protocol range the native layer applies; 0 keeps the native @@ -412,8 +411,6 @@ function Server(options, callback): void { requestCert: options.requestCert, rejectUnauthorized: options.rejectUnauthorized, }); - } else { - this[tlsSymbol] = null; } } diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 6f0fbf8ebb6f..a2b4bd7bbfc1 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -11,6 +11,7 @@ const { checkShouldUseProxy, kWaitForProxyTunnel, kPerRequestCheckServerIdentity, + isTlsSymbol, } = require("internal/http"); const { validateHeaderValue } = require("node:_http_common"); @@ -514,12 +515,9 @@ Agent.prototype._evictSession = function _evictSession(key) { const { shouldUseEnvProxy } = require("node:_http_agent"); -// Like Node's https.Server constructor: default ALPNProtocols to ['http/1.1'] -// when neither ALPNProtocols nor ALPNCallback was given, and store the -// normalized protocol list / callback on the server instance the way -// tls.Server does (test-https-argument-of-creating.js). // https://github.com/nodejs/node/blob/v26.3.0/lib/https.js#L82-L97 -function createServer(options, requestListener) { +function Server(options, requestListener): void { + if (!(this instanceof Server)) return new Server(options, requestListener); if (typeof options === "function") { requestListener = options; options = {}; @@ -534,13 +532,20 @@ function createServer(options, requestListener) { // ALPN requests are always answered with http/1.1. options.ALPNProtocols = ["http/1.1"]; } - const server = http.createServer(options, requestListener); + http.Server.$call(this, options, requestListener); const optionsALPNProtocols = options.ALPNProtocols; if (optionsALPNProtocols) { - require("node:tls").convertALPNProtocols(optionsALPNProtocols, server); + require("node:tls").convertALPNProtocols(optionsALPNProtocols, this); } - server.ALPNCallback = options.ALPNCallback; - return server; + this.ALPNCallback = options.ALPNCallback; + return this; +} +$toClass(Server, "Server", http.Server); +// A listener of this class speaks TLS whatever its options hold. +Server.prototype[isTlsSymbol] = true; + +function createServer(options, requestListener) { + return new Server(options, requestListener); } var https = { @@ -551,7 +556,7 @@ var https = { timeout: 5000, proxyEnv: shouldUseEnvProxy() ? process.env : undefined, }), - Server: http.Server, + Server, createServer, get, request, diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts new file mode 100644 index 000000000000..5530c66fee5a --- /dev/null +++ b/test/js/node/http/node-https-server.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { tls as validCert } from "harness"; +import http from "node:http"; +import https from "node:https"; +import type { AddressInfo } from "node:net"; +import net from "node:net"; +import tls from "node:tls"; + +function listen(server: http.Server): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + return promise; +} + +// Resolves with everything the server wrote back to a bare HTTP/1.1 request. +function plaintextRequest(port: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + let received = ""; + const socket = net.connect(port, "127.0.0.1", () => { + socket.write("GET /secret HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + }); + socket.on("data", chunk => (received += chunk.toString("latin1"))); + socket.on("error", () => {}); + socket.on("close", () => resolve(received)); + return promise; +} + +function tlsRequest(port: number): Promise { + const { promise, resolve } = Promise.withResolvers(); + let received = ""; + const socket = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + socket.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + }); + socket.on("data", chunk => (received += chunk)); + socket.on("error", (err: NodeJS.ErrnoException) => resolve(String(err.code))); + socket.on("close", () => resolve(received)); + return promise; +} + +describe.concurrent("https.Server with no usable key and cert", () => { + test.each<[string, (handler: http.RequestListener) => http.Server]>([ + ["createServer(requestListener)", handler => https.createServer(handler)], + ["createServer({}, requestListener)", handler => https.createServer({}, handler)], + ["createServer(undefined, requestListener)", handler => https.createServer(undefined, handler)], + ["createServer({ key: undefined, cert: undefined })", handler => https.createServer({ key: undefined, cert: undefined }, handler)], // prettier-ignore + ["createServer({ requestCert: true })", handler => https.createServer({ requestCert: true }, handler)], + ["new https.Server({}, requestListener)", handler => new https.Server({}, handler)], + ["https.Server(requestListener)", handler => (https.Server as any)(handler)], + ])("%s is a TLS listener that refuses every connection", async (_label, createServer) => { + let requests = 0; + await using server = createServer((_req, res) => { + requests++; + res.end("SECRET"); + }); + const port = await listen(server); + + const cleartext = await plaintextRequest(port); + expect(cleartext).not.toContain("HTTP/"); + expect(cleartext).not.toContain("SECRET"); + // A cleartext listener yields ERR_SSL_WRONG_VERSION_NUMBER or ECONNRESET here. + expect(await tlsRequest(port)).toContain("_ALERT_"); + expect(requests).toBe(0); + }); +}); + +test("https.Server is its own class and still serves TLS only when it has a key and cert", async () => { + expect(https.Server).not.toBe(http.Server); + expect(http.createServer()).not.toBeInstanceOf(https.Server); + await using server = https.createServer({ ...validCert }, (req, res) => res.end(`encrypted=${(req.socket as tls.TLSSocket).encrypted}`)); // prettier-ignore + expect(server).toBeInstanceOf(https.Server); + const port = await listen(server); + + expect(await plaintextRequest(port)).not.toContain("HTTP/"); + expect(await tlsRequest(port)).toEndWith("encrypted=true"); +}); + +// Bun only: Node's http.Server ignores key and cert. Deployments rely on this, it must never become cleartext. +test.skipIf(!process.versions.bun).each<[string, () => object]>([ + ["key and cert", () => ({ ...validCert })], + ["key, cert and ca", () => ({ ...validCert, ca: validCert.cert })], +])("http.createServer() with %s serves TLS and never cleartext", async (_label, options) => { + await using server = http.createServer(options(), (req, res) => res.end(`encrypted=${(req.socket as tls.TLSSocket).encrypted}`)); // prettier-ignore + const port = await listen(server); + + expect(await plaintextRequest(port)).not.toContain("HTTP/"); + expect(await tlsRequest(port)).toEndWith("encrypted=true"); +}); + +describe("new https.Server() applies the ALPN defaults of createServer()", () => { + function wire(protocols: string[]) { + const out = {} as { ALPNProtocols: Buffer }; + tls.convertALPNProtocols(protocols, out); + return out.ALPNProtocols; + } + + test("defaults ALPNProtocols to http/1.1", () => { + const server = new https.Server() as https.Server & { ALPNProtocols?: Buffer; ALPNCallback?: unknown }; + expect(server.ALPNProtocols).toEqual(wire(["http/1.1"])); + expect(server.ALPNCallback).toBeUndefined(); + }); + + test("keeps an explicit ALPNProtocols", () => { + const server = new https.Server({ ALPNProtocols: ["h2", "http/1.1"] }) as https.Server & { ALPNProtocols?: Buffer }; + expect(server.ALPNProtocols).toEqual(wire(["h2", "http/1.1"])); + }); + + test("stores ALPNCallback and skips the default protocol list", () => { + const ALPNCallback = () => "http/1.1"; + const server = new https.Server({ ALPNCallback }) as https.Server & { ALPNProtocols?: Buffer; ALPNCallback?: unknown }; // prettier-ignore + expect(server.ALPNCallback).toBe(ALPNCallback); + expect(server.ALPNProtocols).toBeUndefined(); + }); +}); From 186a181751e32750da35465ff9b613eb2eed210f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:11:54 +0000 Subject: [PATCH 018/174] node:https: forward crl, sessionTimeout, ecdhCurve, sigalgs and honorCipherOrder to the listener (#41641) The https server copies its TLS options into the native config through a hand-written list that lacked these keys, so they were dropped without an error. The worst case is crl: an mTLS server served a revoked client certificate with socket.authorized === true. Forward them, default honorCipherOrder to true and ecdhCurve to tls.DEFAULT_ECDH_CURVE like tls.Server, and run the validation tls.createServer runs. validateSecureContextOptions moves verbatim from node:tls to internal/tls so both modules share one copy. --- src/js/internal/tls.ts | 129 +++++++++++++++++++- src/js/node/_http_server.ts | 23 +++- src/js/node/tls.ts | 126 +------------------ test/js/node/http/node-https-server.test.ts | 128 +++++++++++++++++++ 4 files changed, 276 insertions(+), 130 deletions(-) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index d87fb5f7657e..f1f60d55ddb8 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -1,4 +1,7 @@ const { isTypedArray, isArrayBuffer } = require("node:util/types"); +const { validateString, validateBuffer } = require("internal/validators"); + +const StringPrototypeSplit = String.prototype.split; function isPemObject(obj: unknown): obj is { pem: unknown } { return $isObject(obj) && "pem" in obj; @@ -142,6 +145,129 @@ function secureProtocolToVersionRange(secureProtocol) { return null; } +const VALID_TLS_VERSIONS = new Set(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"]); + +const SUPPORTED_ECDH_GROUPS = new Set([ + "P-256", + "prime256v1", + "P-384", + "secp384r1", + "P-521", + "secp521r1", + "X25519", + "x25519", + "X25519MLKEM768", + "MLKEM1024", +]); + +// Subset of Node's configSecureContext() validations: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L318 +function validateSecureContextOptions(options) { + const { + ciphers, + key, + passphrase, + ecdhCurve, + minVersion, + maxVersion, + privateKeyIdentifier, + privateKeyEngine, + sessionTimeout, + sigalgs, + ticketKeys, + clientCertEngine, + dhparam, + secureProtocol, + } = options; + validateSecureProtocol(secureProtocol, minVersion, maxVersion); + if (ciphers !== undefined && ciphers !== null) validateString(ciphers, "options.ciphers"); + if (passphrase !== undefined && passphrase !== null) validateString(passphrase, "options.passphrase"); + if (sigalgs !== undefined && sigalgs !== null) { + validateString(sigalgs, "options.sigalgs"); + if (sigalgs === "") throw $ERR_INVALID_ARG_VALUE("options.sigalgs", sigalgs); + } + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/secure-context.js#L222 (BoringSSL has no ENGINE support) + if (privateKeyIdentifier !== undefined && privateKeyIdentifier !== null) { + if (privateKeyEngine === undefined || privateKeyEngine === null) { + throw $ERR_INVALID_ARG_VALUE("options.privateKeyEngine", privateKeyEngine); + } + if (key) { + throw $ERR_INVALID_ARG_VALUE("options.privateKeyIdentifier", privateKeyIdentifier); + } + if (typeof privateKeyIdentifier !== "string") { + throw $ERR_INVALID_ARG_TYPE( + "options.privateKeyIdentifier", + ["string", "null", "undefined"], + privateKeyIdentifier, + ); + } + if (typeof privateKeyEngine !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.privateKeyEngine", ["string", "null", "undefined"], privateKeyEngine); + } + throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); + } + if (ecdhCurve !== undefined) { + validateString(ecdhCurve, "options.ecdhCurve"); + if (ecdhCurve !== "auto") { + for (const curve of StringPrototypeSplit.$call(ecdhCurve, ":")) { + if (!SUPPORTED_ECDH_GROUPS.has(curve)) { + // Not $ERR_*: Node's THROW_ERR_CRYPTO_OPERATION_FAILED has no bracketed + // toString; test-tls-ecdh-multiple.js pins /Error: Failed to set ECDH curve/. + const err = new Error("Failed to set ECDH curve") as Error & { code: string }; + err.code = "ERR_CRYPTO_OPERATION_FAILED"; + throw err; + } + } + } + } + // clientCertEngine must be a string (engine name); a provided engine then + // fails because BoringSSL (which Bun always uses) has no OpenSSL ENGINE + // support, matching Node's setClientCertEngine. Node: + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L296 + if (clientCertEngine !== undefined && clientCertEngine !== null) { + if (typeof clientCertEngine !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.clientCertEngine", ["string", "null", "undefined"], clientCertEngine); + } + throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); + } + // BoringSSL (always used by Bun) has no automatic DH parameter selection. + // Matches Node's setDHParam('auto') throwing ERR_CRYPTO_UNSUPPORTED_OPERATION. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L254 + if (dhparam === "auto") { + throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); + } + if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(minVersion), "minimum"); + if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(maxVersion), "maximum"); + if (ticketKeys !== undefined && ticketKeys !== null) { + validateBuffer(ticketKeys, "options.ticketKeys"); + const ticketKeysByteLength = ticketKeys.byteLength; + if (ticketKeysByteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("options.ticketKeys", ticketKeysByteLength, "must be exactly 48 bytes"); + } + } + // Negative session timeouts are rejected (min 0), matching Node — newer + // OpenSSL/BoringSSL do not handle negative values as users expect. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L319 + if (sessionTimeout !== undefined && sessionTimeout !== null) { + // Node validates this with validateInt32(..., 0), whose range message + // reads ">= 0 && <= 2147483647"; the shared validator here words it + // differently, so spell the check out to match. + if (typeof sessionTimeout !== "number") { + throw $ERR_INVALID_ARG_TYPE("options.sessionTimeout", "number", sessionTimeout); + } + if (!Number.isInteger(sessionTimeout)) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", "an integer", sessionTimeout); + } + if (sessionTimeout < 0 || sessionTimeout > 2147483647) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", ">= 0 && <= 2147483647", sessionTimeout); + } + } +} + +const SSL_OP_CIPHER_SERVER_PREFERENCE = 0x00400000; + let NativeSecureContext; /** @@ -186,9 +312,10 @@ function processPfxOptions(options) { } export { + SSL_OP_CIPHER_SERVER_PREFERENCE, processPfxOptions, secureProtocolToVersionRange, throwOnInvalidTLSArray, tlsStringToProtocolVersion, - validateSecureProtocol, + validateSecureContextOptions, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 2e48f2a9f7a9..61dcb2d4a12c 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -381,14 +381,23 @@ function Server(options, callback): void { } if (tlsHelpers) { - const { validateSecureProtocol, secureProtocolToVersionRange, tlsStringToProtocolVersion } = tlsHelpers; - // Translate secureProtocol, or else minVersion/maxVersion, into the - // integer protocol range the native layer applies; 0 keeps the native - // defaults. A falsy value counts as absent, like in tls.Server. + const { secureProtocolToVersionRange, tlsStringToProtocolVersion } = tlsHelpers; + // A falsy value counts as absent, like in tls.Server. const secureProtocol = options.secureProtocol || undefined; const minVersionOption = options.minVersion || undefined; const maxVersionOption = options.maxVersion || undefined; - validateSecureProtocol(secureProtocol, minVersionOption, maxVersionOption); + tlsHelpers.validateSecureContextOptions({ + ...options, + secureProtocol, + minVersion: minVersionOption, + maxVersion: maxVersionOption, + }); + const crl = options.crl; + if (crl) { + tlsHelpers.throwOnInvalidTLSArray("options.crl", crl); + } + if (options.honorCipherOrder !== false) secureOptions |= tlsHelpers.SSL_OP_CIPHER_SERVER_PREFERENCE; + // secureProtocol, or else minVersion/maxVersion, as the integer range the native layer applies; 0 keeps its defaults. let minVersion, maxVersion; const range = secureProtocolToVersionRange(secureProtocol); if (range) { @@ -403,6 +412,10 @@ function Server(options, callback): void { key, cert, ca, + crl, + sessionTimeout: options.sessionTimeout ?? 0, + sigalgs: options.sigalgs, + ecdhCurve: options.ecdhCurve ?? require("node:tls").DEFAULT_ECDH_CURVE, passphrase, secureOptions, minVersion, diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 299346f1c33b..7df108bb357e 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -13,7 +13,8 @@ const { tlsStringToProtocolVersion, secureProtocolToVersionRange, processPfxOptions, - validateSecureProtocol, + validateSecureContextOptions, + SSL_OP_CIPHER_SERVER_PREFERENCE, } = require("internal/tls"); const { validateString, @@ -197,127 +198,6 @@ function validateCiphers(ciphers: string, name: string = "options") { } } -const VALID_TLS_VERSIONS = new Set(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"]); - -const SUPPORTED_ECDH_GROUPS = new Set([ - "P-256", - "prime256v1", - "P-384", - "secp384r1", - "P-521", - "secp521r1", - "X25519", - "x25519", - "X25519MLKEM768", - "MLKEM1024", -]); - -// Subset of Node's configSecureContext() validations: -// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L318 -function validateSecureContextOptions(options) { - const { - ciphers, - key, - passphrase, - ecdhCurve, - minVersion, - maxVersion, - privateKeyIdentifier, - privateKeyEngine, - sessionTimeout, - sigalgs, - ticketKeys, - clientCertEngine, - dhparam, - secureProtocol, - } = options; - validateSecureProtocol(secureProtocol, minVersion, maxVersion); - if (ciphers !== undefined && ciphers !== null) validateString(ciphers, "options.ciphers"); - if (passphrase !== undefined && passphrase !== null) validateString(passphrase, "options.passphrase"); - if (sigalgs !== undefined && sigalgs !== null) { - validateString(sigalgs, "options.sigalgs"); - if (sigalgs === "") throw $ERR_INVALID_ARG_VALUE("options.sigalgs", sigalgs); - } - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/secure-context.js#L222 (BoringSSL has no ENGINE support) - if (privateKeyIdentifier !== undefined && privateKeyIdentifier !== null) { - if (privateKeyEngine === undefined || privateKeyEngine === null) { - throw $ERR_INVALID_ARG_VALUE("options.privateKeyEngine", privateKeyEngine); - } - if (key) { - throw $ERR_INVALID_ARG_VALUE("options.privateKeyIdentifier", privateKeyIdentifier); - } - if (typeof privateKeyIdentifier !== "string") { - throw $ERR_INVALID_ARG_TYPE( - "options.privateKeyIdentifier", - ["string", "null", "undefined"], - privateKeyIdentifier, - ); - } - if (typeof privateKeyEngine !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.privateKeyEngine", ["string", "null", "undefined"], privateKeyEngine); - } - throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); - } - if (ecdhCurve !== undefined) { - validateString(ecdhCurve, "options.ecdhCurve"); - if (ecdhCurve !== "auto") { - for (const curve of StringPrototypeSplit.$call(ecdhCurve, ":")) { - if (!SUPPORTED_ECDH_GROUPS.has(curve)) { - // Not $ERR_*: Node's THROW_ERR_CRYPTO_OPERATION_FAILED has no bracketed - // toString; test-tls-ecdh-multiple.js pins /Error: Failed to set ECDH curve/. - const err = new Error("Failed to set ECDH curve") as Error & { code: string }; - err.code = "ERR_CRYPTO_OPERATION_FAILED"; - throw err; - } - } - } - } - // clientCertEngine must be a string (engine name); a provided engine then - // fails because BoringSSL (which Bun always uses) has no OpenSSL ENGINE - // support, matching Node's setClientCertEngine. Node: - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L296 - if (clientCertEngine !== undefined && clientCertEngine !== null) { - if (typeof clientCertEngine !== "string") { - throw $ERR_INVALID_ARG_TYPE("options.clientCertEngine", ["string", "null", "undefined"], clientCertEngine); - } - throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); - } - // BoringSSL (always used by Bun) has no automatic DH parameter selection. - // Matches Node's setDHParam('auto') throwing ERR_CRYPTO_UNSUPPORTED_OPERATION. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L254 - if (dhparam === "auto") { - throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); - } - if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(minVersion), "minimum"); - if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) - throw $ERR_TLS_INVALID_PROTOCOL_VERSION(JSON.stringify(maxVersion), "maximum"); - if (ticketKeys !== undefined && ticketKeys !== null) { - validateBuffer(ticketKeys, "options.ticketKeys"); - const ticketKeysByteLength = ticketKeys.byteLength; - if (ticketKeysByteLength !== 48) { - throw $ERR_INVALID_ARG_VALUE("options.ticketKeys", ticketKeysByteLength, "must be exactly 48 bytes"); - } - } - // Negative session timeouts are rejected (min 0), matching Node — newer - // OpenSSL/BoringSSL do not handle negative values as users expect. - // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L319 - if (sessionTimeout !== undefined && sessionTimeout !== null) { - // Node validates this with validateInt32(..., 0), whose range message - // reads ">= 0 && <= 2147483647"; the shared validator here words it - // differently, so spell the check out to match. - if (typeof sessionTimeout !== "number") { - throw $ERR_INVALID_ARG_TYPE("options.sessionTimeout", "number", sessionTimeout); - } - if (!Number.isInteger(sessionTimeout)) { - throw $ERR_OUT_OF_RANGE("options.sessionTimeout", "an integer", sessionTimeout); - } - if (sessionTimeout < 0 || sessionTimeout > 2147483647) { - throw $ERR_OUT_OF_RANGE("options.sessionTimeout", ">= 0 && <= 2147483647", sessionTimeout); - } - } -} - const SymbolReplace = Symbol.replace; const RegExpPrototypeSymbolReplace = RegExp.prototype[SymbolReplace]; const RegExpPrototypeExec = RegExp.prototype.exec; @@ -587,8 +467,6 @@ function normalizePemKeyOption(key, ctxPassphrase) { }); } -const SSL_OP_CIPHER_SERVER_PREFERENCE = 0x00400000; - // SNI is a C string, so as in Node.js it ends at a NUL. checkServerIdentity() still gets the whole servername. function sniName(servername) { if (typeof servername !== "string") return servername; diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index 5530c66fee5a..ced0b55d2ad7 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; import { tls as validCert } from "harness"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; import http from "node:http"; import https from "node:https"; import type { AddressInfo } from "node:net"; import net from "node:net"; +import { join } from "node:path"; import tls from "node:tls"; function listen(server: http.Server): Promise { @@ -112,3 +115,128 @@ describe("new https.Server() applies the ALPN defaults of createServer()", () => expect(server.ALPNProtocols).toBeUndefined(); }); }); + +describe("https.createServer forwards every TLS server option", () => { + const read = (name: string) => readFileSync(join(import.meta.dirname, "../test/fixtures/keys", name), "utf8"); + // ca2 signs agent3 and agent4; ca2-crl-agent3.pem revokes agent3 only. + const mtls = { + key: read("agent1-key.pem"), + cert: read("agent1-cert.pem"), + ca: read("ca2-cert.pem"), + crl: read("ca2-crl-agent3.pem"), + requestCert: true, + }; + + function verdict(req: http.IncomingMessage, res: http.ServerResponse) { + const { authorized, authorizationError } = req.socket as tls.TLSSocket; + res.end(`authorized=${authorized} error=${authorizationError}`); + } + + function get(port: number, agent: string, options?: https.RequestOptions) { + const { promise, resolve } = Promise.withResolvers(); + const req = https.get( + { + host: "127.0.0.1", + port, + key: read(`${agent}-key.pem`), + cert: read(`${agent}-cert.pem`), + rejectUnauthorized: false, + agent: false, + ...options, + }, + res => { + let body = ""; + res.on("data", chunk => (body += chunk)).on("end", () => resolve(body)); + }, + ); + req.on("error", () => resolve("refused")); + return promise; + } + + function handshake(port: number, options: tls.ConnectionOptions) { + const { promise, resolve } = Promise.withResolvers(); + const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ...options }, () => { + resolve(client.getCipher().name); + client.destroy(); + }); + client.on("error", (err: NodeJS.ErrnoException) => resolve(String(err.code))); + return promise; + } + + test.each(["TLSv1.2", "TLSv1.3"] as const)("refuses a client certificate the crl revokes on %s", async version => { + let requests = 0; + await using server = https.createServer({ ...mtls, minVersion: version, maxVersion: version }, (req, res) => { + requests++; + verdict(req, res); + }); + const port = await listen(server); + + expect(await get(port, "agent3")).toBe("refused"); + expect(requests).toBe(0); + expect(await get(port, "agent4")).toBe("authorized=true error=null"); + }); + + test("reports CERT_REVOKED on req.socket when rejectUnauthorized is false", async () => { + await using server = https.createServer({ ...mtls, crl: [mtls.crl], rejectUnauthorized: false }, verdict); + const port = await listen(server); + + expect(await get(port, "agent3")).toBe("authorized=false error=CERT_REVOKED"); + expect(await get(port, "agent4")).toBe("authorized=true error=null"); + }); + + test("restricts the key-share groups to ecdhCurve, or to tls.DEFAULT_ECDH_CURVE", async () => { + const saved = tls.DEFAULT_ECDH_CURVE; + await using explicit = https.createServer({ ...validCert, ecdhCurve: "P-384" }); + tls.DEFAULT_ECDH_CURVE = "P-384"; + try { + await using fallback = https.createServer({ ...validCert }); + for (const server of [explicit, fallback]) { + const port = await listen(server); + expect(await handshake(port, { ecdhCurve: "X25519" })).toContain("HANDSHAKE_FAILURE"); + expect(await handshake(port, { ecdhCurve: "P-384" })).toStartWith("TLS_"); + } + } finally { + tls.DEFAULT_ECDH_CURVE = saved; + } + }); + + test("signs the handshake with sigalgs only", async () => { + await using server = https.createServer({ ...validCert, sigalgs: "rsa_pss_rsae_sha512" }); + const port = await listen(server); + + expect(await handshake(port, { sigalgs: "rsa_pss_rsae_sha256" })).toContain("HANDSHAKE_FAILURE"); + expect(await handshake(port, { sigalgs: "rsa_pss_rsae_sha512" })).toStartWith("TLS_"); + }); + + test("honors the server cipher order unless honorCipherOrder is false", async () => { + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + const aes128 = "ECDHE-RSA-AES128-GCM-SHA256"; + async function negotiated(honorCipherOrder: boolean | undefined) { + await using server = https.createServer({ ...validCert, ciphers: `${aes256}:${aes128}`, honorCipherOrder }); + return await handshake(await listen(server), { ciphers: `${aes128}:${aes256}`, maxVersion: "TLSv1.2" }); + } + expect(await negotiated(undefined)).toBe(aes256); + expect(await negotiated(false)).toBe(aes128); + }); + + test("sends sessionTimeout as the ticket lifetime", async () => { + await using server = https.createServer({ ...validCert, sessionTimeout: 1234 }); + const client = tls.connect({ port: await listen(server), host: "127.0.0.1", rejectUnauthorized: false, maxVersion: "TLSv1.2" }); // prettier-ignore + await once(client, "secureConnect"); + const session = client.getSession()!.toString("hex"); + client.destroy(); + // SSL_SESSION ::= ... ticketLifetimeHint [9] INTEGER + expect(session).toContain("a904020204d2"); + }); + + test.each([ + ["ecdhCurve", "not-a-curve", "ERR_CRYPTO_OPERATION_FAILED"], + ["sessionTimeout", -1, "ERR_OUT_OF_RANGE"], + ["sigalgs", "", "ERR_INVALID_ARG_VALUE"], + ["crl", 42, "ERR_INVALID_ARG_TYPE"], + ["minVersion", "TLSv9", "ERR_TLS_INVALID_PROTOCOL_VERSION"], + ])("throws on an invalid %s like tls.createServer", (name, value, code) => { + expect(() => https.createServer({ ...validCert, [name]: value })).toThrow(expect.objectContaining({ code })); + expect(() => https.createServer({ [name]: value })).toThrow(expect.objectContaining({ code })); + }); +}); From 5d2396a2ddf695f2d6b72a58e205efd53add3a74 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:14:12 +0000 Subject: [PATCH 019/174] node:http: enforce server.blockList (#38261) The native listener bypasses net.Server's accept path, so a blockList given to https.createServer() or assigned to server.blockList was never consulted. Check it in onServerConnection next to the maxConnections gate and close a listed peer without an event, as Node does. A check() that throws closes the connection too: the native listener serves any socket that is still open when the callback returns. https.Server validates and stores the option whether or not it has a certificate; http.Server ignores it like Node. --- src/js/node/_http_server.ts | 21 +++ src/js/node/https.ts | 7 + test/js/node/http/node-https-server.test.ts | 135 +++++++++++++++++++- 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 61dcb2d4a12c..e75924fa5c98 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -1,6 +1,7 @@ // Hardcoded module "node:_http_server" const EventEmitter: typeof import("node:events").EventEmitter = require("node:events"); const { Stream } = require("node:stream"); +const { isIP } = require("internal/net/isIP"); const { _checkInvalidHeaderChar: checkInvalidHeaderChar, chunkExpression, @@ -300,6 +301,7 @@ interface Server extends NodeHTTPServer { httpValidation?: "strict" | "relaxed" | "insecure"; requireHostHeader: boolean; httpAllowHalfOpen: boolean; + blockList?: import("node:net").BlockList; } function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); @@ -1276,6 +1278,25 @@ function onServerConnection(this: Server, listenerGeneration, socketHandle) { return; } + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2338-L2346 + const blockList = this.blockList; + if (blockList) { + const remoteAddress = socket.remoteAddress; + const addressType = isIP(remoteAddress); + if (addressType) { + let blocked = true; + try { + blocked = !!blockList.check(remoteAddress, `ipv${addressType}`); + } finally { + if (blocked) { + tracked?.delete(socket); + socket.destroy(); + } + } + if (blocked) return; + } + } + // Node's connectionListener attaches the HTTPParser (socket.parser) before // emitting 'connection'; expose the shim here so listeners see it populated. socket.parser = createServerParserShim(socket); diff --git a/src/js/node/https.ts b/src/js/node/https.ts index a2b4bd7bbfc1..d874845ae07b 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -538,6 +538,13 @@ function Server(options, requestListener): void { require("node:tls").convertALPNProtocols(optionsALPNProtocols, this); } this.ALPNCallback = options.ALPNCallback; + const blockList = options.blockList; + if (blockList) { + if (!$rust("node_net_binding.rs", "BlockList").isBlockList(blockList)) { + throw $ERR_INVALID_ARG_TYPE("options.blockList", "net.BlockList", blockList); + } + this.blockList = blockList; + } return this; } $toClass(Server, "Server", http.Server); diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index ced0b55d2ad7..e77e0636a15b 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { tls as validCert } from "harness"; +import { bunEnv, bunExe, tempDir, tls as validCert } from "harness"; +import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFileSync } from "node:fs"; import http from "node:http"; @@ -240,3 +241,135 @@ describe("https.createServer forwards every TLS server option", () => { expect(() => https.createServer({ [name]: value })).toThrow(expect.objectContaining({ code })); }); }); + +describe("server.blockList", () => { + type ServerWithBlockList = http.Server & { blockList?: net.BlockList }; + + function blockListOf(address: string) { + const blockList = new net.BlockList(); + blockList.addAddress(address); + return blockList; + } + + function get(mod: typeof http | typeof https, options: https.RequestOptions) { + const { promise, resolve } = Promise.withResolvers(); + mod + .get({ host: "127.0.0.1", agent: false, rejectUnauthorized: false, ...options }, res => { + let body = ""; + res.on("data", chunk => (body += chunk)).on("end", () => resolve(body)); + }) + .on("error", (err: NodeJS.ErrnoException) => resolve(String(err.code))); + return promise; + } + + test.each<[string, (options: https.ServerOptions, listener: http.RequestListener) => ServerWithBlockList]>([ + ["https.createServer()", (options, listener) => https.createServer(options, listener)], + ["new https.Server()", (options, listener) => new https.Server(options, listener)], + ])("%s closes a peer on options.blockList without an event", async (_label, construct) => { + const blockList = blockListOf("127.0.0.1"); + const events: string[] = []; + await using server = construct({ ...validCert, blockList }, (_req, res) => { + events.push("request"); + res.end("served"); + }); + for (const event of ["connection", "secureConnection", "drop"]) server.on(event, () => events.push(event)); + expect(server.blockList).toBe(blockList); + const port = await listen(server); + + expect(await get(https, { port })).toBe("ECONNRESET"); + expect(events).toEqual([]); + + server.blockList = blockListOf("10.0.0.1"); + expect(await get(https, { port })).toBe("served"); + expect(events).toEqual(["connection", "secureConnection", "request"]); + }); + + test("https.createServer() rejects a blockList that is not a net.BlockList, with or without a certificate", () => { + const blockList = { check: () => true } as any; + const error = expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }); + expect(() => https.createServer({ ...validCert, blockList })).toThrow(error); + expect(() => https.createServer({ blockList })).toThrow(error); + }); + + test("http.Server ignores the option and enforces an assigned list on the next connection", async () => { + let requests = 0; + function listener(_req: http.IncomingMessage, res: http.ServerResponse) { + requests++; + res.end("served"); + } + await using server: ServerWithBlockList = http.createServer( + { blockList: blockListOf("127.0.0.1") } as any, + listener, + ); + expect(server.blockList).toBeUndefined(); + const port = await listen(server); + expect(await get(http, { port })).toBe("served"); + + server.blockList = blockListOf("127.0.0.1"); + expect(await get(http, { port })).toBe("ECONNRESET"); + expect(requests).toBe(1); + + server.blockList = undefined; + expect(await get(http, { port })).toBe("served"); + + // A unix socket peer has no address to check. + using dir = tempDir("http-blocklist", {}); + const socketPath = join(String(dir), "server.sock"); + await using unix: ServerWithBlockList = http.createServer(listener); + unix.blockList = blockListOf("127.0.0.1"); + await once(unix.listen(socketPath), "listening"); + expect(await get(http, { socketPath })).toBe("served"); + }); + + test("matches an IPv4 rule against the IPv4-mapped peer of a dual-stack listener", async () => { + await using server: ServerWithBlockList = http.createServer((req, res) => res.end(req.socket.remoteAddress)); + await once(server.listen(0, "::"), "listening"); + const { port } = server.address() as AddressInfo; + expect(await get(http, { port })).toBe("::ffff:127.0.0.1"); + + server.blockList = blockListOf("127.0.0.1"); + expect(await get(http, { port })).toBe("ECONNRESET"); + }); + + // Node never serves the peer either, but leaks the accepted handle instead of closing it. + test.skipIf(!process.versions.bun)("closes the connection when check() throws", async () => { + const child = spawn( + bunExe(), + [ + "-e", + ` + const errors = []; + process.on("uncaughtException", err => errors.push(err.message)); + async function run(name, options) { + const mod = require(name); + let requests = 0; + const server = mod.createServer(options, (req, res) => { requests++; res.end("served"); }); + server.blockList = { check() { throw new Error(name + " check failed"); } }; + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const outcome = await new Promise(resolve => + mod.get({ host: "127.0.0.1", port: server.address().port, agent: false, rejectUnauthorized: false }, res => resolve(res.statusCode)) + .on("error", err => resolve(err.code))); + server.close(); + return { outcome, requests }; + } + (async () => { + const results = [await run("node:http", {}), await run("node:https", JSON.parse(process.env.TEST_TLS))]; + console.log(JSON.stringify({ results, errors })); + })(); + `, + ], + { env: { ...bunEnv, TEST_TLS: JSON.stringify(validCert) }, stdio: ["ignore", "pipe", "inherit"] }, + ); + let stdout = ""; + child.stdout.on("data", chunk => (stdout += chunk)); + const [exitCode] = await once(child, "exit"); + expect(JSON.parse(stdout)).toEqual({ + results: [ + { outcome: "ECONNRESET", requests: 0 }, + { outcome: "ECONNRESET", requests: 0 }, + ], + errors: ["node:http check failed", "node:https check failed"], + }); + expect(exitCode).toBe(0); + }); +}); From d130e1f949d61c3bd15d14bdfbee989fb276f374 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:19:11 +0000 Subject: [PATCH 020/174] node:https: name the Agent pool by what every TLS option holds (#42498) Agent#getName() is the key of the socket pool and of the TLS session cache. It concatenates the options, so any value that stringifies to "[object ...]" (pfx: [{ buf, passphrase }], an ArrayBuffer, a BunFile) gave every request the same name, and certFile/keyFile/caFile, allowPartialTrustChain and secureContext were not part of it at all. A request with one client certificate or CA was then sent on the connection, or resumed the session, that another one authenticated. Key such values by their bytes, or by identity when there are none to read, port Node's getPfxAgentKey() (nodejs/node 9f03017f38), and append the missing options only when they are set so every name Node produces stays the same. --- src/js/node/https.ts | 69 ++++- .../http/node-https-agent-getname.test.ts | 288 ++++++++++++++++++ .../test/parallel/test-https-agent-getname.js | 44 +++ 3 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 test/js/node/http/node-https-agent-getname.test.ts diff --git a/src/js/node/https.ts b/src/js/node/https.ts index d874845ae07b..7300542b12d2 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -14,11 +14,16 @@ const { isTlsSymbol, } = require("internal/http"); const { validateHeaderValue } = require("node:_http_common"); +const { isAnyArrayBuffer, isDataView } = require("node:util/types"); const ArrayPrototypeShift = Array.prototype.shift; +const ArrayPrototypeJoin = Array.prototype.join; +const ArrayPrototypeMap = Array.prototype.map; const ObjectAssign = Object.assign; const ArrayPrototypeUnshift = Array.prototype.unshift; const JSONStringify = JSON.stringify; +const WeakMapPrototypeGet = WeakMap.prototype.get; +const WeakMapPrototypeSet = WeakMap.prototype.set; type HttpsRequestOptions = import("node:https").RequestOptions & { _defaultAgent?: import("node:https").Agent }; @@ -383,6 +388,45 @@ function Agent(options): void { $toClass(Agent, "Agent", http.Agent); Agent.prototype.createConnection = createConnection; +// `"" + value` is "[object ...]" for every value but a string, a Buffer, a TypedArray or an array of those. +const poolKeyObjectIds = new WeakMap(); +let poolKeyObjectCount = 0; +function poolKeyPart(value: unknown): unknown { + if (typeof value !== "object" || value === null || $isTypedArrayView(value)) return value; + if ($isArray(value)) return ArrayPrototypeJoin.$call(ArrayPrototypeMap.$call(value, poolKeyPart), ","); + if (isAnyArrayBuffer(value)) return Buffer.from(value as ArrayBufferLike); + if (isDataView(value)) { + const { buffer, byteOffset, byteLength } = value as DataView; + return Buffer.from(buffer, byteOffset, byteLength); + } + // A key entry. Its passphrase only decrypts the pem, so it stays out of the name. + const { pem } = value as { pem?: unknown }; + if (pem != null) return poolKeyPart(pem); + // Any other object (a Blob, a BunFile) has no contents to read here: key it by identity. + let id = WeakMapPrototypeGet.$call(poolKeyObjectIds, value); + if (id === undefined) WeakMapPrototypeSet.$call(poolKeyObjectIds, value, (id = ++poolKeyObjectCount)); + return `[object #${id}]`; +} + +// getPfxAgentKey() of https://github.com/nodejs/node/commit/9f03017f38 (CVE-2026-56850) +type PfxEntry = { buf?: unknown; passphrase?: unknown } | null | undefined; +function pfxPoolKey(pfx: unknown, passphrase: unknown) { + let entries: unknown[]; + if ($isArray(pfx)) entries = pfx; + // Bun also takes one { buf, passphrase } entry outside an array. + else if ($isObject(pfx) && !$isTypedArrayView(pfx) && (pfx as PfxEntry)!.buf !== undefined) entries = [pfx]; + else return poolKeyPart(pfx); + + let key = ""; + for (let i = 0; i < entries.length; i++) { + const value = entries[i] as PfxEntry; + const raw = value?.buf || value; + const pass = value?.passphrase || passphrase; + key += `:${poolKeyPart(raw)}:${pass}`; + } + return key; +} + /** * Gets a unique name for a set of options. */ @@ -396,6 +440,7 @@ Agent.prototype.getName = function getName(options = kEmptyObject) { ciphers, key, pfx, + passphrase, rejectUnauthorized, servername, host, @@ -411,13 +456,18 @@ Agent.prototype.getName = function getName(options = kEmptyObject) { sigalgs, privateKeyIdentifier, privateKeyEngine, + certFile, + keyFile, + caFile, + allowPartialTrustChain, + secureContext, } = options; name += ":"; - if (ca) name += ca; + if (ca) name += poolKeyPart(ca); name += ":"; - if (cert) name += cert; + if (cert) name += poolKeyPart(cert); name += ":"; if (clientCertEngine) name += clientCertEngine; @@ -426,10 +476,10 @@ Agent.prototype.getName = function getName(options = kEmptyObject) { if (ciphers) name += ciphers; name += ":"; - if (key) name += key; + if (key) name += poolKeyPart(key); name += ":"; - if (pfx) name += pfx; + if (pfx) name += pfxPoolKey(pfx, passphrase); name += ":"; if (rejectUnauthorized !== undefined) name += rejectUnauthorized; @@ -447,7 +497,7 @@ Agent.prototype.getName = function getName(options = kEmptyObject) { if (secureProtocol) name += secureProtocol; name += ":"; - if (crl) name += crl; + if (crl) name += poolKeyPart(crl); name += ":"; if (honorCipherOrder !== undefined) name += honorCipherOrder; @@ -456,7 +506,7 @@ Agent.prototype.getName = function getName(options = kEmptyObject) { if (ecdhCurve) name += ecdhCurve; name += ":"; - if (dhparam) name += dhparam; + if (dhparam) name += poolKeyPart(dhparam); name += ":"; if (secureOptions !== undefined) name += secureOptions; @@ -473,6 +523,13 @@ Agent.prototype.getName = function getName(options = kEmptyObject) { name += ":"; if (privateKeyEngine) name += privateKeyEngine; + // Node's name has no part for these, so a name without them stays Node's name. + if (certFile) name += `:certFile=${JSONStringify(certFile)}`; + if (keyFile) name += `:keyFile=${JSONStringify(keyFile)}`; + if (caFile) name += `:caFile=${JSONStringify(caFile)}`; + if (allowPartialTrustChain) name += ":allowPartialTrustChain"; + if (secureContext) name += `:secureContext=${poolKeyPart(secureContext)}`; + const perRequestCheckServerIdentity = options[kPerRequestCheckServerIdentity]; if (perRequestCheckServerIdentity) name += `:${perRequestCheckServerIdentity}`; diff --git a/test/js/node/http/node-https-agent-getname.test.ts b/test/js/node/http/node-https-agent-getname.test.ts new file mode 100644 index 000000000000..13553bb541c8 --- /dev/null +++ b/test/js/node/http/node-https-agent-getname.test.ts @@ -0,0 +1,288 @@ +/** + * Runs in Bun and in Node.js, where the cases that need Bun-only values or a Node.js release with the fix are skipped. + * + * https.Agent#getName() names the socket pool and the TLS session cache, so two requests whose options make a + * different connection must get different names, whatever form the options take. Node fixed the + * `pfx: [{ buf, passphrase }]` form in nodejs/node 9f03017f38 (CVE-2026-56850). + */ +import assert from "node:assert"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import https from "node:https"; +import type { AddressInfo } from "node:net"; +import { dirname, join } from "node:path"; +import { describe, test } from "node:test"; +import tls from "node:tls"; +import { fileURLToPath } from "node:url"; + +const isBun = !!process.versions.bun; +// Node ships the fix from v22.23.2, v24.18.1 and v26.5.1. An older Node shares the pool, so +// the pfx cases only describe it from those versions on. Bun always runs them. +const runtimeHasFix = (() => { + if (isBun) return true; + const [major, minor, patch] = process.versions.node.split(".").map(Number); + const atLeast = (fixedMinor: number, fixedPatch: number) => + minor > fixedMinor || (minor === fixedMinor && patch >= fixedPatch); + if (major === 22) return atLeast(23, 2); + if (major === 24) return atLeast(18, 1); + if (major === 26) return atLeast(5, 1); + return major > 26; +})(); +const fixedTest = runtimeHasFix ? test : test.skip; +const bunTest = isBun ? test : test.skip; + +const keys = join(dirname(fileURLToPath(import.meta.url)), "..", "test", "fixtures", "keys"); +const read = (name: string) => readFileSync(join(keys, name)); +const readArrayBuffer = (name: string) => { + const buffer = read(name); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +}; + +// agent1 is CN=agent1, agent10 is CN=agent10.example.com. Both .pfx files use "sample". +type Identity = "agent1" | "agent10"; +type Forms = Record object>; +const commonName = { agent1: "agent1", agent10: "agent10.example.com" }; + +const nodeForms: Forms = { + "pfx: [{ buf, passphrase }]": id => ({ pfx: [{ buf: read(`${id}.pfx`), passphrase: "sample" }] }), +}; +const bunForms: Forms = { + "pfx: { buf, passphrase }": id => ({ pfx: { buf: read(`${id}.pfx`), passphrase: "sample" } }), + "pfx: [ArrayBuffer]": id => ({ pfx: [readArrayBuffer(`${id}.pfx`)], passphrase: "sample" }), + "cert, key: ArrayBuffer": id => ({ + cert: readArrayBuffer(`${id}-cert.pem`), + key: readArrayBuffer(`${id}-key.pem`), + }), + "cert, key: BunFile": id => ({ + cert: Bun.file(join(keys, `${id}-cert.pem`)), + key: Bun.file(join(keys, `${id}-key.pem`)), + }), + "certFile, keyFile": id => ({ certFile: join(keys, `${id}-cert.pem`), keyFile: join(keys, `${id}-key.pem`) }), + // Node v26.5.1 shares the connection. + "secureContext": id => ({ + secureContext: tls.createSecureContext({ cert: read(`${id}-cert.pem`), key: read(`${id}-key.pem`) }), + }), +}; +const forms: Forms = isBun ? { ...nodeForms, ...bunForms } : nodeForms; + +// Answers every request with the CN of the client certificate the connection authenticated with. +async function listen() { + const server = tls.createServer( + { key: read("agent2-key.pem"), cert: read("agent2-cert.pem"), requestCert: true, rejectUnauthorized: false }, + socket => { + socket.on("error", () => {}); + socket.on("data", () => { + const body = String(socket.getPeerCertificate().subject?.CN); + socket.write(`HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n${body}`); + }); + }, + ); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { server, port: (server.address() as AddressInfo).port }; +} + +async function get(agent: https.Agent, port: number, options: object) { + const req = https.get({ host: "127.0.0.1", port, agent, rejectUnauthorized: false, ...options }); + const [res] = await once(req, "response"); + res.setEncoding("utf8"); + let peer = ""; + res.on("data", (chunk: string) => (peer += chunk)); + await once(res, "end"); + return { peer, reusedSocket: req.reusedSocket }; +} + +// One agent per form, all forms at once: { [form]: the answers to `identities` in order }. +async function answersByForm(agentOptions: https.AgentOptions, identities: Identity[], reuseOptions: boolean) { + const { server, port } = await listen(); + try { + const entries = await Promise.all( + Object.entries(forms).map(async ([form, optionsFor]) => { + const agent = new https.Agent(agentOptions); + const cached: Partial> = {}; + try { + const answers: object[] = []; + for (const id of identities) { + const options = reuseOptions ? (cached[id] ??= optionsFor(id)) : optionsFor(id); + answers.push(await get(agent, port, options)); + } + return [form, answers]; + } finally { + agent.destroy(); + } + }), + ); + return Object.fromEntries(entries); + } finally { + server.close(); + } +} +const expectedByForm = (answers: object[]) => Object.fromEntries(Object.keys(forms).map(form => [form, answers])); + +describe("https.Agent keeps client certificates apart", () => { + fixedTest("a pooled socket is not shared across client certificates", async () => { + // The third request passes the first request's option values again, so it pools. + assert.deepStrictEqual( + await answersByForm({ keepAlive: true }, ["agent1", "agent10", "agent1"], true), + expectedByForm([ + { peer: commonName.agent1, reusedSocket: false }, + { peer: commonName.agent10, reusedSocket: false }, + { peer: commonName.agent1, reusedSocket: true }, + ]), + ); + }); + + // Without keepAlive every request opens a connection, but the agent still offers the + // session it cached under the same name, and a resumed session keeps its first identity. + fixedTest("a cached TLS session is not resumed across client certificates", async () => { + assert.deepStrictEqual( + await answersByForm({ keepAlive: false }, ["agent1", "agent10"], false), + expectedByForm([ + { peer: commonName.agent1, reusedSocket: false }, + { peer: commonName.agent10, reusedSocket: false }, + ]), + ); + }); +}); + +// The server sends agent6 and ca3, the intermediate that signed it. ca1 signed ca3, ca2 signed neither. +describe("https.Agent keeps trust settings apart", () => { + const rows: [string, () => object, () => object, string][] = [ + [ + "allowPartialTrustChain", + () => ({ ca: read("ca3-cert.pem"), allowPartialTrustChain: true }), + () => ({ ca: read("ca3-cert.pem") }), + "UNABLE_TO_GET_ISSUER_CERT", + ], + [ + "caFile", + () => ({ caFile: join(keys, "ca1-cert.pem") }), + () => ({ caFile: join(keys, "ca2-cert.pem") }), + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + ], + [ + "secureContext", + () => ({ secureContext: tls.createSecureContext({ ca: read("ca1-cert.pem") }) }), + () => ({ secureContext: tls.createSecureContext({ ca: read("ca2-cert.pem") }) }), + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + ], + ]; + for (const [option, trusting, distrusting, code] of rows) { + for (const keepAlive of [true, false]) { + bunTest(`${option}: the second request is verified again, keepAlive: ${keepAlive}`, async () => { + const server = tls.createServer({ key: read("agent6-key.pem"), cert: read("agent6-cert.pem") }, socket => { + socket.on("error", () => {}); + socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + // On the Agent: a request with its own checkServerIdentity never shares a connection. + const agent = new https.Agent({ keepAlive, checkServerIdentity: () => undefined }); + const outcome = async (options: object) => { + const req = https.get({ host: "127.0.0.1", port: (server.address() as AddressInfo).port, agent, ...options }); + try { + const [res] = await once(req, "response"); + res.resume(); + await once(res, "end"); + return res.statusCode; + } catch (error) { + return (error as NodeJS.ErrnoException).code; + } + }; + try { + assert.deepStrictEqual([await outcome(trusting()), await outcome(distrusting())], [200, code]); + } finally { + agent.destroy(); + server.close(); + } + }); + } + } +}); + +describe("https.Agent#getName", () => { + const agent = new https.Agent(); + const name = (options: object) => agent.getName({ host: "localhost", port: 443, ...options }); + + // The names Node v26.5.1 gives, from getPfxAgentKey(). + fixedTest("a pfx array has Node's name", () => { + assert.strictEqual( + name({ pfx: [Buffer.from("a"), { buf: "b", passphrase: "p" }], passphrase: "q" }), + "localhost:443::::::::a:q:b:p::::::::::::::", + ); + assert.strictEqual( + name({ pfx: [Buffer.from("a"), Buffer.from("b")] }), + "localhost:443::::::::a:undefined:b:undefined::::::::::::::", + ); + assert.strictEqual(name({ pfx: Buffer.from("a"), passphrase: "q" }), "localhost:443:::::::a::::::::::::::"); + }); + + fixedTest("`pfx: [{ buf, passphrase }]` is keyed by buf and passphrase", () => { + const buf = read("agent1.pfx"); + const base = name({ pfx: [{ buf, passphrase: "sample" }] }); + assert.strictEqual(name({ pfx: [{ buf: Buffer.from(buf), passphrase: "sample" }] }), base); + assert.strictEqual(name({ pfx: [{ buf }], passphrase: "sample" }), base); + assert.notStrictEqual(name({ pfx: [{ buf: read("agent10.pfx"), passphrase: "sample" }] }), base); + assert.notStrictEqual(name({ pfx: [{ buf, passphrase: "different" }] }), base); + assert.notStrictEqual( + name({ pfx: [{ __proto__: { buf, passphrase: "sample" } }] }), + name({ pfx: [{ __proto__: { buf: read("agent10.pfx"), passphrase: "sample" } }] }), + ); + }); + + for (const form of Object.keys(bunForms)) { + bunTest(`differs by content or identity, ${form}`, () => { + const agent1 = bunForms[form]("agent1"); + assert.strictEqual(name(agent1), name(agent1)); + assert.notStrictEqual(name(agent1), name(bunForms[form]("agent10"))); + }); + } + + bunTest("`key: [{ pem, passphrase }]` is keyed by pem and not by passphrase", () => { + const pem = read("agent1-key.pem"); + const base = name({ key: pem }); + assert.strictEqual(name({ key: [{ pem, passphrase: "a" }] }), base); + assert.strictEqual(name({ key: [{ pem: readArrayBuffer("agent1-key.pem"), passphrase: "b" }] }), base); + assert.strictEqual(name({ key: { pem } }), base); + assert.notStrictEqual(name({ key: [{ pem: read("agent10-key.pem"), passphrase: "a" }] }), base); + }); + + bunTest("equal bytes share a name across string, Buffer, ArrayBuffer, DataView and array forms", () => { + const cert = read("agent1-cert.pem"); + const base = name({ cert: cert.toString() }); + assert.strictEqual(name({ cert }), base); + assert.strictEqual(name({ cert: readArrayBuffer("agent1-cert.pem") }), base); + assert.strictEqual(name({ cert: [readArrayBuffer("agent1-cert.pem")] }), base); + assert.strictEqual( + name({ pfx: [{ buf: readArrayBuffer("agent1.pfx"), passphrase: "sample" }] }), + name({ pfx: [{ buf: read("agent1.pfx"), passphrase: "sample" }] }), + ); + const pfx = read("agent1.pfx"); + assert.strictEqual(name({ pfx: new DataView(pfx.buffer, pfx.byteOffset, pfx.byteLength) }), name({ pfx })); + // What Node names by contents keeps Node's name. + assert.strictEqual( + name({ ca: ["a", null, undefined, Buffer.from("b")], key: ["k1", new Uint8Array([1, 2])], pfx: "pfx" }), + "localhost:443::a,,,b::::k1,1,2:pfx::::::::::::::", + ); + }); + + bunTest("certFile, keyFile and caFile are labelled parts of the name", () => { + const base = name({}); + assert.strictEqual( + name({ certFile: "a", keyFile: "b", caFile: "c" }), + `${base}:certFile="a":keyFile="b":caFile="c"`, + ); + assert.notStrictEqual(name({ certFile: "a" }), name({ keyFile: "a" })); + assert.notStrictEqual(name({ caFile: "a" }), name({ caFile: "b" })); + // A path cannot spell the next part. + assert.notStrictEqual(name({ certFile: 'a":keyFile="b' }), name({ certFile: "a", keyFile: "b" })); + }); + + bunTest("allowPartialTrustChain and secureContext are parts of the name when they are set", () => { + const base = name({}); + assert.strictEqual(name({ allowPartialTrustChain: true }), `${base}:allowPartialTrustChain`); + assert.strictEqual(name({ allowPartialTrustChain: false, secureContext: undefined }), base); + const secureContext = tls.createSecureContext(); + assert.strictEqual(name({ secureContext }), name({ secureContext })); + assert.notStrictEqual(name({ secureContext }), name({ secureContext: tls.createSecureContext() })); + assert.notStrictEqual(name({ secureContext }), base); + }); +}); diff --git a/test/js/node/test/parallel/test-https-agent-getname.js b/test/js/node/test/parallel/test-https-agent-getname.js index 2a13ab1c6f47..8ead852b1df5 100644 --- a/test/js/node/test/parallel/test-https-agent-getname.js +++ b/test/js/node/test/parallel/test-https-agent-getname.js @@ -6,6 +6,7 @@ if (!common.hasCrypto) const assert = require('assert'); const https = require('https'); +const fixtures = require('../common/fixtures'); const agent = new https.Agent(); @@ -52,3 +53,46 @@ assert.strictEqual( '::secureProtocol:c,r,l:false:ecdhCurve:dhparam:0:sessionIdContext:' + '"sigalgs":privateKeyIdentifier:privateKeyEngine' ); + +{ + const baseOptions = { + host: '0.0.0.0', + port: 443, + }; + + const agent1 = fixtures.readKey('agent1.pfx'); + const agent6 = fixtures.readKey('agent6.pfx'); + + assert.notStrictEqual( + agent.getName({ + ...baseOptions, + pfx: [{ buf: agent1, passphrase: 'sample' }], + }), + agent.getName({ + ...baseOptions, + pfx: [{ buf: agent6, passphrase: 'sample' }], + }) + ); + + assert.notStrictEqual( + agent.getName({ + ...baseOptions, + pfx: [{ buf: agent1, passphrase: 'sample' }], + }), + agent.getName({ + ...baseOptions, + pfx: [{ buf: agent1, passphrase: 'different' }], + }) + ); + + assert.notStrictEqual( + agent.getName({ + ...baseOptions, + pfx: [{ __proto__: { buf: agent1, passphrase: 'sample' } }], + }), + agent.getName({ + ...baseOptions, + pfx: [{ __proto__: { buf: agent6, passphrase: 'sample' } }], + }) + ); +} From 03111408ba8b27fcda72eff191a82f286c4f51c1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:19:13 +0000 Subject: [PATCH 021/174] node:https: give the request what tls.connect() throws over a proxy tunnel (#44346) establishTunnel() calls tls.connect() from the proxy socket's 'readable' handler, after https.request() has returned. An option it rejects (minVersion, a wrong passphrase) was thrown with nothing to catch it: an uncaught exception, no 'error' or 'close' on the request, and the CONNECT socket left open. Catch it, close the proxy socket and report it through the same callback as the other tunnel failures. Port of nodejs/node#66096. --- src/js/node/https.ts | 10 +- .../http/node-http-agent-tls-options.test.mts | 251 +++++++++++++++++- ...ttps-proxy-request-invalid-tls-options.mjs | 75 ++++++ 3 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 test/js/node/test/parallel/test-https-proxy-request-invalid-tls-options.mjs diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 7300542b12d2..91df9effe069 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -203,7 +203,15 @@ function establishTunnel(agent, socket, options, tunnelConfig, afterSocket) { $debug("Propagate free event from tunneled socket to tunnel socket"); socket.emit("free"); } - tunneledSocket = require("node:tls").connect(requestOptions, onTLSHandshakeSuccess); + // https://github.com/nodejs/node/blob/ada8c5c9f82f620885820a2268e19dbf8a2228d5/lib/https.js#L295-L308 + try { + tunneledSocket = require("node:tls").connect(requestOptions, onTLSHandshakeSuccess); + } catch (err) { + $debug("tls.connect() over tunnel threw", err); + socket.destroy(); + afterSocket(err, socket); + return headerEndIndex; + } tunneledSocket.on("free", onTunneledSocketFree); tunneledSocket.on("error", onTLSHandshakeError); const agentKey = requestOptions._agentKey; diff --git a/test/js/node/http/node-http-agent-tls-options.test.mts b/test/js/node/http/node-http-agent-tls-options.test.mts index 2ad336cfc34b..6fa19798efe8 100644 --- a/test/js/node/http/node-http-agent-tls-options.test.mts +++ b/test/js/node/http/node-http-agent-tls-options.test.mts @@ -9,6 +9,7 @@ */ import { HttpsProxyAgent } from "https-proxy-agent"; +import assert from "node:assert"; import { once } from "node:events"; import { readFileSync } from "node:fs"; import http from "node:http"; @@ -17,6 +18,7 @@ import type { AddressInfo } from "node:net"; import net from "node:net"; import { dirname, join } from "node:path"; import { describe, test } from "node:test"; +import tls from "node:tls"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -670,9 +672,256 @@ describe("http.request agent options", () => { }); }); +describe( + "https.request through a proxy from proxyEnv, with TLS options that tls.connect() rejects", + // tls.connect() runs when the proxy has answered the CONNECT, after https.request() returned. nodejs/node#66096 gives + // what it throws there to the request. No Node release has that change (v26.10.0 throws it uncaught). It is on Node + // main, so v27 runs this block, and on v26.x-staging (33e4ba358b), which is not in a v26 release. + { skip: typeof Bun === "undefined" && Number(process.versions.node.split(".")[0]) < 27 }, + () => { + const target = { host: "example.invalid", port: 443, path: "/" }; + const invalidVersion = { minVersion: "TLSv9" }; + const rejected = [ + { name: "minVersion", options: invalidVersion, code: "ERR_TLS_INVALID_PROTOCOL_VERSION" }, + { + name: "wrong passphrase", + options: { key: tlsCerts.encryptedKey, cert: tlsCerts.cert, passphrase: "wrong" }, + code: "ERR_OSSL_BAD_DECRYPT", + }, + ]; + + function proxiedAgent(proxyUrl: string, options: https.AgentOptions = {}) { + return new https.Agent({ ...options, proxyEnv: { https_proxy: proxyUrl } } as any); + } + + // The error that tls.connect() throws for these options when nothing is between it and its caller. + function thrownByTlsConnect(options: object) { + try { + tls.connect({ ...options } as any); + } catch (err: any) { + return { code: err.code, message: err.message }; + } + } + + // A proxy that answers each CONNECT with 200 and then holds the connection. + function createHoldingProxy(scheme: string) { + const sockets = new Set(); + const connects: string[] = []; + // What the client sent after the CONNECT. + const afterConnect: Buffer[] = []; + // Settles when the first connection is closed. The proxy does not close it, so the client did. + const { promise: closed, resolve: onClosed } = Promise.withResolvers(); + + function onTunnel(socket: net.Socket) { + let head = ""; + socket.on("error", () => {}); + socket.on("data", function onHead(chunk: Buffer) { + head += chunk.toString("latin1"); + if (!head.includes("\r\n\r\n")) return; + socket.removeListener("data", onHead); + connects.push(head.slice(0, head.indexOf("\r\n"))); + socket.on("data", (chunk: Buffer) => afterConnect.push(chunk)); + socket.write("HTTP/1.1 200 Connection established\r\n\r\n"); + }); + } + + const proxy = + scheme === "https" + ? tls.createServer({ key: tlsCerts.key, cert: tlsCerts.cert }, onTunnel) + : net.createServer(onTunnel); + // The TCP connection, for an https proxy too. + proxy.on("connection", (socket: net.Socket) => { + sockets.add(socket); + socket.on("close", () => { + sockets.delete(socket); + onClosed(); + }); + }); + + function close() { + for (const socket of sockets) socket.destroy(); + proxy.close(); + } + return { proxy, connects, afterConnect, closed, close }; + } + + // What the request emits, in order, up to and including its 'close'. + function eventsOf(req: http.ClientRequest) { + const events: unknown[] = []; + const { promise, resolve } = Promise.withResolvers(); + req.on("socket", () => events.push("socket")); + req.on("response", res => { + events.push(`response ${res.statusCode}`); + res.resume(); + }); + req.on("timeout", () => events.push("timeout")); + req.on("error", (err: NodeJS.ErrnoException) => events.push({ code: err.code, message: err.message })); + req.on("close", () => { + events.push("close"); + resolve(events); + }); + return promise; + } + + for (const scheme of ["http", "https"]) { + for (const { name, options, code } of scheme === "http" ? rejected : rejected.slice(0, 1)) { + test(`${scheme} proxy: ${name}`, async () => { + const defaults = scheme === "https" ? tls.getCACertificates("default") : undefined; + const { proxy, connects, closed, close } = createHoldingProxy(scheme); + const agent = proxiedAgent(`${scheme}://127.0.0.1:${await startProxy(proxy)}`); + try { + // The client has to trust the certificate of an https proxy. + if (defaults) tls.setDefaultCACertificates([...defaults, tlsCerts.cert]); + const expected = { code, message: thrownByTlsConnect(options)?.message }; + const req = https.get({ ...target, agent, ...options } as any); + assert.deepStrictEqual(await eventsOf(req), [expected, "close"]); + await closed; + assert.deepStrictEqual(connects, ["CONNECT example.invalid:443 HTTP/1.1"]); + } finally { + agent.destroy(); + close(); + if (defaults) tls.setDefaultCACertificates(defaults); + } + }); + } + } + + test("agent.createConnection() gives its callback the error and the closed proxy socket", async () => { + const { proxy, closed, close } = createHoldingProxy("http"); + const agent = proxiedAgent(`http://127.0.0.1:${await startProxy(proxy)}`); + try { + const calls: unknown[] = []; + const { promise: called, resolve: onCalled } = Promise.withResolvers(); + const proxySocket = (agent as any).createConnection( + { ...target, ...invalidVersion }, + (err: NodeJS.ErrnoException, socket: net.Socket) => { + calls.push({ code: err.code, proxySocket: socket === proxySocket, destroyed: socket.destroyed }); + onCalled(); + }, + ); + await called; + assert.deepStrictEqual(calls, [ + { code: "ERR_TLS_INVALID_PROTOCOL_VERSION", proxySocket: true, destroyed: true }, + ]); + await closed; + } finally { + agent.destroy(); + close(); + } + }); + + test("a falsy value thrown by tls.connect(): the proxy gets nothing of the request", async () => { + const { proxy, connects, afterConnect, closed, close } = createHoldingProxy("http"); + const agent = proxiedAgent(`http://127.0.0.1:${await startProxy(proxy)}`); + try { + // agent.getName() reads the key too: throw at the first read after the CONNECT, which is tls.connect()'s. + let thrown = false; + const key = [ + { + get pem() { + if (connects.length === 0 || thrown) return tlsCerts.key; + thrown = true; + throw null; + }, + }, + ]; + const req = https.request({ ...target, agent, method: "POST", key } as any); + const events = eventsOf(req); + req.end("for the target only"); + // The Agent takes a falsy error for success. The socket that it gives the request is closed. + const [socket] = await once(req, "socket"); + assert.strictEqual(socket.destroyed, true); + assert.deepStrictEqual(await events, ["socket", { code: "ECONNRESET", message: "socket hang up" }, "close"]); + await closed; + assert.strictEqual(Buffer.concat(afterConnect).toString("latin1"), ""); + } finally { + agent.destroy(); + close(); + } + }); + + test("a request that waited behind maxTotalSockets", async () => { + const { server, port, hostname } = await createHttpsServer(); + const proxy = createConnectProxy(); + let tunnels = 0; + proxy.on("connection", () => tunnels++); + const agent = proxiedAgent(`http://127.0.0.1:${await startProxy(proxy)}`, { + ca: tlsCerts.ca, + maxTotalSockets: 1, + }); + try { + const request = { hostname, port, path: "/", agent }; + const first = https.get(request); + const firstEvents = eventsOf(first); + // The Agent counts the socket of a tunnel from when the tunnel is there. Later requests then wait. + await once(first, "socket"); + const rejectedEvents = eventsOf(https.get({ ...request, ...invalidVersion } as any)); + const lastEvents = eventsOf(https.get(request)); + assert.strictEqual(Object.values(agent.requests).flat().length, 2); + + assert.deepStrictEqual(await rejectedEvents, [thrownByTlsConnect(invalidVersion), "close"]); + assert.deepStrictEqual(await firstEvents, ["socket", "response 200", "close"]); + assert.deepStrictEqual(await lastEvents, ["socket", "response 200", "close"]); + assert.strictEqual(tunnels, 3); + } finally { + agent.destroy(); + server.close(); + proxy.close(); + } + }); + }, +); + // Only run in Bun to avoid infinite loop when Node.js runs this file if (typeof Bun !== "undefined") { - const { bunEnv, nodeExe } = await import("harness"); + const { bunEnv, bunExe, nodeExe } = await import("harness"); + + describe("https.request through a proxy from NODE_USE_ENV_PROXY=1", () => { + test("the request gets what tls.connect() throws, and the process exits", async () => { + // Answers each CONNECT with 200 and then holds the connection. + const sockets = new Set(); + const proxy = net.createServer(socket => { + sockets.add(socket); + socket.on("error", () => {}); + socket.once("data", () => socket.write("HTTP/1.1 200 Connection established\r\n\r\n")); + }); + const port = await startProxy(proxy); + try { + // The global agent takes the proxy from the environment when node:https loads. + const script = ` + const events = []; + const req = require("node:https").get("https://example.invalid/", { minVersion: "TLSv9" }); + req.on("error", err => events.push(err.code)); + req.on("close", () => { + events.push("close"); + console.log(JSON.stringify(events)); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { + ...bunEnv, + NODE_USE_ENV_PROXY: "1", + HTTPS_PROXY: `http://127.0.0.1:${port}`, + https_proxy: undefined, + NO_PROXY: undefined, + no_proxy: undefined, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The proxy holds the connection. The process can only exit when the client closed it. + assert.deepStrictEqual( + { stdout: stdout.trim(), stderr, exitCode }, + { stdout: JSON.stringify(["ERR_TLS_INVALID_PROTOCOL_VERSION", "close"]), stderr: "", exitCode: 0 }, + ); + } finally { + for (const socket of sockets) socket.destroy(); + proxy.close(); + } + }); + }); describe("Node.js compatibility", () => { test("all tests pass in Node.js", async () => { diff --git a/test/js/node/test/parallel/test-https-proxy-request-invalid-tls-options.mjs b/test/js/node/test/parallel/test-https-proxy-request-invalid-tls-options.mjs new file mode 100644 index 000000000000..065476dce273 --- /dev/null +++ b/test/js/node/test/parallel/test-https-proxy-request-invalid-tls-options.mjs @@ -0,0 +1,75 @@ +// This tests that invalid TLS options do not result in an uncaught exception +// after an HTTPS proxy tunnel has been established. + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import { once } from 'events'; +import fixtures from '../common/fixtures.js'; +import { createProxyServer } from '../common/proxy-server.js'; + +if (!common.hasCrypto) + common.skip('missing crypto'); + +// https must be dynamically imported so that builds without crypto support +// can skip it. +const { default: https } = await import('node:https'); + +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustNotCall()); +server.on('error', common.mustNotCall()); +server.listen(0); +await once(server, 'listening'); + +const { proxy, logs } = createProxyServer(); +proxy.listen(0); +await once(proxy, 'listening'); + +const serverHost = `localhost:${server.address().port}`; + +// tls.connect() throws for these options while building the secure context, +// before any handshake happens. +const testCases = [ + { options: { minVersion: 'definitely-invalid' }, code: 'ERR_TLS_INVALID_PROTOCOL_VERSION' }, + { options: { ciphers: 123 }, code: 'ERR_INVALID_ARG_TYPE' }, + { options: { secureProtocol: 'definitely-invalid' }, code: 'ERR_TLS_INVALID_PROTOCOL_METHOD' }, +]; + +for (const { options, code } of testCases) { + const agent = new https.Agent({ + ca: fixtures.readKey('fake-startcom-root-cert.pem'), + proxyEnv: { + HTTPS_PROXY: `http://localhost:${proxy.address().port}`, + }, + }); + const req = https.get({ + host: 'localhost', + port: server.address().port, + path: '/test', + agent, + ...options, + }, common.mustNotCall()); + const [err] = await once(req, 'error'); + assert.strictEqual(err.code, code); + agent.destroy(); +} + +// Verify that the requests went through the proxy and the tunnel was established. +const requests = logs.filter((log) => !('error' in log)); +// The client resets the tunnel as soon as tls.connect() throws, which the +// proxy may observe as an ECONNRESET while still relaying it. +const errors = logs.filter((log) => + 'error' in log && log.error.code !== 'ECONNRESET'); + +assert.deepStrictEqual(requests, testCases.map(() => ({ + method: 'CONNECT', + url: serverHost, + headers: { + 'host': serverHost, + }, +}))); +assert.deepStrictEqual(errors, []); + +proxy.close(); +server.close(); From b2406b425b05e972bb10a88a4a1421975e0b07c6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:24:32 +0000 Subject: [PATCH 022/174] tls: one translation from tls.connect() options to the native tls option The ws and node-fetch shims each need to turn the options Node code gives an https.Agent or tls.connect() into the `tls` option of WebSocket and fetch(). nodeClientTlsToNative() in internal/tls does it once: a fixed list of keys read from an own-property copy, Node's validation, rejectUnauthorized off only for `false`, allowPartialTrustChain on only for `true`, version strings to numbers, { pem } keys and pfx archives to PEM. The native `ca` replaces the default trust store, so the CAs an archive bundles extend only a `ca` the caller gave and are dropped otherwise. normalizePemKeyOption moves verbatim from node:tls, and https.createServer() now uses it: key: [{ pem }] failed to listen with ERR_INVALID_ARG_TYPE. --- src/js/internal/tls.ts | 82 ++++++++++++++++++++- src/js/node/_http_server.ts | 2 +- src/js/node/tls.ts | 27 +------ test/js/node/http/node-https-server.test.ts | 7 ++ 4 files changed, 90 insertions(+), 28 deletions(-) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index f1f60d55ddb8..ec693aa1b548 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -1,7 +1,9 @@ -const { isTypedArray, isArrayBuffer } = require("node:util/types"); +const { isTypedArray, isArrayBuffer, isArrayBufferView } = require("node:util/types"); const { validateString, validateBuffer } = require("internal/validators"); const StringPrototypeSplit = String.prototype.split; +const ArrayPrototypeSome = Array.prototype.some; +const ArrayPrototypeMap = Array.prototype.map; function isPemObject(obj: unknown): obj is { pem: unknown } { return $isObject(obj) && "pem" in obj; @@ -311,11 +313,89 @@ function processPfxOptions(options) { return out; } +function hasPemObject(key) { + if (!key) return false; + if ($isArray(key)) return ArrayPrototypeSome.$call(key, isPemKeyEntry); + return isPemKeyEntry(key); +} + +function isPemKeyEntry(k) { + return k && typeof k === "object" && !isArrayBufferView(k) && "pem" in k; +} + +function normalizePemKeyOption(key, ctxPassphrase) { + if (!key || !hasPemObject(key)) return key; + const entries = $isArray(key) ? key : [key]; + return ArrayPrototypeMap.$call(entries, k => { + if (!isPemKeyEntry(k)) return k; + // Node: val?.passphrase !== undefined ? val.passphrase : passphrase - an + // explicit per-key null means "no passphrase for this key" and does NOT + // fall back to the context-level one. + const passphrase = k.passphrase !== undefined ? k.passphrase : ctxPassphrase; + if (passphrase == null) return k.pem; + const { createPrivateKey } = require("node:crypto"); + return createPrivateKey({ key: k.pem, passphrase }).export({ type: "pkcs8", format: "pem" }); + }); +} + +// The native `ca` replaces the default store, so the CAs of an archive can only extend a `ca` the caller gave. +function unsealPfxForNative(tls) { + if (tls.pfx == null) return tls; + tls = processPfxOptions(tls); + const { ca, _pfxExtraCACerts: pfxCAs } = tls; + if (pfxCAs && ca) tls.ca = $isArray(ca) ? [...ca, ...pfxCAs] : [ca, ...pfxCAs]; + return tls; +} + +const nodeClientTlsKeys = [ + "rejectUnauthorized", + "ca", + "cert", + "key", + "pfx", + "passphrase", + "servername", + "checkServerIdentity", + "ciphers", + "secureOptions", + "minVersion", + "maxVersion", + "crl", + "sigalgs", + "ecdhCurve", + "allowPartialTrustChain", +]; + +// `options` of tls.connect(), as a null-prototype copy, to the `tls` option of fetch() and WebSocket. +function nodeClientTlsToNative(options) { + validateSecureContextOptions(options); + let tls; + for (let i = 0; i < nodeClientTlsKeys.length; i++) { + const name = nodeClientTlsKeys[i]; + let value = options[name]; + if (value == null || value === "") continue; + if (name === "rejectUnauthorized") { + value = value !== false; + } else if (name === "allowPartialTrustChain") { + if (value !== true) continue; + } else if (name === "key") { + value = normalizePemKeyOption(value, options.passphrase); + } else if (name === "minVersion" || name === "maxVersion") { + value = tlsStringToProtocolVersion(value); + } + (tls ??= { __proto__: null })[name] = value; + } + return tls && unsealPfxForNative(tls); +} + export { SSL_OP_CIPHER_SERVER_PREFERENCE, + nodeClientTlsToNative, + normalizePemKeyOption, processPfxOptions, secureProtocolToVersionRange, throwOnInvalidTLSArray, tlsStringToProtocolVersion, + unsealPfxForNative, validateSecureContextOptions, }; diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index e75924fa5c98..a9bd0d61d72c 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -411,7 +411,7 @@ function Server(options, callback): void { } this[tlsSymbol] = normalizeServerTls({ serverName, - key, + key: tlsHelpers.normalizePemKeyOption(key, passphrase), cert, ca, crl, diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 7df108bb357e..695c14cbb1c1 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -12,6 +12,7 @@ const { throwOnInvalidTLSArray, tlsStringToProtocolVersion, secureProtocolToVersionRange, + normalizePemKeyOption, processPfxOptions, validateSecureContextOptions, SSL_OP_CIPHER_SERVER_PREFERENCE, @@ -222,7 +223,6 @@ const ArrayPrototypePush = Array.prototype.push; const ArrayPrototypeSome = Array.prototype.some; const ArrayPrototypeReduce = Array.prototype.reduce; const ArrayPrototypeFilter = Array.prototype.filter; -const ArrayPrototypeMap = Array.prototype.map; const ArrayPrototypeSort = Array.prototype.sort; const ObjectFreeze = Object.freeze; @@ -442,31 +442,6 @@ const NativeSecureContext = $rust("SecureContext.rs", "js.getConstructor"); // crossing into native so `{ key: false }` etc. doesn't throw // ERR_INVALID_ARG_TYPE from the bindgen layer. -function hasPemObject(key) { - if (!key) return false; - if ($isArray(key)) return ArrayPrototypeSome.$call(key, isPemKeyEntry); - return isPemKeyEntry(key); -} - -function isPemKeyEntry(k) { - return k && typeof k === "object" && !isArrayBufferView(k) && "pem" in k; -} - -function normalizePemKeyOption(key, ctxPassphrase) { - if (!key || !hasPemObject(key)) return key; - const entries = $isArray(key) ? key : [key]; - return ArrayPrototypeMap.$call(entries, k => { - if (!isPemKeyEntry(k)) return k; - // Node: val?.passphrase !== undefined ? val.passphrase : passphrase - an - // explicit per-key null means "no passphrase for this key" and does NOT - // fall back to the context-level one. - const passphrase = k.passphrase !== undefined ? k.passphrase : ctxPassphrase; - if (passphrase == null) return k.pem; - const { createPrivateKey } = require("node:crypto"); - return createPrivateKey({ key: k.pem, passphrase }).export({ type: "pkcs8", format: "pem" }); - }); -} - // SNI is a C string, so as in Node.js it ends at a NUL. checkServerIdentity() still gets the whole servername. function sniName(servername) { if (typeof servername !== "string") return servername; diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index e77e0636a15b..b72542de9644 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -220,6 +220,13 @@ describe("https.createServer forwards every TLS server option", () => { expect(await negotiated(false)).toBe(aes128); }); + test("takes a key as [{ pem, passphrase }]", async () => { + const pem = readFileSync(join(import.meta.dirname, "fixtures", "cert.encrypted.key"), "utf8"); + const cert = readFileSync(join(import.meta.dirname, "fixtures", "cert.pem"), "utf8"); + await using server = https.createServer({ key: [{ pem, passphrase: "testpassword" }], cert }); + expect(await handshake(await listen(server), {})).toStartWith("TLS_"); + }); + test("sends sessionTimeout as the ticket lifetime", async () => { await using server = https.createServer({ ...validCert, sessionTimeout: 1234 }); const client = tls.connect({ port: await listen(server), host: "127.0.0.1", rejectUnauthorized: false, maxVersion: "TLSv1.2" }); // prettier-ignore From e4ed4d46580f6caad3300b6cb28abca36bb2e8a1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:24:33 +0000 Subject: [PATCH 023/174] node-fetch: apply the TLS options of the agent (#35609) The shim passes init straight to fetch(), which has no `agent` option, so the ca, cert, key and rejectUnauthorized of an https.Agent were dropped. That is how @kubernetes/client-node configures mTLS and a private CA. Translate agent.options into init.tls unless the caller gave one. --- src/js/thirdparty/node-fetch.ts | 16 ++ .../js/node/http/node-fetch-agent-tls.test.ts | 197 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 test/js/node/http/node-fetch-agent-tls.test.ts diff --git a/src/js/thirdparty/node-fetch.ts b/src/js/thirdparty/node-fetch.ts index 92e8c69ec2e9..9ac061e6ead5 100644 --- a/src/js/thirdparty/node-fetch.ts +++ b/src/js/thirdparty/node-fetch.ts @@ -144,6 +144,18 @@ class Response extends WebResponse { var ResponsePrototype = Response.prototype; const kUrl = Symbol("kUrl"); +const ObjectHasOwn = Object.hasOwn; + +// https.Agent applies `agent.options` to every tls.connect() it makes. +function tlsFromAgent(agent, url) { + if ($isCallable(agent)) { + const href = $isObject(url) ? (url.href ?? url.url) : url; + if (typeof href !== "string" || !URL.canParse(href)) return undefined; + agent = agent.$call(undefined, new URL(href)); + } + if (!$isObject(agent) || !ObjectHasOwn(agent, "options") || !$isObject(agent.options)) return undefined; + return require("internal/tls").nodeClientTlsToNative({ __proto__: null, ...agent.options }); +} class Request extends WebRequest { [kUrl]?: string; @@ -191,6 +203,10 @@ async function fetch( init = { ...init, body: Readable.toWeb(readable) }; } } + if (init && ObjectHasOwn(init, "agent") && (init as any).agent && (init as any).tls === undefined) { + const tls = tlsFromAgent((init as any).agent, url); + if (tls !== undefined) init = { ...init, tls } as any; + } const response = await nativeFetch.$call(undefined, url, init); Object.setPrototypeOf(response, ResponsePrototype); response[kFetched] = true; diff --git a/test/js/node/http/node-fetch-agent-tls.test.ts b/test/js/node/http/node-fetch-agent-tls.test.ts new file mode 100644 index 000000000000..89e6adb2c28a --- /dev/null +++ b/test/js/node/http/node-fetch-agent-tls.test.ts @@ -0,0 +1,197 @@ +// @kubernetes/client-node passes an https.Agent (with ca/cert/key) to node-fetch: https://github.com/oven-sh/bun/issues/19754 +// Uses node:test so that it runs unchanged on Node.js with the node-fetch package. +import nodeFetch from "node-fetch"; +import assert from "node:assert"; +import { constants, createPrivateKey } from "node:crypto"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import https from "node:https"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import { describe, test } from "node:test"; +import type { TLSSocket } from "node:tls"; + +const read = (name: string) => readFileSync(path.join(import.meta.dirname, "..", "test", "fixtures", "keys", name)); +// ca1 signs agent1, ca2 signs agent3. No certificate has a SAN, so clients verify CN under `servername`. +const ca = read("ca1-cert.pem"); +const key = read("agent1-key.pem"); +const cert = read("agent1-cert.pem"); +const servername = "agent1"; + +async function serve(options: https.ServerOptions = { key, cert }) { + const server = https.createServer(options, (req, res) => { + res.writeHead(200, { connection: "close" }); + res.end(`authorized=${(req.socket as TLSSocket).authorized}`); + }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + port: (server.address() as AddressInfo).port, + async [Symbol.asyncDispose]() { + server.close(); + await once(server, "close"); + }, + }; +} + +type Init = { agent?: https.Agent | ((url: URL) => https.Agent); tls?: object }; + +/** The response body, or the code of the rejection. */ +async function outcome(port: number, options: https.AgentOptions | Init["agent"], init?: Init) { + const agent = typeof options === "function" || options instanceof https.Agent ? options : new https.Agent(options); + try { + return await (await nodeFetch(`https://127.0.0.1:${port}/`, { agent, ...init } as any)).text(); + } catch (err) { + return String((err as NodeJS.ErrnoException).code); + } finally { + if (typeof agent !== "function") agent.destroy(); + } +} + +describe("node-fetch applies the TLS options of the agent", () => { + test("ca and servername", async () => { + await using server = await serve(); + assert.strictEqual(await outcome(server.port, { ca, servername }), "authorized=false"); + assert.strictEqual(await outcome(server.port, { ca }), "ERR_TLS_CERT_ALTNAME_INVALID"); + assert.strictEqual(await outcome(server.port, { servername }), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + }); + + test("the client identity, in every form", async () => { + await using server = await serve({ key, cert, ca, requestCert: true, rejectUnauthorized: false }); + const trust = { ca, servername }; + const encryptedKey = createPrivateKey(key).export({ + type: "pkcs8", + format: "pem", + cipher: "aes-256-cbc", + passphrase: "sample", + }); + assert.deepStrictEqual( + { + none: await outcome(server.port, trust), + pem: await outcome(server.port, { ...trust, cert, key }), + pemObject: await outcome(server.port, { ...trust, cert, key: [{ pem: key }] }), + encrypted: await outcome(server.port, { + ...trust, + cert, + key: encryptedKey, + passphrase: "sample", + }), + pfx: await outcome(server.port, { ...trust, pfx: read("agent1.pfx"), passphrase: "sample" }), + pfxObject: await outcome(server.port, { ...trust, pfx: [{ buf: read("agent1.pfx"), passphrase: "sample" }] }), + }, + { + none: "authorized=false", + pem: "authorized=true", + pemObject: "authorized=true", + encrypted: "authorized=true", + pfx: "authorized=true", + pfxObject: "authorized=true", + }, + ); + }); + + test("rejectUnauthorized disables verification only when it is false", async () => { + await using server = await serve(); + assert.strictEqual(await outcome(server.port, { rejectUnauthorized: false }), "authorized=false"); + for (const rejectUnauthorized of [0, null, "false", undefined, true] as any[]) { + assert.strictEqual(await outcome(server.port, { rejectUnauthorized }), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + } + if (process.versions.bun) { + // Node reads both through the prototype chain. + const off = new https.Agent({ rejectUnauthorized: false }); + assert.strictEqual(await outcome(server.port, Object.create(off)), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + await assert.rejects(nodeFetch(`https://127.0.0.1:${server.port}/`, Object.create({ agent: off })), { + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + const tls = { rejectUnauthorized: true }; + assert.strictEqual( + await outcome(server.port, { rejectUnauthorized: false }, { tls }), + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + ); + } + }); + + test("crl", async () => { + await using server = await serve({ key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }); + const trust = { ca: read("ca2-cert.pem"), servername: "agent3" }; + // ca2-crl.pem revokes agent4 only. + assert.strictEqual(await outcome(server.port, { ...trust, crl: read("ca2-crl.pem") }), "authorized=false"); + assert.strictEqual(await outcome(server.port, { ...trust, crl: read("ca2-crl-agent3.pem") }), "CERT_REVOKED"); + }); + + test("checkServerIdentity", async () => { + await using server = await serve(); + const seen: unknown[] = []; + function accept(hostname: string, peer: { subject: { CN: string } }) { + seen.push(hostname, peer.subject.CN); + return undefined; + } + function refuse() { + return Object.assign(new Error("not the pinned certificate"), { code: "ERR_TEST_PIN" }); + } + assert.strictEqual(await outcome(server.port, { ca, checkServerIdentity: accept as any }), "authorized=false"); + assert.deepStrictEqual(seen, ["127.0.0.1", "agent1"]); + assert.strictEqual(await outcome(server.port, { ca, servername, checkServerIdentity: refuse }), "ERR_TEST_PIN"); + }); + + test("minVersion and maxVersion", async () => { + await using server = await serve({ key, cert, minVersion: "TLSv1.3" }); + const trust = { ca, servername }; + assert.strictEqual(await outcome(server.port, { ...trust, minVersion: "TLSv1.2" }), "authorized=false"); + // Node reports the alert as EPROTO. + assert.match(await outcome(server.port, { ...trust, maxVersion: "TLSv1.2" }), /EPROTO|PROTOCOL_VERSION/); + assert.strictEqual( + await outcome(server.port, { ...trust, minVersion: "TLSv9" as any }), + "ERR_TLS_INVALID_PROTOCOL_VERSION", + ); + }); + + test("allowPartialTrustChain", async () => { + // ca1 signs ca3, ca3 signs agent6. The server sends agent6 and ca3. + await using server = await serve({ key: read("agent6-key.pem"), cert: read("agent6-cert.pem") }); + const trust = { ca: read("ca3-cert.pem"), checkServerIdentity: () => undefined }; + assert.strictEqual(await outcome(server.port, { ...trust, allowPartialTrustChain: true }), "authorized=false"); + assert.strictEqual(await outcome(server.port, trust), "UNABLE_TO_GET_ISSUER_CERT"); + // Node takes any truthy value. + if (process.versions.bun) { + assert.strictEqual( + await outcome(server.port, { ...trust, allowPartialTrustChain: 1 as any }), + "UNABLE_TO_GET_ISSUER_CERT", + ); + } + }); + + test("ciphers, sigalgs, ecdhCurve and secureOptions", async () => { + const trust = { ca, servername }; + const refused = /EPROTO|HANDSHAKE_FAILURE|PROTOCOL_VERSION|NO_PROTOCOLS_AVAILABLE/; + await using aes256 = await serve({ key, cert, maxVersion: "TLSv1.2", ciphers: "ECDHE-RSA-AES256-GCM-SHA384" }); + await using sha512 = await serve({ key, cert, sigalgs: "rsa_pss_rsae_sha512" }); + await using p384 = await serve({ key, cert, ecdhCurve: "P-384" }); + await using tls13 = await serve({ key, cert, minVersion: "TLSv1.3" }); + for (const [port, accepted, rejected] of [ + [aes256.port, { ciphers: "ECDHE-RSA-AES256-GCM-SHA384" }, { ciphers: "ECDHE-RSA-AES128-GCM-SHA256" }], + [sha512.port, { sigalgs: "rsa_pss_rsae_sha512" }, { sigalgs: "rsa_pss_rsae_sha256" }], + [p384.port, { ecdhCurve: "P-384" }, { ecdhCurve: "X25519" }], + [tls13.port, { secureOptions: constants.SSL_OP_NO_TLSv1_2 }, { secureOptions: constants.SSL_OP_NO_TLSv1_3 }], + ] as const) { + assert.strictEqual(await outcome(port, { ...trust, ...accepted }), "authorized=false"); + assert.match(await outcome(port, { ...trust, ...rejected }), refused, JSON.stringify(rejected)); + } + }); + + test("an agent that is a function of the request URL", async () => { + await using server = await serve(); + const agent = new https.Agent({ ca, servername }); + // node-fetch v2 passes a legacy url.parse() object and v3 a WHATWG URL. Both have these fields. + let calledWith: URL | undefined; + try { + assert.strictEqual(await outcome(server.port, url => ((calledWith = url), agent)), "authorized=false"); + assert.deepStrictEqual( + { protocol: calledWith?.protocol, hostname: calledWith?.hostname, port: String(calledWith?.port) }, + { protocol: "https:", hostname: "127.0.0.1", port: String(server.port) }, + ); + } finally { + agent.destroy(); + } + }); +}); From 548536402b83a338af93f8e2f3e1f4b73a0dc68a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:27:06 +0000 Subject: [PATCH 024/174] ws: forward the TLS options of the constructor and of the agent (#31397, #42325) npm ws gives every constructor option to https.request(), so ca, rejectUnauthorized, cert, key, pfx or minVersion are written at the top level or on the agent. The shim read TLS options only from the Bun-only `tls` option and five keys of the agent, and dropped the rest without an error: a private CA was not trusted, a client certificate in a pfx was not sent, a minVersion was not enforced. Merge the top-level options with the agent's the way http.Agent does, so the agent wins (measured on Node v26.3.0 with ws 8.18.3), and translate the result with nodeClientTlsToNative(). An explicit `tls` option still replaces both, and its pfx is unsealed too. --- src/js/thirdparty/ws.js | 64 ++--- .../ws/fixtures/agent1-with-server-ca.pfx | Bin 0 -> 3811 bytes test/js/first_party/ws/ws-tls-options.test.ts | 232 ++++++++++++++++++ 3 files changed, 253 insertions(+), 43 deletions(-) create mode 100644 test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx create mode 100644 test/js/first_party/ws/ws-tls-options.test.ts diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 301d18f6abb7..08a81c9ed832 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -30,48 +30,19 @@ function sendAfterClose(state, cb) { } } +const ObjectHasOwn = Object.hasOwn; + /** - * Extracts TLS and proxy options from an agent object. + * Extracts connection and proxy options from an agent object. * @param {Object} agent The agent object to extract options from - * @returns {{ tls: Object|null, proxy: string|Object|null }} + * @returns {{ connectOpts: Object|undefined, proxy: string|Object|null }} */ function extractAgentOptions(agent) { - const connectOpts = agent?.connectOpts || agent?.options; - let tls = null; + let connectOpts = ObjectHasOwn(agent, "connectOpts") ? agent.connectOpts : undefined; + connectOpts ||= ObjectHasOwn(agent, "options") ? agent.options : undefined; + if (!$isObject(connectOpts)) connectOpts = undefined; let proxy = null; - if ($isObject(connectOpts)) { - // Build TLS options - const newTlsOptions = {}; - let hasTlsOptions = false; - - const { rejectUnauthorized, ca, cert, key, passphrase } = connectOpts; - if (rejectUnauthorized !== undefined) { - newTlsOptions.rejectUnauthorized = rejectUnauthorized; - hasTlsOptions = true; - } - if (ca) { - newTlsOptions.ca = ca; - hasTlsOptions = true; - } - if (cert) { - newTlsOptions.cert = cert; - hasTlsOptions = true; - } - if (key) { - newTlsOptions.key = key; - hasTlsOptions = true; - } - if (passphrase) { - newTlsOptions.passphrase = passphrase; - hasTlsOptions = true; - } - - if (hasTlsOptions) { - tls = newTlsOptions; - } - } - // Build proxy - check connectOpts.proxy first, then agent.proxy const agentProxy = connectOpts?.proxy || agent?.proxy; if (agentProxy) { @@ -85,7 +56,7 @@ function extractAgentOptions(agent) { } } - return { tls, proxy }; + return { connectOpts, proxy }; } const eventIds = { @@ -202,22 +173,29 @@ class BunWebSocket extends EventEmitter { if ($isObject(options)) { headers = options?.headers; proxy = options?.proxy; - tlsOptions = options?.tls; if ("perMessageDeflate" in options && !options.perMessageDeflate) { disableDeflate = true; } // Extract from agent if provided (like HttpsProxyAgent) - agent = options?.agent; + const ownOptions = { __proto__: null, ...options }; + agent = ownOptions.agent; + let connectOpts; if ($isObject(agent)) { const agentOpts = extractAgentOptions(agent); - const { proxy: agentProxy, tls: agentTls } = agentOpts; + const agentProxy = agentOpts.proxy; + connectOpts = agentOpts.connectOpts; if (!proxy && agentProxy) { proxy = agentProxy; } - if (!tlsOptions && agentTls) { - tlsOptions = agentTls; - } + } + + tlsOptions = ownOptions.tls; + if ($isObject(tlsOptions)) { + tlsOptions = require("internal/tls").unsealPfxForNative(tlsOptions); + } else if (!tlsOptions) { + // The agent wins: https://github.com/nodejs/node/blob/v26.3.0/lib/_http_agent.js#L305 + tlsOptions = require("internal/tls").nodeClientTlsToNative({ __proto__: null, ...ownOptions, ...connectOpts }); } } diff --git a/test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx b/test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx new file mode 100644 index 0000000000000000000000000000000000000000..7a62abeb2264d791ba540b7a4cfbacf169037efd GIT binary patch literal 3811 zcmai%Ra6v;7KVu-2F9U78tD**P6-jD92z79$wNv@NDd(-rSy<9bSNp^9U}vRG{_(f zT>^r{^{ji>Igj^Yuf6{Nf7oyT`ZfdxUd6-3hrqyTgh2ji_2`RxxJ0-GFmN;g28R8y zLm)8Xoqr?30vPe)AL|<)F3z7d`EP;?vHC|4+=J*tME;c^2q%Di!v!5L{gRQY*{qj8 z+;bj-hewP90TB}a_bMKc00+WM2z(i>j%$w(z~u*!H!TfJUVkN}d2sl%joD9bEDT2c zlHo#266|==h@U4G&|5ZmFfSaIJ7XBhwc!`1p*jkk)>?iO$^V;R6Pbj2a+y}!+V4Xp z&oCzmnm>lJ|B#w_Q8@sX6}4oLpl8)zO#P8by%(5;R&^_wn1N#ifBn>a0NlDvfAo9< zdaF8(5mD60PP4jd7_CqpV4p;iP=l)ZQTX2)V~rXrYKc30$6Fk>7u(l4;5gq3jFF8) z-D;-h_NY9jQ{{?_v!F2woQtITz4oXdA)7o|YEA-%%_*O#vb0%R?#}3q9VZ+aqGSs@ z-pq-NU!l`bGox&0s*X`U#fXRY>)rXK+z>X6UpBfcEXkJoI~hBV!h(t4_aSV+R?Ejs z;!M~~SIQTp&=*?eOlu!m>#+Qa=PkgghAPg~SiQm26(;?4!~GFi(s{!7N`)Aaoll(p z@bB{LPs4KY@GbUFyBMxef(%b`pfCBjba07M)Ysn`((~;zc(^>fD+3!Z2y)6Vm^_ zsSce1ows|)3F9>jIH_Hwz_mU%+kRu+!Y|p|wPt|(3SE6Gy%j-stJH*(#o>zi}>2EajsU11HjBdg>IjN>gk%WObAjXSaYLcVO)&$ zrkPriz<%323<2GJ8qVM}+v8uO#FI!4Yy|?Tt$%)9rbcOeehT`cXzRZKRR|?Jh*xj% zYfw&$8`W}5-eK1`kq$4%KIXa|ZHfJqKsoTNf}L{2jqNnw^8SbI!Yh?E&8o2Gh^k-S z2s&SOze`XHcJqYIA2r9xsB7pGaVaBqSDpB_xF@mG3sHDcO)Ea(KicGsMO6Htr{5MJ zJ+F~L&sDQ$__D4yum1+xX(M)SaeAdLS092-FKx&{c*%`*a3M%U% zUr(!zq&=?v=v?z&u4d!%#`qE}`Rqv|gV1EwrOT|6zg_!?gu#A@Nv8-(1q#Rdkm&(U zleHD`0k$c{Yku`+ppGDEs7@7STs`3{dA?RrTN0XkGRUTELn-4*)P`uVKfA}WH8>nP^Rk_?Rbs+Ucqx=NFc(ouC6<6fF? ze70UjjYZ4}pZZGU&7;r23O)Ar>~4{iWUrb}4>S?IhhDKe7u)2cARNFvx>Py(#(Dz7 zN{a`!;QYy(#e7Zs*}CAjEY)M~1+m|z3aW7|qJ8ncMg0nyB*fkG7Io}fdO1(5&UsUe zq>RrbmS$yK6S-C1D{*@Vd2P?a0tkB^mCL!sb}i})kPTaRIpm$&3pM3yN;?pCV2>ZS zDx?RFw?i@;y}3O2ljNhg_lkB+5E8v-hJabul!5217@;JoV+e!Q5%qnABRZ|=lY3#M zpmZD)Ozz${S*vSz=5pvV*<`T^VptJaoQ8g%(aAo$|AaQ?vJGbOZs#F)>z?cBTS?kN zX#-|$XE}32vwf4$P;+8(9{uj0MnlK54s=_zMJetWy9zKg5Rc^tsv9ibtpB@L#5s@# z+pYH<%(c%CXZKxOb{Qb5coxpGPfp;GJV0@L1lJ@c^tOvrBFo@y7Y(-syDzHJvpnqM zwp_iRKc?dqF}4hMUwm8^W1n;PJU9VxUR#&L$XQ^C!-V`4C;6Ga7JQBxd{b7^`(vv0 z**kCPNpc>hZtBlq9p&^dL)+Qoc68;DU&JhJE0O%a-nZqJX*H#>O)}G{z{XU%O({&E+Dcp| z7bR*JP`&x1J&XrMX7gC=Mby5!Rh{%OI*AADMD@8XFNni_@$hqM|Ez(9#k+%#`9w`q zIo-za+Kw*iX}|9}N$7STU>TbJimYPQTq4KR(6!#HF_S2$M73*>Ga;0!;k=a9j?T5- zLFqJ$N5Uheyzez91V)hX54ZdcLIlA87(u`v+xO3S5|aLpkw7H4cz;5fKc>Y010VmI z2ln`?7?&qUNi^0j@+bcne6FCMYU)yI`{#k)6E8yitN<{;WJOi4c!|YG%R_d5o|E;& z5(*}|tzw5-odLwt5yhjgj-nGTJ?Aq6PooK(RVA@zzSqtmA;q;wMVY6*j=-v&n@%J-jSkO_yD*H#}|1Eq(cJ%GMivU=?l_`by2+sE=O7jC24e@2I8z z-8wyKKkY&q`wXJI!u(W%^@2pxbl|x44h?v@33brchE|e+; zZ$3{uvHoEdkcHEg#d*O;!?q)YTJ3Otv~B#DWo>p%s{B~?7bpU!f21b)zCfKGP0K|n zavwuX*^f`DKTF8rr9CPpA>p5$_YPk@Fn+#H-bcK;b4VZM<^(OI1Wd$8S&OOZ3;62xiD%?yz!H7t+bY zSZ==}n)f|Z@7O-yP(bwDeMxX6SVCljLKq71q2^)`wNal#=#8>ooeq|$|=)k#b95VpPXbF-zU|yk*#{l zE)8|pA>``c$7*1tq&mq1m95PX3Po^FcrDCzHSnFl)7Pp17Iw=AvOl^7 zwHMO2TT?8kbUalG@!N;qyzS+S)Ux!5AQBNIjZI#Dg+(Mn8aL2OJk8HSBMkQv=&6z0 zr^TDP_4NFzcEppK8F?az8UMa5wEK~lO{6!$1i z&H$ne5rN$M=Pks;CBOl&B^ewn42?@2!WnUG`K-bAZ+HW_-hRlG^0cvI#fuWD1Q1^) OVJlNjq2qs7?SBBKSQ{1q literal 0 HcmV?d00001 diff --git a/test/js/first_party/ws/ws-tls-options.test.ts b/test/js/first_party/ws/ws-tls-options.test.ts new file mode 100644 index 000000000000..41670184bca3 --- /dev/null +++ b/test/js/first_party/ws/ws-tls-options.test.ts @@ -0,0 +1,232 @@ +// npm `ws` gives every option of the constructor to https.request(), so TLS options are top-level options or options +// of the agent. Every test here also passes on Node.js with the ws package, but for the rows marked Bun only. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir, tls as serverIdentity } from "harness"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import https from "node:https"; +import net, { type AddressInfo } from "node:net"; +import { join } from "node:path"; +import type { TLSSocket } from "node:tls"; +import WebSocket, { WebSocketServer } from "ws"; + +const isBun = !!process.versions.bun; +const read = (name: string) => readFileSync(join(import.meta.dirname, "../../node/test/fixtures/keys", name)); +// The server's certificate is self-signed for localhost. ca1 signs agent1, the client, and nothing else here. +const ca = serverIdentity.cert; +const ca1 = read("ca1-cert.pem"); +// agent1's key and certificate, with ca1. +const pfx = read("agent1.pfx"); + +/** Tells each client whether it presented a certificate that ca1 signed. */ +async function serve(options?: https.ServerOptions) { + const server = https.createServer({ ...serverIdentity, ca: ca1, requestCert: true, rejectUnauthorized: false, ...options }); // prettier-ignore + server.on("tlsClientError", () => {}); + const wss = new WebSocketServer({ server }); + wss.on("connection", (ws, req) => ws.send(`authorized=${(req.socket as TLSSocket).authorized}`)); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + url: `wss://localhost:${(server.address() as AddressInfo).port}`, + [Symbol.dispose]() { + for (const client of wss.clients) client.terminate(); + server.close(); + }, + }; +} + +/** The server's message, or "refused". */ +function dial(url: string, options: object) { + const { promise, resolve } = Promise.withResolvers(); + const ws = new WebSocket(url, options); + ws.on("message", data => { + resolve(String(data)); + ws.terminate(); + }); + ws.on("error", () => resolve("refused")); + return promise; +} + +const anonymous = "authorized=false"; +const identified = "authorized=true"; + +describe.concurrent("ws TLS options", () => { + test("top-level ca and rejectUnauthorized", async () => { + using server = await serve(); + expect({ + none: await dial(server.url, {}), + ca: await dial(server.url, { ca }), + otherCa: await dial(server.url, { ca: ca1 }), + emptyCa: await dial(server.url, { ca: "" }), + unverified: await dial(server.url, { rejectUnauthorized: false, ALPNProtocols: ["http/1.1"] }), + }).toEqual({ none: "refused", ca: anonymous, otherCa: "refused", emptyCa: "refused", unverified: anonymous }); + }); + + test("only an own rejectUnauthorized: false disables verification", async () => { + using server = await serve(); + expect(await dial(server.url, { rejectUnauthorized: false })).toBe(anonymous); + for (const rejectUnauthorized of [0, null, "false", "", undefined, true]) { + expect(await dial(server.url, { rejectUnauthorized })).toBe("refused"); + expect(await dial(server.url, { agent: new https.Agent({ rejectUnauthorized } as any) })).toBe("refused"); + } + expect(await dial(server.url, Object.create({ rejectUnauthorized: false }))).toBe("refused"); + // Bun only: Node's Agent reads its own inherited `options`. + const inherited = Object.create(new https.Agent({ rejectUnauthorized: false })); + if (isBun) expect(await dial(server.url, { agent: inherited })).toBe("refused"); + }); + + test("the options of the agent win over the top-level options", async () => { + using server = await serve(); + const agent = (options: object) => new https.Agent(options); + expect({ + agentOff: await dial(server.url, { agent: agent({ rejectUnauthorized: false }), rejectUnauthorized: true }), + agentOn: await dial(server.url, { agent: agent({ rejectUnauthorized: true }), rejectUnauthorized: false }), + agentUndefined: await dial(server.url, { agent: agent({ rejectUnauthorized: undefined }), rejectUnauthorized: false }), // prettier-ignore + agentSilent: await dial(server.url, { agent: agent({}), rejectUnauthorized: false }), + agentCa: await dial(server.url, { agent: agent({ ca }), ca: ca1 }), + agentOtherCa: await dial(server.url, { agent: agent({ ca: ca1 }), ca }), + merged: await dial(server.url, { agent: agent({ ca }), cert: read("agent1-cert.pem"), key: read("agent1-key.pem") }), // prettier-ignore + }).toEqual({ + agentOff: anonymous, + agentOn: "refused", + agentUndefined: "refused", + agentSilent: anonymous, + agentCa: anonymous, + agentOtherCa: "refused", + merged: identified, + }); + }); + + test("the client identity, in every form", async () => { + using server = await serve(); + const cert = read("agent1-cert.pem"); + const key = read("agent1-key.pem"); + expect({ + pem: await dial(server.url, { ca, cert, key }), + pemObject: await dial(server.url, { ca, cert, key: [{ pem: key }] }), + pfx: await dial(server.url, { ca, pfx, passphrase: "sample" }), + pfxObject: await dial(server.url, { ca, pfx: [{ buf: pfx, passphrase: "sample" }] }), + agentPfx: await dial(server.url, { agent: new https.Agent({ ca, pfx, passphrase: "sample" }) }), + // Bun only. + tlsPfx: isBun ? await dial(server.url, { tls: { ca, pfx, passphrase: "sample" } }) : identified, + }).toEqual({ + pem: identified, + pemObject: identified, + pfx: identified, + pfxObject: identified, + agentPfx: identified, + tlsPfx: identified, + }); + }); + + test("minVersion and maxVersion", async () => { + using tls12 = await serve({ maxVersion: "TLSv1.2" }); + using tls13 = await serve({ minVersion: "TLSv1.3" }); + expect({ + min13to12: await dial(tls12.url, { ca, minVersion: "TLSv1.3" }), + max12to12: await dial(tls12.url, { ca, maxVersion: "TLSv1.2" }), + max12to13: await dial(tls13.url, { ca, maxVersion: "TLSv1.2" }), + min13to13: await dial(tls13.url, { ca, minVersion: "TLSv1.3" }), + }).toEqual({ min13to12: "refused", max12to12: anonymous, max12to13: "refused", min13to13: anonymous }); + }); + + test("an option that tls.connect() rejects throws", () => { + const url = "wss://localhost:1"; + // BoringSSL and OpenSSL word it differently. + expect(() => new WebSocket(url, { agent: new https.Agent({ pfx, passphrase: "wrong" }) })).toThrow(/mac verif/i); + expect(() => new WebSocket(url, { agent: new https.Agent({ minVersion: "TLSv9" as any }) })).toThrow( + expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_VERSION" }), + ); + }); + + test("top-level options reach the target through an HttpsProxyAgent", async () => { + using server = await serve(); + const connects: string[] = []; + const proxy = net.createServer(client => { + client.on("error", () => {}); + client.once("data", head => { + const target = head.toString("latin1").split(" ")[1]; + connects.push(target); + const upstream = net.connect(Number(target.split(":")[1]), "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection established\r\n\r\n"); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + }); + }); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + try { + const agent = new HttpsProxyAgent(`http://127.0.0.1:${(proxy.address() as AddressInfo).port}`); + expect(await dial(server.url, { agent, ca, pfx, passphrase: "sample" })).toBe(identified); + expect(connects).toEqual([server.url.slice("wss://".length)]); + } finally { + proxy.close(); + } + }); + + test.skipIf(!isBun)("an explicit tls option replaces the agent's and the top-level options", async () => { + using server = await serve(); + const off = { rejectUnauthorized: false }; + expect(await dial(server.url, { ...off })).toBe(anonymous); + expect(await dial(server.url, { ...off, agent: new https.Agent(off), tls: { ca: ca1 } })).toBe("refused"); + expect(await dial(server.url, { ca: ca1, agent: new https.Agent({ ca: ca1 }), tls: { ca } })).toBe(anonymous); + }); +}); + +describe.concurrent("ws with a pfx that bundles a CA", () => { + // agent1's key and certificate, with the server's certificate as the CA. From the repository root: + // bun -e 'await Bun.write("/tmp/server.pem", (await import("./test/harness.ts")).tls.cert)' + // openssl pkcs12 -export -passout pass:sample -certfile /tmp/server.pem \ + // -inkey test/js/node/test/fixtures/keys/agent1-key.pem -in test/js/node/test/fixtures/keys/agent1-cert.pem \ + // -out test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx + const pfxWithServerCa = readFileSync(join(import.meta.dirname, "fixtures/agent1-with-server-ca.pfx")); + + test("adds the CA to the ca option", async () => { + using server = await serve(); + expect(await dial(server.url, { ca: ca1, pfx: pfxWithServerCa, passphrase: "sample" })).toBe(identified); + expect(await dial(server.url, { ca: [ca1], pfx: [{ buf: pfxWithServerCa, passphrase: "sample" }] })).toBe(identified); // prettier-ignore + // Node adds it to the default store too. The native `ca` can only replace that store, so Bun leaves it out. + if (isBun) expect(await dial(server.url, { pfx: pfxWithServerCa, passphrase: "sample" })).toBe("refused"); + }); + + // `openssl x509 -subject_hash` of the server's certificate. + const hashedName = "c62891c1.0"; + test.each([ + ["NODE_EXTRA_CA_CERTS", "server.pem", true], + // Node reads these two with --use-openssl-ca only. + ["SSL_CERT_FILE", "server.pem", isBun], + ["SSL_CERT_DIR", "hashed", isBun], + ] as const)("keeps trusting %s", async (name, path, applies) => { + if (!applies) return; + using server = await serve(); + using dir = tempDir("ws-pfx-default-store", { "server.pem": ca, [`hashed/${hashedName}`]: ca }); + const child = spawn( + bunExe(), + [ + "-e", + ` + const WebSocket = require("ws"); + const ws = new WebSocket(process.env.TEST_URL, { pfx: require("fs").readFileSync(process.env.TEST_PFX), passphrase: "sample" }); + ws.on("message", data => { console.log(String(data)); ws.terminate(); }); + ws.on("error", err => console.log("refused", err.message)); + `, + ], + { + cwd: import.meta.dirname, + env: { + ...bunEnv, + TEST_URL: server.url, + TEST_PFX: join(import.meta.dirname, "../../node/test/fixtures/keys/agent1.pfx"), + [name]: join(String(dir), path), + }, + stdio: ["ignore", "pipe", "inherit"], + }, + ); + let stdout = ""; + child.stdout.on("data", chunk => (stdout += chunk)); + const [exitCode] = await once(child, "exit"); + expect(stdout.trim()).toBe(identified); + expect(exitCode).toBe(0); + }); +}); From a2d571b277ab747c23d0b02d441086728ebeebef Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:20:31 +0000 Subject: [PATCH 025/174] websocket: honor tls.serverName and call tls.checkServerIdentity on the client handshake (#41648) `new WebSocket(url, { tls })` took the SNI and the verified name from the URL only and never read `tls.checkServerIdentity`, so a pin that returns an Error still opened the connection. `serverName` now sets the SNI and the verified name, also inside a proxy tunnel; an HTTPS proxy keeps its own name. The callback replaces the built-in name check for the target. `CppWebSocket` gives the one verdict for every handshake (direct, proxy hop, tunnel, renegotiation). The callback lives in a slot of the WebSocket that its JS wrapper marks, so a callback that captures the WebSocket is still collected with it. A call that `JSValue::call` dropped (stopping VM, disposed graph, retired `--isolate` realm) is not an approval: `call_may_have_been_dropped` sits next to `call` and shares its conditions. --- packages/bun-types/bun.d.ts | 19 +- src/http_jsc/websocket_client.rs | 29 +- src/http_jsc/websocket_client/CppWebSocket.rs | 146 ++++ .../websocket_client/WebSocketProxyTunnel.rs | 102 ++- .../WebSocketUpgradeClient.rs | 184 ++-- src/jsc/JSValue.rs | 23 +- src/jsc/bindings/webcore/JSWebSocket.cpp | 27 + src/jsc/bindings/webcore/JSWebSocket.h | 1 + src/jsc/bindings/webcore/WebSocket.cpp | 10 + src/jsc/bindings/webcore/WebSocket.h | 11 + test/integration/bun-types/bun-types.test.ts | 22 +- .../bun-types/fixture/websocket.ts | 20 + .../module-graph-callbacks.test.ts | 32 +- test/js/web/websocket/proxy-test-utils.ts | 6 + .../websocket-tls-server-identity.test.ts | 812 ++++++++++++++++++ test/js/web/websocket/websocket-unix.test.ts | 30 + 16 files changed, 1328 insertions(+), 146 deletions(-) create mode 100644 test/js/web/websocket/websocket-tls-server-identity.test.ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index c3aef5aca399..0389dcd7afc5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4999,7 +4999,24 @@ declare module "bun" { * }); * ``` */ - tls?: TLSOptions; + tls?: TLSOptions & { + /** + * The Node.js spelling of `serverName`. + */ + servername?: string; + /** + * Replaces the built-in hostname check, as in `fetch()` and `tls.connect()`. + * Any truthy return value rejects the server, including the Promise of an + * `async` function. + * Through an HTTPS proxy it runs for the target's certificate only. + * With `rejectUnauthorized: false` it still runs when the certificate + * chain verified, but its result is not enforced. + * @param hostname - The name the certificate is verified against: `serverName` if set, else the URL host + * @param cert - The leaf certificate of the server, as in `fetch()`. Unlike in `tls.connect()`, it has no `issuerCertificate` + * @returns An error if the server is unauthorized, otherwise undefined + */ + checkServerIdentity?: NonNullable; + }; }; type WebSocketOptionsHeaders = { diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index 5d3c0ee6d257..d41a6ba63824 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -11,7 +11,6 @@ use core::cell::{Cell, RefCell}; use core::ffi::{c_int, c_void}; use core::mem::size_of; -use bun_boringssl as boringssl; use bun_boringssl::c::OwnedSslCtx; use bun_collections::LinearFifo; use bun_collections::linear_fifo::DynamicBuffer; @@ -23,7 +22,7 @@ use bun_ptr::{BackRef, JsCell, RefPtr, Root, ThisPtr}; use bun_uws::{self as uws, NewSocketHandler, us_bun_verify_error_t}; use bun_uws_sys::us_socket_t; -use self::cpp_websocket::{CppWebSocket, CppWebSocketRef}; +use self::cpp_websocket::{CppWebSocket, CppWebSocketRef, TlsHandshake}; use self::websocket_deflate::WebSocketDeflate; use self::websocket_proxy_tunnel::WebSocketProxyTunnel; @@ -253,31 +252,17 @@ impl WebSocket { ) { jsc::mark_binding!(); - let authorized = success == 1; - log!("onHandshake({})", success); let Some(ws) = self.cpp_websocket() else { return; }; - if !ws.reject_unauthorized() { - // We accept the connection regardless of SSL errors. - return; - } - - if ssl_error.error_no != 0 || !authorized { - self.fail(ErrorCode::FailedToConnect); - return; - } - - // Fail closed: without the SSL handle or a name to check against we - // cannot verify the peer. - let Some(ssl) = socket.ssl_mut() else { - self.fail(ErrorCode::FailedToConnect); - return; - }; - let hostname = &self.verified_hostname; - if hostname.is_empty() || !boringssl::check_server_identity(ssl, hostname) { + if !ws.accepts_tls_peer( + TlsHandshake::Renegotiation, + socket.ssl_mut(), + success == 1 && ssl_error.error_no == 0, + &self.verified_hostname, + ) { self.fail(ErrorCode::FailedToConnect); } } diff --git a/src/http_jsc/websocket_client/CppWebSocket.rs b/src/http_jsc/websocket_client/CppWebSocket.rs index 5c0b298f3591..d241f02dfa30 100644 --- a/src/http_jsc/websocket_client/CppWebSocket.rs +++ b/src/http_jsc/websocket_client/CppWebSocket.rs @@ -8,10 +8,12 @@ //! //! Note: This is specifically for WebSocket client implementations, not for server-side WebSockets. +use bun_boringssl as boringssl; use bun_boringssl::c::OwnedSslCtx; use bun_core::ffi::FfiSlice; use bun_core::{EncodedSlice, String as BunString}; use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::{ContextId, JSGlobalObject, JSValue, JsResult, VirtualMachineRef}; use bun_ptr::ThisPtr; use bun_uws_sys::Socket; @@ -23,6 +25,35 @@ bun_opaque::opaque_ffi! { pub struct CppWebSocket; } +/// Whose certificate a TLS handshake presented. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum TlsPeer { + /// The WebSocket server, reached directly or inside a proxy tunnel. + Target, + /// An HTTPS proxy. `tls.checkServerIdentity` is for the target only, as in fetch. + Proxy, +} + +/// Who decides whether a certificate whose chain verified names the server. +enum NameCheck { + /// `rejectUnauthorized: false` and no callback. + Nobody, + BuiltIn, + /// `tls.checkServerIdentity`. Without `enforce` it still runs, as in Node, and its verdict is dropped. + Callback { + callback: JSValue, + enforce: bool, + }, +} + +#[derive(Clone, Copy)] +pub(crate) enum TlsHandshake { + /// `tls.checkServerIdentity` runs in the context of the script that made the WebSocket. + First { context: ContextId, peer: TlsPeer }, + /// On the connected client's socket. BoringSSL refuses a changed certificate here. + Renegotiation, +} + /// Matches `WebCore::WebSocket::HandshakeRawHeader` (WebSocket.h). #[repr(C)] pub struct RawHeader<'a> { @@ -75,10 +106,17 @@ unsafe extern "C" { opcode: u8, ); safe fn WebSocket__rejectUnauthorized(websocket_context: &CppWebSocket) -> bool; + safe fn WebSocket__isProxyTLS(websocket_context: &CppWebSocket) -> bool; safe fn WebSocket__bunContext(websocket_context: &CppWebSocket) -> *const core::ffi::c_void; + safe fn WebSocket__checkServerIdentity(websocket_context: &CppWebSocket) -> JSValue; safe fn WebSocket__holdPendingActivityForClient(websocket_context: &CppWebSocket); safe fn WebSocket__releasePendingActivityForClient(websocket_context: &CppWebSocket); safe fn WebSocket__setProtocol(websocket_context: &CppWebSocket, protocol: BunString); + // Also declared in `bun_runtime::api::bun::x509`, which is above this crate. + safe fn Bun__X509__toJSLegacyEncoding( + cert: &mut boringssl::c::X509, + global_object: &JSGlobalObject, + ) -> JSValue; } // Receivers are `&self` (not `&mut self`) because `CppWebSocket` is @@ -150,6 +188,70 @@ impl CppWebSocket { WebSocket__rejectUnauthorized(self) } + /// Rooted through the JS wrapper, which is alive while the socket is. + fn check_server_identity(&self) -> Option { + let callback = WebSocket__checkServerIdentity(self); + (!callback.is_empty_or_undefined_or_null() && callback.is_callable()).then_some(callback) + } + + fn name_check(&self, peer: TlsPeer) -> NameCheck { + let enforce = self.reject_unauthorized(); + let callback = match peer { + TlsPeer::Target => self.check_server_identity(), + TlsPeer::Proxy => None, + }; + match callback { + Some(callback) => NameCheck::Callback { callback, enforce }, + None if enforce => NameCheck::BuiltIn, + None => NameCheck::Nobody, + } + } + + /// The name check inside the handshake. `Unchecked` leaves the name to `accepts_tls_peer`. + pub(crate) fn server_identity( + &self, + peer: TlsPeer, + ssl: &mut boringssl::c::SSL, + hostname: &[u8], + ) -> boringssl::ServerIdentity { + match self.name_check(peer) { + NameCheck::BuiltIn => boringssl::server_identity(ssl, Some(hostname)), + NameCheck::Nobody | NameCheck::Callback { .. } => boringssl::ServerIdentity::Unchecked, + } + } + + /// The one verdict on a completed TLS handshake. May run JS that closes the WebSocket. + pub(crate) fn accepts_tls_peer( + &self, + handshake: TlsHandshake, + ssl: Option<&mut boringssl::c::SSL>, + chain_verified: bool, + hostname: &[u8], + ) -> bool { + if !chain_verified { + // As in Node, the callback never sees a chain that did not verify. + return !self.reject_unauthorized(); + } + let peer = match handshake { + TlsHandshake::First { peer, .. } => peer, + // Without a tunnel, that socket goes to the target or to the HTTPS proxy of a ws:// target. + TlsHandshake::Renegotiation if WebSocket__isProxyTLS(self) => TlsPeer::Proxy, + TlsHandshake::Renegotiation => TlsPeer::Target, + }; + match (self.name_check(peer), handshake) { + (NameCheck::Nobody, _) | (NameCheck::Callback { .. }, TlsHandshake::Renegotiation) => { + true + } + (NameCheck::BuiltIn, _) => ssl.is_some_and(|ssl| { + !hostname.is_empty() && bun_uws::check_server_identity(ssl, hostname) + }), + (NameCheck::Callback { callback, enforce }, TlsHandshake::First { context, .. }) => { + ssl.is_some_and(|ssl| run_check_server_identity(context, callback, ssl, hostname)) + || !enforce + } + } + } + /// `buffered_data` and `secure` are handed on to the connected client. pub(crate) fn did_connect( &self, @@ -203,6 +305,50 @@ impl CppWebSocket { } } +fn run_check_server_identity( + context: ContextId, + callback: JSValue, + ssl: &mut boringssl::c::SSL, + hostname: &[u8], +) -> bool { + let vm = VirtualMachineRef::get(); + let _context = vm.enter_context(context); + let event_loop = vm.event_loop_mut(); + event_loop.enter(); + let verdict = match call_check_server_identity(vm.global(), callback, ssl, hostname) { + Ok(approved) => approved, + Err(err) => { + let _ = bun_jsc::task::report_error_or_terminate(vm.global(), err); + false + } + }; + event_loop.exit(); + verdict +} + +/// `Ok(true)` only if the callback ran and returned a falsy value. +fn call_check_server_identity( + global: &JSGlobalObject, + callback: JSValue, + ssl: &mut boringssl::c::SSL, + hostname: &[u8], +) -> JsResult { + let Some(cert) = ssl.peer_leaf_certificate() else { + return Ok(false); + }; + let js_cert = + bun_jsc::from_js_host_call(global, || Bun__X509__toJSLegacyEncoding(cert, global))?; + let js_hostname = bun_jsc::bun_string_jsc::create_utf8_for_js(global, hostname)?; + let verdict = callback.call(global, JSValue::UNDEFINED, &[js_hostname, js_cert])?; + js_hostname.ensure_still_alive(); + js_cert.ensure_still_alive(); + if callback.call_may_have_been_dropped(global) { + return Ok(false); + } + // As in `tls.connect()` (src/js/node/net.ts), any truthy verdict rejects: an async callback's Promise too. + Ok(!verdict.is_truthy()) +} + /// RAII owner of one pending-activity ref on a C++ `WebCore::WebSocket`. /// /// Construction calls [`CppWebSocket::r#ref`]; `Drop` calls diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 38f1e22846a2..3e00ec8f7864 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -96,6 +96,43 @@ impl UpgradeClientRef { HttpsUpgradeClient::on_proxy_tls_handshake_complete, ); } + + fn tunnel_server_identity( + self, + ssl: &mut bun_boringssl::c::SSL, + hostname: &[u8], + ) -> bun_boringssl::ServerIdentity { + match self { + UpgradeClientRef::Http(client) => { + HttpUpgradeClient::tunnel_server_identity(client.this_ptr(), ssl, hostname) + } + UpgradeClientRef::Https(client) => { + HttpsUpgradeClient::tunnel_server_identity(client.this_ptr(), ssl, hostname) + } + } + } + + fn accepts_tunnel_peer( + self, + ssl: Option<&mut bun_boringssl::c::SSL>, + chain_verified: bool, + hostname: &[u8], + ) -> bool { + match self { + UpgradeClientRef::Http(client) => HttpUpgradeClient::accepts_tunnel_peer( + client.this_ptr(), + ssl, + chain_verified, + hostname, + ), + UpgradeClientRef::Https(client) => HttpsUpgradeClient::accepts_tunnel_peer( + client.this_ptr(), + ssl, + chain_verified, + hostname, + ), + } + } } type WebSocketClient = crate::websocket_client::WebSocket; @@ -117,8 +154,6 @@ pub struct WebSocketProxyTunnel { write_buffer: JsCell, /// Hostname for SNI (Server Name Indication) sni_hostname: Option>, - /// Whether to reject unauthorized certificates - reject_unauthorized: bool, } use bun_uws::MaybeAnySocket as SocketUnion; @@ -131,7 +166,6 @@ impl WebSocketProxyTunnel { upgrade_client: UpgradeClientRef, socket: NewSocketHandler, sni_hostname: &[u8], - reject_unauthorized: bool, ) -> RefPtr { // `assume_ssl`/`assume_tcp` rebuild the handler around the same // `InternalSocket`. @@ -151,7 +185,6 @@ impl WebSocketProxyTunnel { sni_hostname: Some(Box::<[u8]>::from(bun_http::strip_ipv6_brackets( sni_hostname, ))), - reject_unauthorized, }) } @@ -164,11 +197,12 @@ impl WebSocketProxyTunnel { pub(crate) fn start( this: ThisPtr, ssl_options: &SslConfig, + reject_unauthorized: bool, initial_data: &[u8], ) -> crate::Result<()> { // Allow handshake to complete so we can access peer certificate for manual // hostname verification in onHandshake(). The actual reject_unauthorized - // check uses self.reject_unauthorized field. + // check is the WebSocket's: `CppWebSocket::accepts_tls_peer`. let options = ssl_options.for_client_verification(); // tier-neutral `init_from_options` takes the lowered @@ -197,7 +231,7 @@ impl WebSocketProxyTunnel { let wrapper = this.wrapper.get_or_init(|| wrapper); // The inner connection's form of the `set_inline_reject` call in // `WebSocketUpgradeClient::handle_open`. - if this.reject_unauthorized { + if reject_unauthorized { wrapper.set_inline_reject(); } let ssl = wrapper.ssl.get(); @@ -240,18 +274,20 @@ impl WebSocketProxyTunnel { Ok(()) } - /// SSLWrapper callback: Called before TLS handshake starts + /// SSLWrapper callback: the name check inside the handshake. The WebSocket decides. fn server_identity( this: ThisPtr, ssl: &mut bun_boringssl::c::SSL, ) -> bun_boringssl::ServerIdentity { - let hostname = this - .sni_hostname - .as_deref() - .filter(|_| this.reject_unauthorized); - bun_boringssl::server_identity(ssl, hostname) + match (this.upgrade_client.get(), this.sni_hostname.as_deref()) { + (Some(upgrade_client), Some(hostname)) => { + upgrade_client.tunnel_server_identity(ssl, hostname) + } + _ => bun_boringssl::ServerIdentity::Unchecked, + } } + /// SSLWrapper callback: Called before TLS handshake starts fn on_open(this: ThisPtr) { let _guard = RefPtr::from_this(this); bun_core::scoped_log!(WebSocketProxyTunnel, "onOpen"); @@ -298,10 +334,7 @@ impl WebSocketProxyTunnel { // Snapshot the fields we need; `terminate()` / `on_proxy_tls_handshake_complete()` // re-enter `tunnel.detach_upgrade_client()` / `tunnel.write()`, so no borrow of // `*this` may span the dispatch. - let (upgrade_client, reject_unauthorized) = - (this.upgrade_client.get(), this.reject_unauthorized); - - let Some(upgrade_client) = upgrade_client else { + let Some(upgrade_client) = this.upgrade_client.get() else { return; }; @@ -310,26 +343,23 @@ impl WebSocketProxyTunnel { return; } - // Check for SSL errors if we need to reject unauthorized - if reject_unauthorized { - if ssl_error.error_no != 0 { - upgrade_client.terminate(ErrorCode::TlsHandshakeFailed); - return; - } - - // Verify server identity. - let ssl = this.wrapper.get().and_then(|w| w.ssl.get()); - let failed_identity = match (ssl, this.sni_hostname.as_deref()) { - (Some(ssl_ptr), Some(hostname)) => !bun_uws::check_server_identity( - bun_opaque::opaque_deref_mut(ssl_ptr.as_ptr()), - hostname, - ), - _ => false, - }; - if failed_identity { - upgrade_client.terminate(ErrorCode::TlsHandshakeFailed); - return; - } + let ssl = this + .wrapper + .get() + .and_then(|w| w.ssl.get()) + .map(|ssl| bun_opaque::opaque_deref_mut(ssl.as_ptr())); + let accepted = upgrade_client.accepts_tunnel_peer( + ssl, + ssl_error.error_no == 0, + this.sni_hostname.as_deref().unwrap_or_default(), + ); + // `tls.checkServerIdentity` may have closed the WebSocket, which detaches the client. + let Some(upgrade_client) = this.upgrade_client.get() else { + return; + }; + if !accepted { + upgrade_client.terminate(ErrorCode::TlsHandshakeFailed); + return; } // TLS handshake successful - notify client to send WebSocket upgrade diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 9ba258305480..ef61eb74640b 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -39,7 +39,7 @@ use bun_ptr::{BackRef, JsCell, RefPtr, ThisPtr}; use super::websocket_proxy_tunnel::IntoUpgradeClientRef; use bun_uws::{self as uws, SocketHandler, SocketKind}; -use super::cpp_websocket::CppWebSocket; +use super::cpp_websocket::{CppWebSocket, TlsHandshake, TlsPeer}; use super::websocket_deflate as WebSocketDeflate; use super::websocket_proxy::WebSocketProxy; use super::websocket_proxy_tunnel::WebSocketProxyTunnel; @@ -121,8 +121,10 @@ pub struct HTTPClient { to_send_len: Cell, headers_buf: JsCell<[picohttp::Header; 128]>, body: JsCell>, - /// Owned NUL-terminated hostname for SNI; empty when unset. + /// SNI and verified name: `tls.serverName`, else the dialed host. Empty when unset. hostname: JsCell, + /// The context of the script that made the WebSocket. `checkServerIdentity` runs in it. + context: bun_jsc::ContextId, poll_ref: JsCell, state: Cell, subprotocols: JsCell, @@ -201,6 +203,13 @@ where debug_assert!(vm.event_loop_handle.is_some()); + let server_name: Option> = ssl_config + .as_deref() + .and_then(SSLConfig::server_name_bytes) + .map(bun_http::strip_ipv6_brackets) + .filter(|name| !name.is_empty()) + .map(Box::from); + // Decode all BunString inputs into UTF-8 slices. The underlying // JavaScript strings may be Latin1 or UTF-16; `String.to_utf8()` either // borrows the 8-bit ASCII backing (no allocation) or allocates a @@ -274,7 +283,10 @@ where ); // Duplicate target_host (needed for SNI during TLS handshake). - let target_host_dup: Box<[u8]> = Box::from(host_slice.slice()); + let target_host_dup: Box<[u8]> = match &server_name { + Some(name) => name.clone(), + None => Box::from(host_slice.slice()), + }; let proxy = WebSocketProxy::init( target_host_dup, @@ -315,10 +327,11 @@ where ); let loop_ = global.bun_vm().uws_loop(); + let context = websocket.context(); let group = global .bun_vm() .as_mut() - .client_socket_groups_in(websocket.context()) + .client_socket_groups_in(context) .ws_upgrade_group::(loop_); let kind: SocketKind = if SSL { SocketKind::WsClientUpgradeTls @@ -378,6 +391,7 @@ where headers_buf: JsCell::new([picohttp::Header::ZERO; 128]), body: JsCell::new(Vec::new()), hostname: JsCell::new(ZBox::default()), + context: context.id(), poll_ref: JsCell::new(poll_ref), state: Cell::new(State::Initializing), proxy: JsCell::new(proxy_state), @@ -409,17 +423,13 @@ where bun_analytics::features::web_socket.fetch_add(1, core::sync::atomic::Ordering::Relaxed); if SSL { - // SNI uses the URL host (defaulted to "localhost" in - // C++ when absent), mirroring the TCP path below. A - // user-supplied Host header does NOT affect SNI; use - // `tls: { checkServerIdentity }` or put the hostname - // in the URL (wss+unix://name/path) to verify against - // a specific certificate name. - if !host_slice.slice().is_empty() { - this.hostname - .set(ZBox::from_bytes(bun_http::strip_ipv6_brackets( - host_slice.slice(), - ))); + // A user-supplied Host header does NOT affect SNI. + let sni: &[u8] = match &server_name { + Some(name) => name, + None => bun_http::strip_ipv6_brackets(host_slice.slice()), + }; + if !sni.is_empty() { + this.hostname.set(ZBox::from_bytes(sni)); } } @@ -447,14 +457,13 @@ where bun_analytics::features::web_socket.fetch_add(1, core::sync::atomic::Ordering::Relaxed); if SSL { - // SNI for the outer TLS socket must use the host we actually - // dialed. For HTTPS proxy connections, that's the proxy host, - // not the wss:// target. - if !display_host.is_empty() { - this.hostname - .set(ZBox::from_bytes(bun_http::strip_ipv6_brackets( - display_host, - ))); + // Through a proxy this socket's peer is the proxy; `server_name` names the target. + let sni: &[u8] = match &server_name { + Some(name) if !using_proxy => name, + _ => bun_http::strip_ipv6_brackets(display_host), + }; + if !sni.is_empty() { + this.hostname.set(ZBox::from_bytes(sni)); } } @@ -593,34 +602,33 @@ where ); let handshake_success = success == 1; - let reject_unauthorized = this - .cpp_websocket() - .is_some_and(|ws| ws.reject_unauthorized()); if handshake_success { - // handshake completed but we may have ssl errors - if reject_unauthorized { - // only reject the connection if reject_unauthorized == true - if ssl_error.error_no != 0 { - log!( - "TLS handshake failed: ssl_error={}, has_custom_ctx={}", - ssl_error.error_no, - this.secure.get().is_some() - ); - Self::fail(this, ErrorCode::TlsHandshakeFailed); - return; - } - let Some(ssl) = socket.ssl_mut() else { - // No SSL object to verify against — treat as handshake failure. - Self::fail(this, ErrorCode::TlsHandshakeFailed); - return; - }; - let hostname = this.identity_hostname(ssl); - let identity_ok = - !hostname.is_empty() && uws::check_server_identity(ssl, &hostname); - if !identity_ok { - Self::fail(this, ErrorCode::TlsHandshakeFailed); - } + let Some(ws) = this.cpp_websocket() else { + return; + }; + // `tls.checkServerIdentity` may close the WebSocket, which frees `this`. + let _guard = RefPtr::from_this(this); + // That close clears `hostname`, so the name is moved out while the verdict borrows it. + let hostname = this.hostname.take(); + let ssl = socket.ssl_mut(); + let name = identity_name(hostname.as_bytes(), ssl.as_deref()); + let handshake = TlsHandshake::First { + context: this.context, + peer: this.tls_peer(), + }; + let accepted = ws.accepts_tls_peer(handshake, ssl, ssl_error.error_no == 0, &name); + if this.cpp_websocket().is_none() { + return; + } + this.hostname.set(hostname); + if !accepted { + log!( + "TLS handshake failed: ssl_error={}, has_custom_ctx={}", + ssl_error.error_no, + this.secure.get().is_some() + ); + Self::fail(this, ErrorCode::TlsHandshakeFailed); } } else { // if we are here is because server rejected us, and the error_no is the cause of this @@ -629,28 +637,51 @@ where } } - /// `handle_handshake`'s name check, asked inside the handshake. + /// The name check inside the handshake. See `CppWebSocket::server_identity`. pub fn server_identity(&self, ssl: &mut boringssl::c::SSL) -> boringssl::ServerIdentity { - let rejects = self - .cpp_websocket() - .is_some_and(|ws| ws.reject_unauthorized()); - let hostname = rejects.then(|| self.identity_hostname(ssl)); - boringssl::server_identity(ssl, hostname.as_deref()) + let Some(ws) = self.cpp_websocket() else { + return boringssl::ServerIdentity::Unchecked; + }; + let hostname = identity_name(self.hostname.get().as_bytes(), Some(ssl)); + ws.server_identity(self.tls_peer(), ssl, &hostname) } - /// The name to match: the dialed host, else the SNI. - fn identity_hostname(&self, ssl: &boringssl::c::SSL) -> std::borrow::Cow<'_, [u8]> { - let own_hostname = self.hostname.get(); - if !own_hostname.is_empty() { - own_hostname.as_bytes().into() + /// Through a proxy this socket's peer is the proxy. The tunnel handshakes with the target. + fn tls_peer(&self) -> TlsPeer { + if self.proxy.get().is_some() { + TlsPeer::Proxy } else { - ssl.servername() - .map(<[u8]>::to_vec) - .unwrap_or_default() - .into() + TlsPeer::Target } } + /// `server_identity` for the tunnel, whose peer is the target. + pub(crate) fn tunnel_server_identity( + this: ThisPtr, + ssl: &mut boringssl::c::SSL, + hostname: &[u8], + ) -> boringssl::ServerIdentity { + this.cpp_websocket() + .map_or(boringssl::ServerIdentity::Unchecked, |ws| { + ws.server_identity(TlsPeer::Target, ssl, hostname) + }) + } + + /// `accepts_tls_peer` for the tunnel, whose peer is the target. + pub(crate) fn accepts_tunnel_peer( + this: ThisPtr, + ssl: Option<&mut boringssl::c::SSL>, + chain_verified: bool, + hostname: &[u8], + ) -> bool { + let handshake = TlsHandshake::First { + context: this.context, + peer: TlsPeer::Target, + }; + this.cpp_websocket() + .is_some_and(|ws| ws.accepts_tls_peer(handshake, ssl, chain_verified, hostname)) + } + /// Takes `ThisPtr` because `terminate` may free `this`; see `fail`. pub fn handle_open(this: ThisPtr, socket: Socket) { log!("onOpen"); @@ -938,7 +969,6 @@ where Self::upgrade_client_ref(this), socket, p.get_target_host(), - reject_unauthorized, ) }); let Some(tunnel) = tunnel else { @@ -958,7 +988,14 @@ where }; // Start TLS handshake - if WebSocketProxyTunnel::start(tunnel.this_ptr(), &ssl_options, initial_data).is_err() { + if WebSocketProxyTunnel::start( + tunnel.this_ptr(), + &ssl_options, + reject_unauthorized, + initial_data, + ) + .is_err() + { drop(tunnel); Self::terminate(this, ErrorCode::ProxyTunnelFailed); return; @@ -1822,6 +1859,21 @@ fn compute_accept_value(key: &[u8]) -> [u8; 28] { result } +/// The name to match: `hostname` (`tls.serverName`, else the dialed host), else the SNI. +fn identity_name<'a>( + hostname: &'a [u8], + ssl: Option<&boringssl::c::SSL>, +) -> std::borrow::Cow<'a, [u8]> { + if !hostname.is_empty() { + hostname.into() + } else { + ssl.and_then(|ssl| ssl.servername()) + .map(<[u8]>::to_vec) + .unwrap_or_default() + .into() + } +} + // LAYERING: `Bun__WebSocket__parseSSLConfig` / `Bun__WebSocket__freeSSLConfig` // live in `bun_runtime::socket::ssl_config` (src/runtime/socket/SSLConfig.rs). // `SSLConfig::from_js` walks Blob/JSCArrayBuffer/node_fs values (tier-6) and diff --git a/src/jsc/JSValue.rs b/src/jsc/JSValue.rs index 70c5c377612d..99857aead1a1 100644 --- a/src/jsc/JSValue.rs +++ b/src/jsc/JSValue.rs @@ -1721,12 +1721,7 @@ impl JSValue { this_value: JSValue, args: &[JSValue], ) -> JsResult { - // A `Bun.ModuleGraph` that was disposed hears nothing more from native code, nor does a - // test file that finished (only `--isolate` retires a realm). - let vm = global.bun_vm(); - if vm.calls_nobody() - || (vm.test_isolation_enabled && self.is_from_retired_test_isolation_realm()) - { + if self.has_nobody_to_call(global) { return Ok(JSValue::UNDEFINED); } host_fn::from_js_host_call(global, || { @@ -1735,6 +1730,22 @@ impl JSValue { unsafe { Bun__JSValue__call(global, self, this_value, args.len(), args.as_ptr()) } }) } + + /// A `Bun.ModuleGraph` that was disposed hears nothing more from native code, nor does a + /// test file that finished (only `--isolate` retires a realm). + #[inline] + fn has_nobody_to_call(self, global: &JSGlobalObject) -> bool { + let vm = global.bun_vm(); + vm.calls_nobody() + || (vm.test_isolation_enabled && self.is_from_retired_test_isolation_realm()) + } + + /// Whether the `undefined` that [`call`](Self::call) just returned may be its silent no-op, for a caller that reads it as consent. + pub fn call_may_have_been_dropped(self, global: &JSGlobalObject) -> bool { + self.has_nobody_to_call(global) + || !global.bun_vm().script_allowed() + || global.vm().execution_forbidden() + } } /// RAII guard returned by [`JSValue::protected`]. Calls [`JSValue::unprotect`] diff --git a/src/jsc/bindings/webcore/JSWebSocket.cpp b/src/jsc/bindings/webcore/JSWebSocket.cpp index 60993d8be5ff..ae0748c0e888 100644 --- a/src/jsc/bindings/webcore/JSWebSocket.cpp +++ b/src/jsc/bindings/webcore/JSWebSocket.cpp @@ -68,6 +68,7 @@ #include "JSDOMURL.h" #include "headers.h" #include "ObjectBindings.h" +#include "ErrorCode.h" namespace WebCore { using namespace JSC; @@ -221,6 +222,8 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG // Native heap SSLConfig. RAII — freed on any early return, moved into // WebSocket::create() on success. WebSocketSSLConfigPtr sslConfig; + // Set on the wrapper below; the options argument keeps it alive until then. + JSValue checkServerIdentity; auto headersInit = std::optional>, IDLRecord>>::ReturnType>(); // Default true — matches Bun's existing behavior of always offering permessage-deflate. // ws.WebSocket passes `perMessageDeflate: false` to opt out. @@ -274,6 +277,15 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG // Parse full TLS options using the native SSLConfig parser sslConfig = WebSocketSSLConfigPtr { Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(tlsOptionsValue)) }; RETURN_IF_EXCEPTION(throwScope, {}); + + // As in fetch(): a value that cannot run must not pass for a pin. + auto checkServerIdentityValue = Bun::getIfPropertyExistsPrototypePollutionMitigation(globalObject, tlsOptions, PropertyName(Identifier::fromString(vm, "checkServerIdentity"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (checkServerIdentityValue && !checkServerIdentityValue.isUndefinedOrNull()) { + if (!checkServerIdentityValue.isCallable()) + return Bun::ERR::INVALID_ARG_TYPE(throwScope, globalObject, "tls.checkServerIdentity"_s, "function"_s, checkServerIdentityValue); + checkServerIdentity = checkServerIdentityValue; + } } // Parse perMessageDeflate option (ws-compatible). Mirrors npm ws's @@ -344,6 +356,10 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *globalObject, throwScope, WTF::move(object)); if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); + if (checkServerIdentity) { + auto* wrapper = uncheckedDowncast(asObject(jsValue)); + wrapper->wrapped().setCheckServerIdentity(vm, wrapper, checkServerIdentity); + } setSubclassStructureIfNeeded(lexicalGlobalObject, callFrame, asObject(jsValue)); RETURN_IF_EXCEPTION(throwScope, {}); return JSValue::encode(jsValue); @@ -992,6 +1008,17 @@ JSC::GCClient::IsoSubspace* JSWebSocket::subspaceForImpl(JSC::VM& vm) return WebCore::subspaceForImpl(vm, BUN_SUBSPACE_SLOTS(m_clientSubspaceForWebSocket, m_subspaceForWebSocket)); } +template +void JSWebSocket::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + auto* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + thisObject->wrapped().checkServerIdentity().visit(visitor); +} + +DEFINE_VISIT_CHILDREN(JSWebSocket); + size_t JSWebSocket::estimatedSize(JSCell* cell, JSC::VM& vm) { auto* thisObject = uncheckedDowncast(cell); diff --git a/src/jsc/bindings/webcore/JSWebSocket.h b/src/jsc/bindings/webcore/JSWebSocket.h index f4e29bd79f38..683d3639a934 100644 --- a/src/jsc/bindings/webcore/JSWebSocket.h +++ b/src/jsc/bindings/webcore/JSWebSocket.h @@ -56,6 +56,7 @@ class JSWebSocket : public JSEventTarget { return subspaceForImpl(vm); } static JSC::GCClient::IsoSubspace* subspaceForImpl(JSC::VM& vm); + DECLARE_VISIT_CHILDREN; static void analyzeHeap(JSCell*, JSC::HeapAnalyzer&); static size_t estimatedSize(JSCell*, JSC::VM&); WebSocket& wrapped() const diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index e16ed2793218..d0e51df0b286 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -1799,6 +1799,11 @@ extern "C" bool WebSocket__rejectUnauthorized(WebCore::WebSocket* webSocket) return webSocket->rejectUnauthorized(); } +extern "C" bool WebSocket__isProxyTLS(WebCore::WebSocket* webSocket) +{ + return webSocket->isProxyTLS(); +} + // The Rust half of the context of the script that made the WebSocket. Called from connect(), // which has the context. extern "C" void* WebSocket__bunContext(WebCore::WebSocket* webSocket) @@ -1806,6 +1811,11 @@ extern "C" void* WebSocket__bunContext(WebCore::WebSocket* webSocket) return webSocket->scriptExecutionContext()->bunContext(); } +extern "C" JSC::EncodedJSValue WebSocket__checkServerIdentity(WebCore::WebSocket* webSocket) +{ + return JSC::JSValue::encode(webSocket->checkServerIdentity().getValue(JSC::jsUndefined())); +} + // The native client keeps this object (and its wrapper) alive across work it has queued that will // call back into it; one such claim at a time. extern "C" void WebSocket__holdPendingActivityForClient(WebCore::WebSocket* webSocket) diff --git a/src/jsc/bindings/webcore/WebSocket.h b/src/jsc/bindings/webcore/WebSocket.h index e6f152ad9119..b60aabaac094 100644 --- a/src/jsc/bindings/webcore/WebSocket.h +++ b/src/jsc/bindings/webcore/WebSocket.h @@ -39,6 +39,7 @@ #include #include "FetchHeaders.h" #include "WebSocketErrorCode.h" +#include "JSValueInWrappedObject.h" namespace WebCore { class JSBlob; @@ -285,6 +286,14 @@ class WebSocket final : public RefCounted, public EventTargetWithInli return m_rejectUnauthorized; } + bool isProxyTLS() const { return m_connectionType == ConnectionType::ProxyTLS; } + + const JSValueInWrappedObject& checkServerIdentity() const { return m_checkServerIdentity; } + void setCheckServerIdentity(JSC::VM& vm, const JSC::JSCell* wrapper, JSC::JSValue callback) + { + m_checkServerIdentity.set(vm, wrapper, callback); + } + size_t memoryCost() const; private: @@ -351,6 +360,8 @@ class WebSocket final : public RefCounted, public EventTargetWithInli // TLS options (native heap SSLConfig — ownership is released to the // upgrade client in connect(); freed by ~WebSocketSSLConfigPtr otherwise). WebSocketSSLConfigPtr m_sslConfig; + // `tls.checkServerIdentity`, marked by JSWebSocket::visitChildren. + JSValueInWrappedObject m_checkServerIdentity; NativeCallbacks m_native; }; diff --git a/test/integration/bun-types/bun-types.test.ts b/test/integration/bun-types/bun-types.test.ts index 38cbb1730958..8215aabd3fab 100644 --- a/test/integration/bun-types/bun-types.test.ts +++ b/test/integration/bun-types/bun-types.test.ts @@ -1082,57 +1082,57 @@ describe("@types/bun integration test", () => { }, { code: 2551, - line: "websocket.ts:192:17", + line: "websocket.ts:206:17", message: "Property 'URL' does not exist on type 'WebSocket'. Did you mean 'url'?", }, { code: 2322, - line: "websocket.ts:196:3", + line: "websocket.ts:210:3", message: "Type '\"nodebuffer\"' is not assignable to type 'BinaryType'.", }, { code: 2339, - line: "websocket.ts:242:6", + line: "websocket.ts:256:6", message: "Property 'ping' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:245:6", + line: "websocket.ts:259:6", message: "Property 'ping' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:249:6", + line: "websocket.ts:263:6", message: "Property 'ping' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:253:6", + line: "websocket.ts:267:6", message: "Property 'ping' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:256:6", + line: "websocket.ts:270:6", message: "Property 'pong' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:259:6", + line: "websocket.ts:273:6", message: "Property 'pong' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:263:6", + line: "websocket.ts:277:6", message: "Property 'pong' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:267:6", + line: "websocket.ts:281:6", message: "Property 'pong' does not exist on type 'WebSocket'.", }, { code: 2339, - line: "websocket.ts:270:6", + line: "websocket.ts:284:6", message: "Property 'terminate' does not exist on type 'WebSocket'.", }, { diff --git a/test/integration/bun-types/fixture/websocket.ts b/test/integration/bun-types/fixture/websocket.ts index b8de6edfc556..01105e5252b3 100644 --- a/test/integration/bun-types/fixture/websocket.ts +++ b/test/integration/bun-types/fixture/websocket.ts @@ -56,6 +56,20 @@ import { expectType } from "./utilities"; rejectUnauthorized: true, }, }); + + // tls.serverName and tls.checkServerIdentity, as in fetch() + const options = { + tls: { + serverName: "dev.local", + checkServerIdentity(hostname, cert) { + expectType(hostname); + expectType(cert.fingerprint256); + expectType(cert.raw); + return hostname === "dev.local" ? undefined : new Error("pin mismatch"); + }, + }, + } satisfies Bun.WebSocketOptions; + options.tls.checkServerIdentity("dev.local", {} as import("node:tls").PeerCertificate); } // Assignability test @@ -269,3 +283,9 @@ import { expectType } from "./utilities"; // Terminate the connection immediately ws.terminate(); } + +// tls.servername, the node spelling of tls.serverName +{ + const options = { tls: { servername: "dev.local" } } satisfies Bun.WebSocketOptions; + expectType(options.tls.servername); +} diff --git a/test/js/bun/module-graph/module-graph-callbacks.test.ts b/test/js/bun/module-graph/module-graph-callbacks.test.ts index 58157e675b90..88b7f88eb05f 100644 --- a/test/js/bun/module-graph/module-graph-callbacks.test.ts +++ b/test/js/bun/module-graph/module-graph-callbacks.test.ts @@ -3,7 +3,7 @@ // things at the same time. One entry per kind of callback; add one when adding an API that calls back. import { afterAll, beforeAll, expect, test } from "bun:test"; import { rmSync } from "fs"; -import { bunExe, tempDir } from "harness"; +import { bunExe, tempDir, tls as tlsCerts } from "harness"; import { AsyncLocalStorage } from "node:async_hooks"; import { join } from "path"; @@ -156,6 +156,14 @@ const dir = String( ws.addEventListener("message", () => { seen("message"); ws.close(); }); ws.addEventListener("close", () => { seen("close"); resolve(); }); }), + webSocketCheckServerIdentity: (seen, env) => + new Promise(resolve => { + const ws = new WebSocket("wss://localhost:" + env.httpsPort + "/ws", { + tls: { ca: env.ca, checkServerIdentity() { seen("checkServerIdentity"); } }, + }); + ws.onopen = () => ws.close(); + ws.onclose = resolve; + }), bunServe: async seen => { const closed = defer(); const server = Bun.serve({ @@ -273,8 +281,15 @@ const dir = String( ); afterAll(() => rmSync(dir, { recursive: true, force: true })); -let hostHttp: Bun.Server; +let hostHttp: Bun.Server; +let hostHttps: Bun.Server; beforeAll(() => { + hostHttps = Bun.serve({ + port: 0, + tls: tlsCerts, + fetch: (request, server) => (server.upgrade(request) ? undefined : new Response("no", { status: 400 })), + websocket: { message() {} }, + }); hostHttp = Bun.serve({ port: 0, fetch(request, server) { @@ -297,7 +312,10 @@ beforeAll(() => { websocket: { message: (ws, message) => void ws.send(message) }, }); }); -afterAll(() => hostHttp.stop(true)); +afterAll(() => { + hostHttp.stop(true); + hostHttps.stop(true); +}); test("every callback of several graphs and the host, all running at once, finds its own graph's context", async () => { const storage = new AsyncLocalStorage(); @@ -325,7 +343,13 @@ test("every callback of several graphs and the host, all running at once, finds runners.flatMap(({ tag, graph, app }) => names.map(name => { const seen = (event: string) => record(name + ": " + event, tag); - const env: Record = { tag, httpPort: hostHttp.port, bun: bunExe() }; + const env: Record = { + tag, + httpPort: hostHttp.port, + httpsPort: hostHttps.port, + ca: tlsCerts.cert, + bun: bunExe(), + }; if (name === "hostTarget") { const labels = [`host listener (dispatched from ${tag})`, `host abort listener (aborted from ${tag})`]; hostListenerLabels.push(...labels); diff --git a/test/js/web/websocket/proxy-test-utils.ts b/test/js/web/websocket/proxy-test-utils.ts index f00f58ea74e2..b0d5ec423579 100644 --- a/test/js/web/websocket/proxy-test-utils.ts +++ b/test/js/web/websocket/proxy-test-utils.ts @@ -161,6 +161,7 @@ export async function startProxy(server: net.Server): Promise { */ export async function startRecordingProxy(options: ConnectProxyOptions = {}) { const requests: ConnectRequest[] = []; + const sni: (string | null)[] = []; let connections = 0; const proxy = createConnectProxy({ ...options, @@ -172,10 +173,15 @@ export async function startRecordingProxy(options: ConnectProxyOptions = {}) { proxy.on("connection", () => { connections++; }); + proxy.on("secureConnection", (socket: tls.TLSSocket) => { + sni.push(socket.servername || null); + }); const port = await startProxy(proxy); return { port, requests, + /** With `tls: true`, the SNI of every handshake the proxy completed. `null` when the client sent none. */ + sni, get connections() { return connections; }, diff --git a/test/js/web/websocket/websocket-tls-server-identity.test.ts b/test/js/web/websocket/websocket-tls-server-identity.test.ts new file mode 100644 index 000000000000..1b6326176e82 --- /dev/null +++ b/test/js/web/websocket/websocket-tls-server-identity.test.ts @@ -0,0 +1,812 @@ +import { heapStats } from "bun:jsc"; +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, nodeExe, tls as tlsCerts } from "harness"; +import { createHash, X509Certificate } from "node:crypto"; +import { once } from "node:events"; +import net, { type AddressInfo } from "node:net"; +import tls from "node:tls"; +import { Worker } from "node:worker_threads"; +import { WebSocket as WsPackageWebSocket } from "ws"; +import { clientEvents, startRecordingProxy } from "./proxy-test-utils"; + +// NO_PROXY applies to explicit proxies too. An ambient +// NO_PROXY=localhost,127.0.0.1,... would bypass the proxy tests below. +const prevNoProxy = process.env.NO_PROXY; +const prevNoProxyLower = process.env.no_proxy; +process.env.NO_PROXY = ""; +process.env.no_proxy = ""; +afterAll(() => { + if (prevNoProxy === undefined) delete process.env.NO_PROXY; + else process.env.NO_PROXY = prevNoProxy; + if (prevNoProxyLower === undefined) delete process.env.no_proxy; + else process.env.no_proxy = prevNoProxyLower; +}); + +// A TLS server that records the SNI of every handshake and answers one +// WebSocket upgrade per connection. The harness certificate has CN=server-bun +// and SAN DNS:localhost, IP:127.0.0.1, IP:::1. With `hold`, a handshake that +// sends SNI stops at the ClientHello until `hold` resolves. +function startSniServer({ + requestCert = false, + hold, + version, +}: { requestCert?: boolean; hold?: Promise; version?: tls.SecureVersion } = {}) { + const sni: (string | null)[] = []; + const clientCertificates: (string | undefined)[] = []; + let applicationData = ""; + const connectionEnded = Promise.withResolvers(); + const clientHello = Promise.withResolvers(); + const server = tls.createServer( + { + key: tlsCerts.key, + cert: tlsCerts.cert, + minVersion: version, + maxVersion: version, + // The client certificate is recorded, not verified. + requestCert, + rejectUnauthorized: false, + SNICallback(servername, cb) { + sni.push(servername); + clientHello.resolve(); + const resume = () => cb(null, tls.createSecureContext({ key: tlsCerts.key, cert: tlsCerts.cert })); + if (hold) hold.then(resume); + else resume(); + }, + }, + socket => { + let buffered = ""; + socket.on("data", chunk => { + applicationData += chunk.toString("latin1"); + buffered += chunk.toString("latin1"); + const end = buffered.indexOf("\r\n\r\n"); + if (end === -1) return; + const key = buffered + .slice(0, end) + .split("\r\n") + .find(line => line.toLowerCase().startsWith("sec-websocket-key:")) + ?.split(":", 2)[1] + .trim(); + if (!key) { + socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + return; + } + const accept = createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + buffered = ""; + }); + socket.on("error", () => {}); + socket.on("close", () => connectionEnded.resolve()); + }, + ); + server.on("tlsClientError", () => connectionEnded.resolve()); + // A handshake with no server_name extension never reaches SNICallback. + server.on("secureConnection", socket => { + if (!socket.servername) sni.push(null); + if (requestCert) clientCertificates.push(socket.getPeerCertificate()?.fingerprint256); + }); + const { promise, resolve } = Promise.withResolvers(); + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + return { + port: promise, + sni, + clientCertificates, + // The server has the ClientHello of a handshake that sends SNI. + clientHello: clientHello.promise, + // What the client has sent over TLS so far. + get received() { + return applicationData; + }, + // The same, once the server saw the connection end. A rejected peer must + // get none of the upgrade request: it carries Authorization and Cookie. + async receivedInTotal() { + await connectionEnded.promise; + return applicationData; + }, + [Symbol.dispose]() { + server.close(); + }, + }; +} + +// A TLS 1.2 server that answers one WebSocket upgrade, then forces a TLS +// renegotiation and sends a text frame. It runs in real node because a +// BoringSSL server cannot renegotiate. It waits for the client's first frame, +// so the connected client, not the upgrade client, owns the socket by then. +const node = nodeExe(); +const renegotiatingServer = ` + const tls = require("tls"); + const crypto = require("crypto"); + const server = tls.createServer( + { cert: process.env.SERVER_CERT, key: process.env.SERVER_KEY, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" }, + socket => { + socket.on("error", () => {}); + let head = ""; + const onHead = chunk => { + head += chunk.toString("latin1"); + if (!head.includes("\\r\\n\\r\\n")) return; + socket.off("data", onHead); + const key = /sec-websocket-key:\\s*(\\S+)/i.exec(head)[1]; + const accept = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\n" + + "Sec-WebSocket-Accept: " + accept + "\\r\\n\\r\\n", + ); + socket.once("data", () => { + socket.renegotiate({ rejectUnauthorized: false }, err => { + if (err) return socket.destroy(err); + const payload = Buffer.from("after renegotiation"); + socket.write(Buffer.concat([Buffer.from([0x81, payload.length]), payload])); + }); + }); + }; + socket.on("data", onHead); + }, + ); + server.listen(0, "127.0.0.1", () => console.log(server.address().port)); +`; + +function openSession(ws: WebSocket) { + ws.addEventListener("open", () => ws.close(1000)); + return clientEvents(ws); +} + +const opened = [{ code: 1000, reason: "", wasClean: true }]; +const expectedFingerprint256 = new X509Certificate(tlsCerts.cert).fingerprint256; +const tlsFailed = (url: string) => [ + { error: `WebSocket connection to '${url}' failed: TLS handshake failed` }, + { code: 1015, reason: "TLS handshake failed", wasClean: false }, +]; + +describe.concurrent("WebSocket tls.serverName", () => { + // `servername` is the node spelling of the same option. + test.each(["serverName", "servername"] as const)( + "%s is sent as SNI when the URL host is an IP address", + async key => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, [key]: "localhost" } }); + expect(await openSession(ws)).toEqual(opened); + expect(server.sni).toEqual(["localhost"]); + }, + ); + + test("is the name the certificate is verified against", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + // 127.0.0.1 is in the SAN, evil.test is not. Without serverName this would open. + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, serverName: "evil.test" } }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(server.sni).toEqual(["evil.test"]); + expect(await server.receivedInTotal()).toBe(""); + }); + + test("is the bare address when it is an IPv6 literal in brackets, like fetch", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + // ::1 is in the SAN as an IP address, and an IP address is never sent as SNI. + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, serverName: "[::1]" } }); + expect(await openSession(ws)).toEqual(opened); + const hostnames: string[] = []; + const withCallback = new WebSocket(url, { + tls: { ca: tlsCerts.cert, serverName: "[::1]", checkServerIdentity: hostname => void hostnames.push(hostname) }, + }); + expect(await openSession(withCallback)).toEqual(opened); + expect({ sni: server.sni, hostnames }).toEqual({ sni: [null, null], hostnames: ["::1"] }); + }); + + test("is used for the tunnel handshake through an HTTP proxy", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://127.0.0.1:${await server.port}/`; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${proxy.port}`, + tls: { ca: tlsCerts.cert, serverName: "localhost" }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(server.sni).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + }); + + // The proxy is dialed by IP address, so its handshake has no SNI and its certificate is + // checked against 127.0.0.1. `serverName` is for the handshake with the target. + test.each([ + ["a name the certificate has", "localhost", undefined], + ["a name that only the callback accepts", "target.test", () => undefined], + ] as const)("names the target and not an HTTPS proxy: %s", async (_label, serverName, checkServerIdentity) => { + using server = startSniServer(); + using proxy = await startRecordingProxy({ tls: true }); + const ws = new WebSocket(`wss://127.0.0.1:${await server.port}/`, { + proxy: `https://127.0.0.1:${proxy.port}`, + tls: { ca: tlsCerts.cert, serverName, checkServerIdentity }, + }); + expect(await openSession(ws)).toEqual(opened); + expect({ proxy: proxy.sni, target: server.sni }).toEqual({ proxy: [null], target: [serverName] }); + expect(proxy.requests).toHaveLength(1); + }); +}); + +describe.concurrent("WebSocket tls.checkServerIdentity", () => { + test("is called with the hostname and the peer certificate", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const calls: { hostname: string; subject: string; altnames: string; fingerprint256: string; raw: boolean }[] = []; + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity(hostname: string, cert: tls.PeerCertificate) { + calls.push({ + hostname, + subject: cert.subject.CN, + altnames: cert.subjectaltname, + fingerprint256: cert.fingerprint256, + raw: Buffer.isBuffer(cert.raw), + }); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual([ + { + hostname: "localhost", + subject: "server-bun", + altnames: "DNS:localhost, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1", + fingerprint256: expectedFingerprint256, + raw: true, + }, + ]); + // Control for the rejection tests: the server does record the request. + // It read all of it before the 101, so no wait for the connection to end. + expect(server.received).toStartWith("GET / HTTP/1.1\r\n"); + }); + + test("drains microtasks queued by the callback before the open event", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const order: string[] = []; + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + order.push("callback"); + queueMicrotask(() => order.push("microtask")); + return undefined; + }, + }, + }); + ws.addEventListener("open", () => order.push("open")); + expect(await openSession(ws)).toEqual(opened); + expect(order).toEqual(["callback", "microtask", "open"]); + }); + + test.each(["TLSv1.2", "TLSv1.3"] as const)("rejects the connection when it returns an Error: %s", async version => { + using server = startSniServer({ version }); + const url = `wss://localhost:${await server.port}/`; + let calls = 0; + const ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + calls++; + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(calls).toBe(1); + // Rejected before the upgrade: the peer never sees the request. + expect(await server.receivedInTotal()).toBe(""); + }); + + // The client runs in a child process: the exception it throws is reported as + // uncaught, which would fail this test run. + test("rejects the connection when it throws, and reports the exception", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + await using client = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const events = []; + process.on("uncaughtException", error => events.push("uncaught: " + error.message)); + const ws = new WebSocket(process.env.WS_URL, { + tls: { + ca: process.env.WS_CA, + checkServerIdentity() { + throw new Error("PIN-REJECT"); + }, + }, + }); + ws.onopen = () => { + events.push("open"); + ws.close(); + }; + ws.onerror = event => events.push("error: " + event.message); + ws.onclose = event => { + events.push("close " + event.code); + console.log(JSON.stringify(events)); + }; + `, + ], + env: { ...bunEnv, WS_URL: url, WS_CA: tlsCerts.cert }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([client.stdout.text(), client.stderr.text(), client.exited]); + expect({ stderr, events: JSON.parse(stdout || "null") }).toEqual({ + stderr: "", + events: [ + "uncaught: PIN-REJECT", + `error: WebSocket connection to '${url}' failed: TLS handshake failed`, + "close 1015", + ], + }); + expect(exitCode).toBe(0); + expect(await server.receivedInTotal()).toBe(""); + }); + + test("runs in the Bun.ModuleGraph that opened the WebSocket, so a throw goes to its onError", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const errors: unknown[] = []; + const graph = new Bun.ModuleGraph({ onError: (error, kind) => errors.push([kind, (error as Error).message]) }); + try { + let current: unknown; + const events = await graph.run(() => { + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + current = Bun.ModuleGraph.current; + throw new Error("PIN-REJECT"); + }, + }, + }); + return openSession(ws); + }); + expect({ events, errors, ranInGraph: current === graph }).toEqual({ + events: tlsFailed(url), + errors: [["uncaughtException", "PIN-REJECT"]], + ranInGraph: true, + }); + } finally { + graph.dispose(); + } + }); + + describe.each(["direct", "through an HTTP proxy"] as const)("from inside the callback, %s", route => { + test.each(["close", "terminate"] as const)("may %s() the WebSocket", async method => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://localhost:${await server.port}/`; + const ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + proxy: route === "direct" ? undefined : `http://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + ws[method](); + return undefined; + }, + }, + }); + const reason = "WebSocket is closed before the connection is established"; + expect(await clientEvents(ws)).toEqual([ + { error: `WebSocket connection to '${url}' failed: ${reason}` }, + { code: 1006, reason, wasClean: false }, + ]); + expect(await server.receivedInTotal()).toBe(""); + }); + }); + + test("replaces the built-in hostname check, like fetch", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + const calls: string[] = []; + // evil.test is not in the SAN. The callback approves it anyway. + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + serverName: "evil.test", + checkServerIdentity(hostname: string) { + calls.push(hostname); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["evil.test"]); + }); + + // mTLS. The callback, not the built-in check, still decides on the name. + test("replaces the built-in hostname check when the server asks for a client certificate", async () => { + using server = startSniServer({ requestCert: true }); + const url = `wss://127.0.0.1:${await server.port}/`; + const calls: string[] = []; + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + cert: tlsCerts.cert, + key: tlsCerts.key, + serverName: "evil.test", + checkServerIdentity(hostname: string) { + calls.push(hostname); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["evil.test"]); + expect(server.clientCertificates).toEqual([expectedFingerprint256]); + }); + + // The verdict is read as tls.connect() reads it: any truthy value rejects. An async + // callback returns a Promise, which is never an Error, so it must not pass for approval. + test.each([ + ["a Promise, from an async callback", async () => new Error("PIN-REJECT")], + ["a string", () => "pin mismatch"], + ] as const)("rejects the connection when it returns %s", async (_label, checkServerIdentity) => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + tls: { ca: tlsCerts.cert, checkServerIdentity: checkServerIdentity as never }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(await server.receivedInTotal()).toBe(""); + }); + + test.each([ + ["undefined", undefined], + ["false", false], + ] as const)("approves when it returns %s, as tls.connect() does", async (_label, verdict) => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + let calls = 0; + const checkServerIdentity = () => (calls++, verdict); + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, checkServerIdentity: checkServerIdentity as never } }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toBe(1); + }); + + test.each([ + ["a string", "pin"], + ["an object", {}], + ] as const)("throws when it is %s, like fetch", (_label, value) => { + let error: unknown; + try { + new WebSocket("wss://localhost:1/", { tls: { checkServerIdentity: value as never } }); + } catch (e) { + error = e; + } + expect(error).toMatchObject({ + code: "ERR_INVALID_ARG_TYPE", + message: expect.stringContaining('The "tls.checkServerIdentity" property must be of type function'), + }); + }); + + test("is ignored when it is null, and the built-in check applies", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + const ws = new WebSocket(url, { + tls: { ca: tlsCerts.cert, serverName: "evil.test", checkServerIdentity: null as never }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + }); + + test("is found on the prototype of the tls object, like the other tls options", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + let calls = 0; + const tlsOptions = Object.create({ + checkServerIdentity() { + calls++; + return new Error("PIN-REJECT"); + }, + }); + tlsOptions.ca = tlsCerts.cert; + const ws = new WebSocket(url, { tls: tlsOptions }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(calls).toBe(1); + }); + + describe("with rejectUnauthorized: false, like fetch", () => { + test("runs only on a chain that verified, and its verdict is not enforced", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + // Without `ca` the chain fails: the harness certificate is self-signed. + for (const [label, ca] of [ + ["verified", tlsCerts.cert], + ["unverified", undefined], + ] as const) { + const ws = new WebSocket(url, { + tls: { + ca, + rejectUnauthorized: false, + checkServerIdentity(hostname: string) { + calls.push(`${label}: ${hostname}`); + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + } + expect(calls).toEqual(["verified: localhost"]); + }); + + test("does the same for the target inside a proxy tunnel", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + rejectUnauthorized: false, + checkServerIdentity(hostname: string) { + calls.push(hostname); + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + }); + }); + + // On a renegotiation BoringSSL requires the same certificate, so the verdict + // of the callback still holds and the callback does not run again. + (node ? test : test.skip).each([ + ["a name the certificate does not have", { serverName: "evil.test" }, "evil.test"], + ["an IP URL, which has no SNI", {}, "127.0.0.1"], + ] as const)("a certificate it approved survives a TLS 1.2 renegotiation: %s", async (_label, names, hostname) => { + await using server = Bun.spawn({ + cmd: [node!, "-e", renegotiatingServer], + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + env: { ...bunEnv, SERVER_CERT: tlsCerts.cert, SERVER_KEY: tlsCerts.key }, + }); + const { value } = await server.stdout.getReader().read(); + const port = Number(new TextDecoder().decode(value).trim()); + + const calls: string[] = []; + const ws = new WebSocket(`wss://127.0.0.1:${port}/`, { + tls: { + ca: tlsCerts.cert, + ...names, + checkServerIdentity(name: string) { + calls.push(name); + return undefined; + }, + }, + }); + ws.addEventListener("open", () => ws.send("ready")); + ws.addEventListener("message", () => ws.close(1000)); + expect(await clientEvents(ws)).toEqual(["after renegotiation", ...opened]); + expect(calls).toEqual([hostname]); + }); + + test("is called for the target certificate through an HTTP proxy", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + checkServerIdentity(hostname: string) { + calls.push(hostname); + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(calls).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + expect(await server.receivedInTotal()).toBe(""); + }); + + test("is not called for the certificate of an HTTPS proxy", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy({ tls: true }); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + // The proxy presents the harness certificate too. The built-in check + // verifies it against 127.0.0.1; the callback sees only the target. + const ws = new WebSocket(url, { + proxy: `https://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + checkServerIdentity(hostname: string) { + calls.push(hostname); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + }); + + // A call into a VM that is stopping is a no-op that returns undefined, which is also how the callback approves. + test("does not approve when the handshake ends in a worker that terminate() has stopped", async () => { + using server = startSniServer(); + const serverPort = await server.port; + // Holds the server's first flight, so that the test decides when the handshake can end. + const flight = Promise.withResolvers<() => Promise>(); + const relay = net.createServer(client => { + const upstream = net.connect(serverPort, "127.0.0.1"); + client.on("error", () => {}); + client.on("close", () => upstream.destroy()); + upstream.on("error", () => {}); + client.pipe(upstream); + upstream.once("data", bytes => + flight.resolve(() => new Promise(sent => client.write(bytes, () => (upstream.pipe(client), sent())))), + ); + }); + const other = Promise.withResolvers(); + const otherServer = net.createServer(other.resolve); + relay.listen(0, "127.0.0.1"); + otherServer.listen(0, "127.0.0.1"); + await Promise.all([once(relay, "listening"), once(otherServer, "listening")]); + + const [STAGE, BOTH_READABLE, STOPPED] = [0, 1, 2]; + const flags = new Int32Array(new SharedArrayBuffer(12)); + const stage = async (value: number) => { + while (Atomics.load(flags, STAGE) !== value) await new Promise(resolve => setImmediate(resolve)); + }; + const raise = (flag: number) => (Atomics.store(flags, flag, 1), Atomics.notify(flags, flag)); + const worker = new Worker( + ` + const { parentPort, workerData } = require("node:worker_threads"); + const { url, ca, otherPort, flags } = workerData; + globalThis.ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + tls: { ca, checkServerIdentity: () => new Error("PIN-REJECT") }, + }); + Bun.connect({ + hostname: "127.0.0.1", + port: otherPort, + socket: { + data() { + Atomics.store(flags, ${STAGE}, 2); + Atomics.wait(flags, ${STOPPED}, 0); + }, + }, + }); + parentPort.on("message", () => { + Atomics.store(flags, ${STAGE}, 1); + Atomics.wait(flags, ${BOTH_READABLE}, 0); + }); + `, + { + eval: true, + workerData: { + url: `wss://127.0.0.1:${(relay.address() as AddressInfo).port}/`, + ca: tlsCerts.cert, + otherPort: (otherServer.address() as AddressInfo).port, + flags, + }, + }, + ); + try { + const [deliverFlight, otherSocket] = await Promise.all([flight.promise, other.promise]); + // While the worker's thread is blocked, both of its sockets become readable, the other one first. + worker.postMessage("block"); + await stage(1); + await new Promise(sent => otherSocket.write("x", sent)); + await deliverFlight(); + raise(BOTH_READABLE); + // One pass of the worker's event loop now reads both. terminate() lands between the two. + await stage(2); + const terminated = worker.terminate(); + raise(STOPPED); + expect(await server.receivedInTotal()).toBe(""); + await terminated; + } finally { + raise(BOTH_READABLE); + raise(STOPPED); + await worker.terminate(); + relay.close(); + otherServer.close(); + } + }); + + // The `ws` package passes its `tls` object to the native client as it is. + test("applies through the ws package, with tls.serverName", async () => { + using server = startSniServer(); + const calls: string[] = []; + const ws = new WsPackageWebSocket(`wss://127.0.0.1:${await server.port}/`, { + tls: { + ca: tlsCerts.cert, + serverName: "localhost", + checkServerIdentity(hostname: string) { + calls.push(hostname); + return new Error("PIN-REJECT"); + }, + }, + }); + // The first event decides. A close with no error before it must fail the assertion, not hang. + const outcome = await new Promise(resolve => { + ws.on("open", () => resolve("open")); + ws.on("error", () => resolve("error")); + ws.on("close", code => resolve(`close ${code}`)); + }); + expect({ outcome, calls, sni: server.sni }).toEqual({ outcome: "error", calls: ["localhost"], sni: ["localhost"] }); + expect(await server.receivedInTotal()).toBe(""); + }); +}); + +// Not concurrent: these count every live WebSocket in the process, or force a full GC. +describe("WebSocket tls.checkServerIdentity lifetime", () => { + test("survives a GC before the handshake ends when only the WebSocket refers to it", async () => { + const release = Promise.withResolvers(); + using server = startSniServer({ hold: release.promise }); + const url = `wss://127.0.0.1:${await server.port}/`; + const calls: string[] = []; + // Nothing in this scope refers to the callback or to the tls object. The built-in check + // rejects evil.test, so the connection opens only if the callback is still there to run. + const ws = (() => + new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + serverName: "evil.test", + checkServerIdentity: (hostname: string) => void calls.push(hostname), + }, + }))(); + const session = openSession(ws); + // A connection that ends before its ClientHello fails the assertion below and does not hang here. + await Promise.race([server.clientHello, session]); + Bun.gc(true); + release.resolve(); + expect(await session).toEqual(opened); + expect(calls).toEqual(["evil.test"]); + }); + + test("does not keep a closed WebSocket alive when the callback captures it", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const liveWebSockets = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.WebSocket || 0; + }; + const before = liveWebSockets(); + const total = 16; + let checks = 0; + await Promise.all( + Array.from({ length: total }, async () => { + // The cycle: the WebSocket holds the callback, and the callback holds the WebSocket. + const ws: WebSocket = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + if (ws.readyState === WebSocket.CONNECTING) checks++; + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + }), + ); + expect(checks).toBe(total); + // The WebSocket drops its pending activity in a task after the close event. + let leaked = liveWebSockets() - before; + for (let i = 0; i < 10 && leaked > 2; i++) { + await new Promise(resolve => setImmediate(resolve)); + leaked = liveWebSockets() - before; + } + expect(leaked).toBeLessThanOrEqual(2); + }); +}); diff --git a/test/js/web/websocket/websocket-unix.test.ts b/test/js/web/websocket/websocket-unix.test.ts index dd4065586a25..0a607550e3ec 100644 --- a/test/js/web/websocket/websocket-unix.test.ts +++ b/test/js/web/websocket/websocket-unix.test.ts @@ -195,6 +195,36 @@ describe.skipIf(isWindows)("WebSocket over unix domain socket", () => { } }); + test("wss+unix:// takes the SNI and the verified name from tls.serverName", async () => { + const unix = sockPath("name"); + const seen: (string | null)[] = []; + const server = tls.createServer( + { ...tlsCert, SNICallback: (name, cb) => (seen.push(name), cb(null, undefined)) }, + socket => { + socket.on("error", () => {}); + socket.destroy(); + }, + ); + server.listen(unix); + await once(server, "listening"); + try { + const codes: number[] = []; + // The harness certificate names localhost. + for (const [host, serverName] of [ + ["evil.test", "localhost"], + ["localhost", "evil.test"], + ]) { + const ws = new WebSocket(`wss+unix://${host}${unix}`, { tls: { ca: tlsCert.cert, serverName } }); + const { promise, resolve } = Promise.withResolvers(); + ws.onclose = event => resolve(event.code); + codes.push(await promise); + } + expect({ seen, codes }).toEqual({ seen: ["localhost", "evil.test"], codes: [1006, 1015] }); + } finally { + server.close(); + } + }); + test("works from a subprocess", async () => { const unix = sockPath("sp"); await using server = Bun.serve({ From ceed2d01d6f72715dc50969f89cbc26c23609417 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:49:57 +0000 Subject: [PATCH 026/174] websocket: close the connection of a wss:// client that is done with it (#37487, #43048) Through a CONNECT proxy the socket stays with the upgrade client after the upgrade, and the connected client closed only its own detached handle. So terminate(), a protocol error and teardown left the proxy connection, the tunnel and its SSL_CTX behind, and the upgrade client's keep-alive kept the process running. The three abrupt paths now share `drop_connection`, which closes the socket through the tunnel, and the upgrade client drops its keep-alive once the connected client holds one. After a clean close() nothing bounded the wait for the server's TCP close on TLS and in a tunnel, so a server that keeps the connection open pinned the fd. The client now arms a 30 s socket timeout like `ws` (BUN_CONFIG_WS_CLOSE_TIMEOUT) and ends the connection with close_notify and a FIN. Frames that arrive after the close event are not parsed, so the server's Close frame no longer gets a second one back. --- docs/runtime/environment-variables.mdx | 1 + src/bun_core/env_var.rs | 2 + src/http_jsc/websocket_client.rs | 68 ++++++++--- .../websocket_client/WebSocketProxyTunnel.rs | 33 ++++-- .../WebSocketUpgradeClient.rs | 9 +- src/uws/lib.rs | 1 + test/js/web/websocket/proxy-test-utils.ts | 63 ++++++++++ .../websocket/websocket-close-code.test.ts | 110 +++++++++++++++++- test/js/web/websocket/websocket-proxy.test.ts | 77 ++++++++++++ 9 files changed, 336 insertions(+), 28 deletions(-) diff --git a/docs/runtime/environment-variables.mdx b/docs/runtime/environment-variables.mdx index 1ef65508bcd3..4fe63174674b 100644 --- a/docs/runtime/environment-variables.mdx +++ b/docs/runtime/environment-variables.mdx @@ -216,6 +216,7 @@ Bun reads these environment variables to configure aspects of its behavior. | `NO_COLOR` | If `NO_COLOR=1`, then ANSI color output is [disabled](https://no-color.org/). | | `FORCE_COLOR` | If `FORCE_COLOR=1`, then ANSI color output is forced on, even if `NO_COLOR` is set. | | `BUN_CONFIG_MAX_HTTP_REQUESTS` | Sets the maximum number of concurrent HTTP requests sent by fetch and `bun install`. Defaults to `256`. Lower it if you run into rate limits or connection issues. | +| `BUN_CONFIG_WS_CLOSE_TIMEOUT` | Sets how many seconds a `WebSocket` client waits for the server to close the connection after the closing handshake. Bun then closes it. Defaults to `30`. `0` disables the timeout. | | `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD` | If `BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD=true`, then `bun --watch` does not clear the console on reload | | `DO_NOT_TRACK` | Disable uploading crash reports to `bun.report` on crash. On macOS & Windows, crash report uploads are enabled by default. Bun sends no other telemetry, though we plan to add some. If `DO_NOT_TRACK=1`, then auto-uploading crash reports and telemetry are both [disabled](https://do-not-track.dev/). | | `BUN_OPTIONS` | Prepends command-line arguments to any Bun execution. For example, `BUN_OPTIONS="--hot"` makes `bun run dev` behave like `bun --hot run dev`. | diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 727254857979..bd67cd0c3d96 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -66,6 +66,8 @@ new!(pub BUN_CONFIG_HTTP_IDLE_TIMEOUT: unsigned, "BUN_CONFIG_HTTP_IDLE_TIMEOUT", // A peer that accepts the TCP connection but never answers the upgrade fails // with error + close(1006). 0 disables; uSockets' 4 s sweep rounds small values up. new!(pub BUN_CONFIG_WS_HANDSHAKE_TIMEOUT: unsigned, "BUN_CONFIG_WS_HANDSHAKE_TIMEOUT", { default: 120 }); +// Seconds the `new WebSocket()` client waits for the server's TCP close after its close event. 0 disables. +new!(pub BUN_CONFIG_WS_CLOSE_TIMEOUT: unsigned, "BUN_CONFIG_WS_CLOSE_TIMEOUT", { default: 30 }); new!(pub BUN_CRASH_REPORT_URL: string, "BUN_CRASH_REPORT_URL", {}); new!(pub BUN_DEBUG: string, "BUN_DEBUG", {}); new!(pub BUN_DEBUG_ALL: boolean, "BUN_DEBUG_ALL", {}); diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index d41a6ba63824..e873d0149c4c 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -61,6 +61,26 @@ const MAX_CLOSE_REASON: usize = MAX_CONTROL_PAYLOAD - 2; /// Outgoing control frame prefix: 2-byte header + 4-byte masking key. const CONTROL_HEADER_SIZE: usize = 6; +/// Seconds a connection may outlive its close event (`closeTimeout` of the `ws` package). +#[inline] +fn close_timeout_seconds() -> core::ffi::c_uint { + bun_http::normalize_idle_timeout_seconds( + bun_core::env_var::BUN_CONFIG_WS_CLOSE_TIMEOUT + .get() + .unwrap_or(30), + ) +} + +/// Closes with a FIN: a reset would drop what the kernel still has to send behind a clean close event. +fn close_at_close_timeout(socket: Socket) { + socket.shutdown(); + socket.close(uws::CloseKind::FastShutdown); + // uSockets defers this close once behind unsent TLS ciphertext. + if !socket.is_closed() { + socket.set_timeout(close_timeout_seconds()); + } +} + #[derive(bun_ptr::CellRefCounted)] pub struct WebSocket { pub(crate) ref_count: Cell, @@ -214,12 +234,9 @@ impl WebSocket { log!("cancel"); let this = self; let had_tunnel = this.tunnel().is_some(); - this.clear_data(); - - // Failure still sends close_notify best-effort but never waits for the peer's reply. - this.tcp.get().close(uws::CloseKind::Failure); + this.drop_connection(); - // In tunnel mode tcp is .detached so close() above is a no-op and + // In tunnel mode tcp is .detached, so this client's // handle_close() never fires. Mirror what handle_close() does for // the non-tunnel path: drop the C++ ref (if still held) via // dispatch_abrupt_close so e.g. ws.terminate() — which calls @@ -231,6 +248,16 @@ impl WebSocket { } } + /// The caller holds a ref guard. In tunnel mode the socket is the upgrade client's and only the tunnel reaches it. + fn drop_connection(&self) { + let tunnel = self.proxy_tunnel.get().clone(); + self.clear_data(); + match tunnel { + Some(tunnel) => tunnel.close_socket(), + None => self.tcp.get().close(uws::CloseKind::Failure), + } + } + /// Every caller holds a ref guard (the uws dispatcher, a C++ entry point, /// or the tunnel callback), so `self` outlives the releases below. pub(crate) fn fail(&self, code: ErrorCode) { @@ -551,6 +578,10 @@ impl WebSocket { }; let terminated = loop { + // The close event is out: a Close frame from the server gets no second one back. + if self.cpp_websocket().is_none() { + break true; + } log!("onData ({})", <&'static str>::from(cursor.state)); let step = match cursor.state { @@ -1160,6 +1191,11 @@ impl WebSocket { self.tcp.get().shutdown_read(); self.tcp.get().shutdown(); } + // RFC 6455 7.1.1: the server closes the TCP connection first, within reason. + match self.tunnel() { + Some(tunnel) => tunnel.set_timeout(close_timeout_seconds()), + None => self.tcp.get().set_timeout(close_timeout_seconds()), + } } fn finish_pending_close(&self) { @@ -1209,7 +1245,11 @@ impl WebSocket { self.drain_send_buffer_and_finish_close(); } - pub fn handle_timeout(&self, _socket: Socket) { + pub fn handle_timeout(&self, socket: Socket) { + if self.cpp_websocket().is_none() { + close_at_close_timeout(socket); + return; + } self.terminate(ErrorCode::Timeout); } @@ -1553,19 +1593,12 @@ impl WebSocket { /// The JS wrapper was collected: C++ is letting go of its ref. pub(crate) fn finalize(this: ThisPtr) { log!("finalize"); - // clear_data() may drop the tunnel's I/O-layer ref and the block - // below drops the C++ ref; keep `this` alive until we've finished the - // tcp close check. + // Either release below may be the last ref. let _guard = RefPtr::from_this(this); - this.clear_data(); - // This is only called by outgoing_websocket. this.release_cpp_ref(); - - if !this.tcp.get().is_closed() { - this.tcp.get().close(uws::CloseKind::Failure); - } + this.drop_connection(); } /// The owning C++ WebSocket's context is being torn down: forget it (nothing @@ -1576,10 +1609,7 @@ impl WebSocket { let _guard = RefPtr::from_this(this); let _cpp_ref = this.outgoing_websocket.replace(None); - this.clear_data(); - if !this.tcp.get().is_closed() { - this.tcp.get().close(uws::CloseKind::Failure); - } + this.drop_connection(); } pub(crate) fn memory_cost(&self) -> usize { diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index 3e00ec8f7864..ece018083f9a 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -148,8 +148,8 @@ pub struct WebSocketProxyTunnel { connected_websocket: Cell>>, /// SSL wrapper for TLS inside tunnel; set once in `start()`. wrapper: OnceCell, - /// Socket reference (the proxy connection) - socket: SocketUnion, + /// The proxy connection, until the upgrade client that owns it lets go. + socket: Cell, /// Write buffer for encrypted data (maintains TLS record ordering) write_buffer: JsCell, /// Hostname for SNI (Server Name Indication) @@ -180,7 +180,7 @@ impl WebSocketProxyTunnel { upgrade_client: Cell::new(Some(upgrade_client)), connected_websocket: Cell::new(None), wrapper: OnceCell::new(), - socket, + socket: Cell::new(socket), write_buffer: JsCell::new(StreamBuffer::default()), sni_hostname: Some(Box::<[u8]>::from(bun_http::strip_ipv6_brackets( sni_hostname, @@ -410,6 +410,7 @@ impl WebSocketProxyTunnel { /// do not re-enter the upgrade client's terminate/clearData path. pub(crate) fn detach_upgrade_client(&self) { self.upgrade_client.set(None); + self.socket.set(SocketUnion::None); } /// SSLWrapper callback: Called with encrypted data to send to network @@ -427,7 +428,7 @@ impl WebSocketProxyTunnel { } // Try direct write to socket - let written = this.socket.write(encrypted_data); + let written = this.socket.get().write(encrypted_data); if written < 0 { // Write failed - buffer data for retry when socket becomes writable bun_core::handle_oom(this.write_buffer.with_mut(|b| b.write(encrypted_data))); @@ -466,7 +467,7 @@ impl WebSocketProxyTunnel { return false; } let to_send_len = to_send.len(); - let written = this.socket.write(to_send); + let written = this.socket.get().write(to_send); if written < 0 { return true; } @@ -541,7 +542,7 @@ impl WebSocketProxyTunnel { } pub(crate) fn pause_stream(&self) -> bool { - match &self.socket { + match self.socket.get() { SocketUnion::Tcp(s) => s.pause_stream(), SocketUnion::Ssl(s) => s.pause_stream(), SocketUnion::None => false, @@ -549,12 +550,30 @@ impl WebSocketProxyTunnel { } pub(crate) fn resume_stream(&self) -> bool { - match &self.socket { + match self.socket.get() { SocketUnion::Tcp(s) => s.resume_stream(), SocketUnion::Ssl(s) => s.resume_stream(), SocketUnion::None => false, } } + + /// The upgrade client's `handle_timeout` closes the proxy connection. + pub(crate) fn set_timeout(&self, seconds: core::ffi::c_uint) { + match self.socket.get() { + SocketUnion::Tcp(s) => s.set_timeout(seconds), + SocketUnion::Ssl(s) => s.set_timeout(seconds), + SocketUnion::None => {} + } + } + + /// Runs the upgrade client's `handle_close`, which drops its ref on the tunnel. + pub(crate) fn close_socket(&self) { + match self.socket.replace(SocketUnion::None) { + SocketUnion::Tcp(s) => s.close(bun_uws::CloseKind::Failure), + SocketUnion::Ssl(s) => s.close(bun_uws::CloseKind::Failure), + SocketUnion::None => {} + } + } } // HOST_EXPORT(WebSocketProxyTunnel__setConnectedWebSocket, c) diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index ef61eb74640b..43face7b4ce0 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -1370,6 +1370,9 @@ where // Same order as the non-tunnel arm below, which detaches the socket // before did_connect. this.state.set(State::Done); + // The connected client holds its own. + this.poll_ref + .with_mut(|p| p.unref(VirtualMachineRef::get().loop_ctx())); // Create the WebSocket client with the tunnel ws.did_connect_with_tunnel( @@ -1486,7 +1489,11 @@ where } /// Takes `ThisPtr` because `terminate` may free `this`; see `fail`. - pub fn handle_timeout(this: ThisPtr, _: Socket) { + pub fn handle_timeout(this: ThisPtr, socket: Socket) { + if this.state.get() == State::Done { + super::close_at_close_timeout(socket); + return; + } Self::terminate(this, ErrorCode::Timeout); } diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 458845c68504..e29796eb0040 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -1618,6 +1618,7 @@ pub use bun_uws_sys::socket::{ /// state. Used by proxy-tunnel layers (HTTP `ProxyTunnel`, WebSocket /// `WebSocketProxyTunnel`) where the inner socket may be either transport and /// may be detached. Distinct from [`AnySocket`] which has no `None` variant. +#[derive(Clone, Copy)] pub enum MaybeAnySocket { Tcp(SocketTCP), Ssl(SocketTLS), diff --git a/test/js/web/websocket/proxy-test-utils.ts b/test/js/web/websocket/proxy-test-utils.ts index b0d5ec423579..def0ef04f4f7 100644 --- a/test/js/web/websocket/proxy-test-utils.ts +++ b/test/js/web/websocket/proxy-test-utils.ts @@ -4,6 +4,7 @@ */ import { tls as tlsCerts } from "harness"; +import { createHash } from "crypto"; import net from "net"; import tls from "tls"; @@ -154,6 +155,23 @@ export async function startProxy(server: net.Server): Promise { }); } +/** How a connection that a server accepted went away: after the peer's FIN or close_notify, with an error, or both. */ +export interface Departure { + fin: boolean; + error?: string; +} + +export function departure(socket: net.Socket): Promise { + const seen: Departure = { fin: false }; + socket.on("end", () => { + seen.fin = true; + }); + socket.on("error", (error: NodeJS.ErrnoException) => { + seen.error = error.code; + }); + return new Promise(resolve => socket.on("close", () => resolve(seen))); +} + /** * Starts a CONNECT proxy for one test and records every connection it accepts * and every CONNECT request it reads, so a test can assert what the client @@ -162,6 +180,7 @@ export async function startProxy(server: net.Server): Promise { export async function startRecordingProxy(options: ConnectProxyOptions = {}) { const requests: ConnectRequest[] = []; const sni: (string | null)[] = []; + const departures: Promise[] = []; let connections = 0; const proxy = createConnectProxy({ ...options, @@ -176,12 +195,17 @@ export async function startRecordingProxy(options: ConnectProxyOptions = {}) { proxy.on("secureConnection", (socket: tls.TLSSocket) => { sni.push(socket.servername || null); }); + proxy.on(options.tls ? "secureConnection" : "connection", (socket: net.Socket) => { + departures.push(departure(socket)); + }); const port = await startProxy(proxy); return { port, requests, /** With `tls: true`, the SNI of every handshake the proxy completed. `null` when the client sent none. */ sni, + /** One per connection the proxy accepted so far. Each resolves when that connection is gone. */ + departures, get connections() { return connections; }, @@ -199,6 +223,45 @@ export function connectRequest(port: number, headers: Record = { }; } +/** + * A wss:// server that answers the upgrade by hand and gives every later chunk + * to `onFrames`. It never ends a connection on its own. + */ +export async function startRawWssServer( + onFrames: (socket: tls.TLSSocket, chunk: Buffer) => void = () => {}, + options: tls.TlsOptions = {}, +) { + const departures: Promise[] = []; + const server = tls.createServer({ key: tlsCerts.key, cert: tlsCerts.cert, ...options }, socket => { + departures.push(departure(socket)); + let request = ""; + let upgraded = false; + socket.on("data", chunk => { + if (upgraded) return onFrames(socket, chunk); + request += chunk.toString("latin1"); + if (!request.includes("\r\n\r\n")) return; + upgraded = true; + const key = /^sec-websocket-key: (.*)\r$/im.exec(request)![1]; + const accept = createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + }); + }); + const port = await startProxy(server); + return { + port, + /** One per connection the server accepted so far. Each resolves when that connection is gone. */ + departures, + [Symbol.dispose]() { + server.close(); + }, + }; +} + /** A WebSocket echo server. It greets each client with "connected", then echoes every message. */ export function startEchoServer(options: { tls?: boolean } = {}) { return Bun.serve({ diff --git a/test/js/web/websocket/websocket-close-code.test.ts b/test/js/web/websocket/websocket-close-code.test.ts index 1e9f6f12d9ac..e2b7563cfacb 100644 --- a/test/js/web/websocket/websocket-close-code.test.ts +++ b/test/js/web/websocket/websocket-close-code.test.ts @@ -4,8 +4,11 @@ // in websocket.test.js ("WebSocket CloseEvent reports the received close // code"); this file holds the close() validation and the cases added with it. import { describe, expect, it } from "bun:test"; +import { bunEnv, bunExe } from "harness"; import crypto from "node:crypto"; -import { createServer, type Socket } from "node:net"; +import { once } from "node:events"; +import { type AddressInfo, connect, createServer, type Socket } from "node:net"; +import { type Departure, departure, startRawWssServer, startRecordingProxy } from "./proxy-test-utils"; describe.concurrent("WebSocket close() argument validation", () => { // Close codes an RFC 6455 endpoint must never put on the wire. close() has to @@ -228,3 +231,108 @@ describe.concurrent("WebSocket client and server-sent close frames", () => { }); }); }); + +// RFC 6455 section 7.1.1: the client lets the server close the TCP connection first, but not forever. +describe.concurrent("wss:// after the close event", () => { + const CLOSE_1000 = Buffer.from([0x88, 0x02, 0x03, 0xe8]); + + it("the server's Close frame gets no second Close frame back", async () => { + let fromClient = Buffer.alloc(0); + using server = await startRawWssServer((socket, chunk) => { + if (fromClient.length === 0) socket.end(CLOSE_1000); + fromClient = Buffer.concat([fromClient, chunk]); + }); + const ws = new WebSocket(`wss://127.0.0.1:${server.port}`, { tls: { rejectUnauthorized: false } }); + ws.onopen = () => ws.close(1000); + const event = await new Promise(resolve => (ws.onclose = resolve)); + expect({ code: event.code, wasClean: event.wasClean }).toEqual({ code: 1000, wasClean: true }); + await Promise.all(server.departures); + // One masked Close frame with a status code: 2 header bytes, 4 mask bytes, 2 payload bytes. + expect({ opcode: fromClient[0] & 0x0f, bytes: fromClient.length }).toEqual({ opcode: 0x8, bytes: 8 }); + }); + + // Stands between the client and its peer and sees how the client's TCP connection ends. After + // `freeze()` the peer hears nothing more from the client, like a peer that stopped reading. + async function startRelay(port: number) { + let frozen = false; + let fromClient = Buffer.alloc(0); + const departed = Promise.withResolvers(); + const relay = createServer(client => { + departed.resolve(departure(client)); + const peer = connect(port, "127.0.0.1"); + peer.on("error", () => {}); + peer.pipe(client); + client.on("close", () => peer.destroy()); + client.on("data", chunk => { + fromClient = Buffer.concat([fromClient, chunk]); + if (!frozen) peer.write(chunk); + }); + }); + relay.listen(0, "127.0.0.1"); + await once(relay, "listening"); + return { + port: (relay.address() as AddressInfo).port, + departed: departed.promise, + freeze: () => void (frozen = true), + // The content type of the last TLS record the client sent. + lastRecordType() { + let offset = 0; + for (let next; (next = offset + 5 + fromClient.readUInt16BE(offset + 3)) < fromClient.length; ) offset = next; + return fromClient[offset]; + }, + [Symbol.dispose]: () => void relay.close(), + }; + } + + // uSockets sweeps its timeouts every 4 s, so a timeout of 1 s takes 4 to 8 s. + it.each([ + // TLS 1.2 does not hide the type of a record: 21 is an alert, the close_notify. + ["direct", 21], + // This proxy never answers the close_notify of the client. TLS 1.3 shows every record as 23. + ["https proxy", 23], + ] as const)( + "the client closes a connection that its peer keeps open: %s", + async (route, lastRecordType) => { + using server = await startRawWssServer(socket => socket.write(CLOSE_1000), { maxVersion: "TLSv1.2" }); + using proxy = await startRecordingProxy({ tls: true }); + using relay = await startRelay(route === "direct" ? server.port : proxy.port); + await using client = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const ws = new WebSocket(process.env.WS_URL, { + proxy: process.env.WS_PROXY || undefined, + tls: { rejectUnauthorized: false }, + }); + ws.onopen = () => setImmediate(() => ws.close()); + ws.onclose = event => console.log(event.code, event.wasClean); + // An exit would close the connection too, so stay until the test lets go of stdin. + process.stdin.resume(); + `, + ], + env: { + ...bunEnv, + NO_PROXY: "", + no_proxy: "", + BUN_CONFIG_WS_CLOSE_TIMEOUT: "1", + WS_URL: `wss://127.0.0.1:${route === "direct" ? relay.port : server.port}`, + WS_PROXY: route === "direct" ? "" : `https://127.0.0.1:${relay.port}`, + }, + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + }); + const { value } = await client.stdout.getReader().read(); + expect(new TextDecoder().decode(value)).toBe("1000 true\n"); + relay.freeze(); + // A FIN and not a reset, which drops what is still in flight behind the clean close event. + expect({ departure: await relay.departed, lastRecordType: relay.lastRecordType() }).toEqual({ + departure: { fin: true }, + lastRecordType, + }); + expect(client.exitCode).toBeNull(); + }, + 20_000, + ); +}); diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index fb5f8e8437a4..6510888d7387 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -1,3 +1,4 @@ +import { sslCtxLiveCount } from "bun:internal-for-testing"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import * as harness from "harness"; import { tls as tlsCerts } from "harness"; @@ -17,6 +18,7 @@ import { failingSession, startEchoServer, startProxy, + startRawWssServer, startRecordingProxy, } from "./proxy-test-utils"; // Use dynamic require to avoid linter removing the import @@ -689,6 +691,81 @@ describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => { }); }); +// After the upgrade the proxy connection still belongs to the upgrade client, and only the tunnel reaches it. +describe.concurrent("wss:// through a proxy: the end of the proxy connection", () => { + describe.each(["http", "https"] as const)("%s proxy", scheme => { + test.each([ + ["terminate()", 1006, (ws: WebSocket) => ws.terminate()], + ["an invalid frame from the server", 1002, (ws: WebSocket) => ws.send("go")], + ] as const)("%s closes it", async (_label, code, act) => { + using origin = await startRawWssServer(socket => socket.write(Buffer.from([0x83, 0x00]))); + using recorded = await startRecordingProxy({ tls: scheme === "https" }); + const codes = await Promise.all( + Array.from({ length: 4 }, async () => { + const ws = new WebSocket(`wss://127.0.0.1:${origin.port}`, { + proxy: `${scheme}://127.0.0.1:${recorded.port}`, + tls: { rejectUnauthorized: false }, + }); + // Not inside the open event: there the tunnel still has the upgrade client to close through. + ws.addEventListener("open", () => setImmediate(act, ws)); + return closeCodeOf(await clientEvents(ws)); + }), + ); + expect(codes).toEqual([code, code, code, code]); + expect(await Promise.all(recorded.departures)).toHaveLength(4); + }); + }); + + test.each([ + ["terminate()", "terminate", "1006 false\n"], + ["close(), with a server that never closes the connection", "close", "1000 true\n"], + ] as const)("the process exits after %s", async (_label, method, output) => { + using origin = await startRawWssServer(); + using recorded = await startRecordingProxy(); + await using client = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const ws = new WebSocket(process.env.WS_URL, { proxy: process.env.WS_PROXY, tls: { rejectUnauthorized: false } }); + ws.onopen = () => setImmediate(() => ws.${method}()); + ws.onclose = event => console.log(event.code, event.wasClean); + `, + ], + env: { + ...bunEnv, + NO_PROXY: "", + no_proxy: "", + WS_URL: `wss://127.0.0.1:${origin.port}`, + WS_PROXY: `http://127.0.0.1:${recorded.port}`, + }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([client.stdout.text(), client.exited]); + expect(stdout).toBe(output); + expect(exitCode).toBe(0); + }); +}); + +// Not concurrent: it counts every live SSL_CTX in the process. +test("wss:// through a proxy: terminate() frees the SSL_CTX of the tunnel", async () => { + using recorded = await startRecordingProxy(); + const terminated = async () => { + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { + proxy: `http://127.0.0.1:${recorded.port}`, + tls: { rejectUnauthorized: false }, + }); + ws.addEventListener("message", () => ws.terminate()); + return closeCodeOf(await clientEvents(ws)); + }; + expect(await terminated()).toBe(1006); + const before = sslCtxLiveCount(); + expect(await Promise.all([terminated(), terminated(), terminated(), terminated()])).toEqual([1006, 1006, 1006, 1006]); + // Not toBe: a GC may free a context that an earlier test left behind. + expect(sslCtxLiveCount()).toBeLessThanOrEqual(before); +}); + describe("WebSocket through HTTPS proxy (TLS proxy)", () => { // These tests verify WebSocket connections through HTTPS (TLS) proxy servers From 5256d0843f8e6ab705ca3dd669ee31013b42af0c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:14:50 +0000 Subject: [PATCH 027/174] test(ws): servername, checkServerIdentity and crl reach the native WebSocket (#31397, #41648) The ws shim forwards these three options; the native client honours them since the WebSocket server-identity change. --- test/js/first_party/ws/ws-tls-options.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/js/first_party/ws/ws-tls-options.test.ts b/test/js/first_party/ws/ws-tls-options.test.ts index 41670184bca3..5bb6042c193a 100644 --- a/test/js/first_party/ws/ws-tls-options.test.ts +++ b/test/js/first_party/ws/ws-tls-options.test.ts @@ -230,3 +230,49 @@ describe.concurrent("ws with a pfx that bundles a CA", () => { expect(exitCode).toBe(0); }); }); + +describe.concurrent("ws TLS options that name or pin the server", () => { + // CN=agent1, no subjectAltName, signed by ca1. + const agent1 = { key: read("agent1-key.pem"), cert: read("agent1-cert.pem") }; + + test("servername", async () => { + using server = await serve(agent1); + expect({ + none: await dial(server.url, { ca: ca1 }), + topLevel: await dial(server.url, { ca: ca1, servername: "agent1" }), + agent: await dial(server.url, { agent: new https.Agent({ ca: ca1, servername: "agent1" }) }), + agentWins: await dial(server.url, { agent: new https.Agent({ servername: "agent1" }), ca: ca1, servername: "other" }), // prettier-ignore + agentWinsWrong: await dial(server.url, { agent: new https.Agent({ servername: "other" }), ca: ca1, servername: "agent1" }), // prettier-ignore + }).toEqual({ + none: "refused", + topLevel: anonymous, + agent: anonymous, + agentWins: anonymous, + agentWinsWrong: "refused", + }); + }); + + test("checkServerIdentity", async () => { + using server = await serve(agent1); + const seen: unknown[] = []; + function accept(hostname: string, cert: { subject: { CN: string } }) { + seen.push(hostname, cert.subject.CN); + return undefined; + } + const refuse = () => new Error("not the pinned certificate"); + expect(await dial(server.url, { ca: ca1, checkServerIdentity: accept })).toBe(anonymous); + expect(seen).toEqual(["localhost", "agent1"]); + expect(await dial(server.url, { ca: ca1, servername: "agent1", checkServerIdentity: refuse })).toBe("refused"); + expect(await dial(server.url, { agent: new https.Agent({ ca: ca1, checkServerIdentity: accept as any }) })).toBe(anonymous); // prettier-ignore + // It does not replace the verification of the chain. + expect(await dial(server.url, { checkServerIdentity: accept })).toBe("refused"); + }); + + test("crl", async () => { + // ca2 signs agent3. ca2-crl.pem revokes agent4 only. + using server = await serve({ key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }); + const trust = { ca: read("ca2-cert.pem"), servername: "agent3" }; + expect(await dial(server.url, { ...trust, crl: read("ca2-crl.pem") })).toBe(anonymous); + expect(await dial(server.url, { ...trust, crl: read("ca2-crl-agent3.pem") })).toBe("refused"); + }); +}); From 045220d8a75401692225f77611af7d39541aad31 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:03:24 +0000 Subject: [PATCH 028/174] node:tls: keep every addContext() entry for the next listen() (#43080) addContext() wrote either the live listener or the contexts map, never both, so an entry added while listening was lost after close() + listen(), and a replaced one came back stale. It now always records the entry, in call order. A falsy servername throws Node's ERR_TLS_REQUIRED_SERVER_NAME. --- src/js/builtins.d.ts | 1 + src/js/node/tls.ts | 12 ++- src/jsc/bindings/ErrorCode.cpp | 2 + src/jsc/bindings/ErrorCode.ts | 1 + test/js/node/tls/node-tls-server.test.ts | 110 +++++++++++++++++++++++ 5 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/js/builtins.d.ts b/src/js/builtins.d.ts index bb108a82c74c..ecd55f327009 100644 --- a/src/js/builtins.d.ts +++ b/src/js/builtins.d.ts @@ -529,6 +529,7 @@ declare function $ERR_TLS_RENEGOTIATION_UNSUPPORTED(): Error; declare function $ERR_TLS_INVALID_STATE(): Error; declare function $ERR_UNAVAILABLE_DURING_EXIT(): Error; declare function $ERR_TLS_CERT_ALTNAME_FORMAT(): SyntaxError; +declare function $ERR_TLS_REQUIRED_SERVER_NAME(): Error; declare function $ERR_TLS_SNI_FROM_SERVER(): Error; declare function $ERR_SSL_NO_CIPHER_MATCH(): Error; declare function $ERR_INVALID_URI(): URIError; diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 695c14cbb1c1..2e9a5f2e6e32 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1130,9 +1130,13 @@ function Server(options, secureConnectionListener): void { if (serverOptions?.ALPNProtocols) convertALPNProtocols(serverOptions.ALPNProtocols, this); this._sharedCreds = undefined; - let contexts: Map | null = null; + const contexts = new Map>(); this.addContext = function (hostname, context) { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574 + if (!hostname) { + throw $ERR_TLS_REQUIRED_SERVER_NAME(); + } if (typeof hostname !== "string") { throw new TypeError("hostname must be a string"); } @@ -1144,10 +1148,10 @@ function Server(options, secureConnectionListener): void { // Pass the native SSL_CTX wrapper, not the JS InternalSecureContext — // the native side detects it via SecureContext.fromJS and up_refs. addServerName(handle, hostname, context.context); - } else { - if (!contexts) contexts = new Map(); - contexts.set(hostname, context); } + // listen() replays the map in order: a re-added name moves to the end. + contexts.$delete(hostname); + contexts.$set(hostname, context); }; this.setSecureContext = function (options) { diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index fe4b6c4d73c7..396fb9fc9443 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2380,6 +2380,8 @@ JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(Bun::jsFunctionMakeErrorWithCode, __att return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_UNAVAILABLE_DURING_EXIT, "Cannot call function in process exit handler"_s)); case ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT, "Invalid subject alternative name string"_s)); + case ErrorCode::ERR_TLS_REQUIRED_SERVER_NAME: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_REQUIRED_SERVER_NAME, "\"servername\" is required parameter for Server.addContext"_s)); case ErrorCode::ERR_TLS_SNI_FROM_SERVER: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_SNI_FROM_SERVER, "Cannot issue SNI from a TLS server-side socket"_s)); case ErrorCode::ERR_TLS_INVALID_STATE: diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 2d6c59a241eb..1819ef40ab00 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -247,6 +247,7 @@ const errors: ErrorCodeMapping = [ ["ERR_TLS_PROTOCOL_VERSION_CONFLICT", TypeError], ["ERR_TLS_RENEGOTIATION_DISABLED", Error], ["ERR_TLS_RENEGOTIATION_UNSUPPORTED", Error], + ["ERR_TLS_REQUIRED_SERVER_NAME", Error], ["ERR_TLS_SNI_FROM_SERVER", Error], ["ERR_TLS_INVALID_STATE", Error], ["ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS", TypeError], diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index a53d6375292e..eb3da4465858 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1995,6 +1995,116 @@ describe("addContext with a name of more than 10 labels", () => { }); }); +describe("addContext() entries apply to every listen()", () => { + // tls.Server keeps every entry, like node's server._contexts, and loads them into each listener it creates. + const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); + const agent1 = { key: fixture("agent1-key.pem"), cert: fixture("agent1-cert.pem") }; + const agent2 = { key: fixture("agent2-key.pem"), cert: fixture("agent2-cert.pem") }; + const agent3 = { key: fixture("agent3-key.pem"), cert: fixture("agent3-cert.pem") }; + + async function listen(server: Server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return server.address() as AddressInfo; + } + + async function relisten(server: Server) { + server.close(); + await once(server, "close"); + return listen(server); + } + + // The CN of the certificate the server presents for `servername`. + async function servedCN({ address, port }: AddressInfo, servername: string) { + const client = connect({ host: address, port, servername, rejectUnauthorized: false }); + try { + await once(client, "secureConnect"); + return client.getPeerCertificate().subject.CN; + } finally { + client.destroy(); + } + } + + it("an entry added while listening is still there after close() and listen()", async () => { + const server: Server = createServer(agent1, socket => socket.end()); + try { + const first = await listen(server); + server.addContext("added.example", agent2); + const whileListening = await servedCN(first, "added.example"); + const second = await relisten(server); + expect({ + whileListening, + afterRelisten: await servedCN(second, "added.example"), + otherName: await servedCN(second, "other.example"), + }).toEqual({ + whileListening: "agent2", + afterRelisten: "agent2", + otherName: "agent1", + }); + } finally { + server.close(); + } + }); + + it("an entry replaced while listening stays replaced after close() and listen()", async () => { + const server: Server = createServer(agent1, socket => socket.end()); + server.addContext("rotated.example", agent2); + try { + const first = await listen(server); + const beforeReplace = await servedCN(first, "rotated.example"); + server.addContext("rotated.example", agent3); + const afterReplace = await servedCN(first, "rotated.example"); + const second = await relisten(server); + expect({ + beforeReplace, + afterReplace, + afterRelisten: await servedCN(second, "rotated.example"), + }).toEqual({ + beforeReplace: "agent2", + afterReplace: "agent3", + afterRelisten: "agent3", + }); + } finally { + server.close(); + } + }); + + it("addContext() without a servername throws ERR_TLS_REQUIRED_SERVER_NAME and keeps no entry", async () => { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574 + const required = expect.objectContaining({ + name: "Error", + code: "ERR_TLS_REQUIRED_SERVER_NAME", + message: '"servername" is required parameter for Server.addContext', + }); + const server: Server = createServer(agent1, socket => socket.end()); + try { + // A kept empty name would make every listen() fail. + for (const servername of ["", undefined, null]) { + expect(() => server.addContext(servername as any, agent2)).toThrow(required); + } + await listen(server); + expect(() => server.addContext("", agent2)).toThrow(required); + expect(await servedCN(await relisten(server), "added.example")).toBe("agent1"); + } finally { + server.close(); + } + }); + + it("listen() loads the entries in addContext() call order", async () => { + // "ordered.example." and "ordered.example" land on the same native SNI + // entry, so the order a listener receives them in decides what it serves. + const server: Server = createServer(agent1, socket => socket.end()); + server.addContext("ordered.example", agent2); + server.addContext("ordered.example.", agent2); + server.addContext("ordered.example", agent3); + try { + expect(await servedCN(await listen(server), "ordered.example")).toBe("agent3"); + } finally { + server.close(); + } + }); +}); + describe("tls.Server socket destroySoon", () => { // destroySoon() after end(big) must deliver every byte even when the TLS write // batcher's final flush spills (#31584). The spill/kernel-buffer race hits ~4% of From f4baf757bd4388c8ddc1eceeb46f99b0d0841d16 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:14:27 +0000 Subject: [PATCH 029/174] node:tls: build the tls.Server SecureContext in setSecureContext() (#42050) A server that is not listening only stashed the raw fields, so key or certificate material BoringSSL rejects surfaced later, out of listen() or the first injected connection. setSecureContext() now builds the shared context from the staged fields before it assigns anything, like Node's _sharedCreds, so the call (and tls.createServer / http2.createSecureServer) throws and the previous credentials stay. The lazy getter, its try/catch in the 'connection' listener and the JS KEY_TYPE_MISMATCH pre-check go away. --- src/js/node/_http2_upgrade.ts | 5 +- src/js/node/tls.ts | 48 ++--- test/js/node/tls/node-tls-server.test.ts | 223 +++++++++++------------ test/js/node/tls/ssl-ctx-cache.test.ts | 50 ++++- test/regression/issue/24924.test.ts | 16 +- 5 files changed, 172 insertions(+), 170 deletions(-) diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index 98739ae42758..711013d4b46b 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -1,6 +1,5 @@ const { Duplex } = require("node:stream"); const upgradeDuplexToTLS = $newRustFunction("runtime/socket/socket.rs", "jsUpgradeDuplexToTLS", 2); -const kSharedCreds = Symbol.for("::buntlssharedcreds::"); interface NativeHandle { resume(): void; @@ -30,7 +29,7 @@ interface Http2SecureServer { _requestCert?: boolean; _rejectUnauthorized?: boolean; emit(event: string, ...args: any[]): boolean; - [kSharedCreds](): { context: unknown }; + _sharedCreds: { context: unknown }; } type DuplexStream = import("node:stream").Duplex; @@ -345,7 +344,7 @@ function upgradeRawSocketToH2( [handle, events] = upgradeDuplexToTLS(rawSocket, { isServer: true, tls: { - secureContext: server[kSharedCreds]().context, + secureContext: server._sharedCreds.context, requestCert: server._requestCert, rejectUnauthorized: server._rejectUnauthorized, ALPNProtocols: server.ALPNProtocols diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 2e9a5f2e6e32..1f79560fd39d 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -597,13 +597,11 @@ function translatePeerCertificate(c) { } const ksecureContext = Symbol("ksecureContext"); -const ksharedCredsOptions = Symbol("ksharedCredsOptions"); const kcheckServerIdentity = Symbol("kcheckServerIdentity"); const ksession = Symbol("ksession"); const krenegotiationDisabled = Symbol("renegotiationDisabled"); const buntls = Symbol.for("::buntls::"); -const kSharedCreds = Symbol.for("::buntlssharedcreds::"); // net.ts's SNI dispatch uses this to recognize a raw native SecureContext // (Node's `context.context || context` unwrap accepts both the wrapper and // the unwrapped native context). @@ -1044,10 +1042,10 @@ TLSSocket.prototype[buntls] = function (port, host) { let CLIENT_RENEG_LIMIT = 3, CLIENT_RENEG_WINDOW = 600; -function buildSharedCreds(server) { - return (server._sharedCreds = new InternalSecureContext( +function buildSharedCreds(server, options) { + return new InternalSecureContext( { - ...server[ksharedCredsOptions], + ...options, pfx: undefined, _pfxExtraCACerts: undefined, key: server.key, @@ -1064,9 +1062,12 @@ function buildSharedCreds(server) { secureProtocol: server.secureProtocol, minVersion: server.minVersion, maxVersion: server.maxVersion, + // Part of the interning key: one SSL_CTX is one session cache. + requestCert: server._requestCert === true, + rejectUnauthorized: server._rejectUnauthorized, }, true, - )); + ); } function Server(options, secureConnectionListener): void { @@ -1277,14 +1278,16 @@ function Server(options, secureConnectionListener): void { next.minVersion = options.minVersion; next.maxVersion = options.maxVersion; } + const isContext = serverTLSOptions instanceof InternalSecureContext; + // [buntls] and buildSharedCreds read the staged fields over the server's own. + const staged = { __proto__: this, ...next }; + // Both throw on material BoringSSL rejects, so they run before the fields change. + const sharedCreds = isContext ? serverTLSOptions : buildSharedCreds(staged, serverTLSOptions); + const handle = this._handle; + if (handle && options && !isContext) { + setListenerSecureContext(handle, staged[buntls](0, undefined, false)[0]); + } if (options) { - // Throws on material BoringSSL rejects, so it runs before the fields change. - const handle = this._handle; - if (handle && !(serverTLSOptions instanceof InternalSecureContext)) { - // [buntls] reads its receiver: the staged fields over the server's own. - const staged = { __proto__: this, ...next }; - setListenerSecureContext(handle, staged[buntls](0, undefined, false)[0]); - } this.cert = next.cert; this.key = next.key; this.ca = next.ca; @@ -1301,11 +1304,7 @@ function Server(options, secureConnectionListener): void { this.minVersion = next.minVersion; this.maxVersion = next.maxVersion; } - this._sharedCreds = serverTLSOptions instanceof InternalSecureContext ? serverTLSOptions : null; - this[ksharedCredsOptions] = - serverTLSOptions == null || serverTLSOptions instanceof InternalSecureContext - ? serverTLSOptions - : { ...serverTLSOptions }; + this._sharedCreds = sharedCreds; }; // Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to @@ -1387,16 +1386,8 @@ function Server(options, secureConnectionListener): void { // TLS layer, like Node's tls.Server wraps any injected duplex // (node v26.3.0 lib/_tls_wrap.js, Server's connection listener). if (!socket || (socket.encrypted && socket.server === this)) return; - let secureContext; - try { - secureContext = this[kSharedCreds](); - } catch (err) { - socket.destroy(); - this.emit("error", err); - return; - } const wrapped = new TLSSocket(socket, { - secureContext, + secureContext: this._sharedCreds, isServer: true, requestCert: this._requestCert, rejectUnauthorized: this._rejectUnauthorized, @@ -1418,9 +1409,6 @@ function Server(options, secureConnectionListener): void { } } $toClass(Server, "Server", NetServer); -Server.prototype[kSharedCreds] = function () { - return this._sharedCreds || buildSharedCreds(this); -}; function createServer(options, connectionListener) { return new Server(options, connectionListener); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index eb3da4465858..761f205fdd0c 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -203,94 +203,48 @@ describe("tls.createServer listen", () => { ); }); - it("should not listen with wrong password", done => { - const { mustCall, mustNotCall } = createCallCheckCtx(done); - - const server: Server = createServer({ - key: passKey, - passphrase: "invalid", - cert: cert, - }); - - server.on("error", mustCall()); - let timeout: Timer; - function closeAndFail() { - clearTimeout(timeout); - server.close(); - mustNotCall()(); - } - - timeout = setTimeout(closeAndFail, 100); - - server.listen(0, "0.0.0.0", closeAndFail); + // Node decrypts the key while tls.createServer() builds the SecureContext, so + // a wrong or missing passphrase throws synchronously from the constructor + // instead of surfacing later on the 'error' event of listen(). + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1383 + it("should throw from createServer with wrong password", () => { + expect(() => + createServer({ + key: passKey, + passphrase: "invalid", + cert: cert, + }), + ).toThrow(expect.objectContaining({ code: "ERR_OSSL_BAD_DECRYPT" })); }); - it("should reject passphrase longer than PEM_BUFSIZE without crashing", done => { + it("should reject passphrase longer than PEM_BUFSIZE without crashing", () => { // BoringSSL invokes the passphrase callback with a 1024-byte stack buffer. // A longer passphrase must fail key decryption rather than overflow that buffer. - const { mustCall, mustNotCall } = createCallCheckCtx(done); - - const server: Server = createServer({ - key: passKey, - passphrase: Buffer.alloc(2000, "A").toString(), - cert: cert, - }); - - server.on("error", mustCall()); - let timeout: Timer; - function closeAndFail() { - clearTimeout(timeout); - server.close(); - mustNotCall()(); - } - - timeout = setTimeout(closeAndFail, 100); - - server.listen(0, "0.0.0.0", closeAndFail); + expect(() => + createServer({ + key: passKey, + passphrase: Buffer.alloc(2000, "A").toString(), + cert: cert, + }), + ).toThrow(expect.objectContaining({ code: expect.stringMatching(/^ERR_OSSL_/) })); }); - it("should not listen without cert", done => { - const { mustCall, mustNotCall } = createCallCheckCtx(done); - - const server: Server = createServer({ - key: passKey, - passphrase: "invalid", - }); - - server.on("error", mustCall()); - - let timeout: Timer; - function closeAndFail() { - clearTimeout(timeout); - server.close(); - mustNotCall()(); - } - - timeout = setTimeout(closeAndFail, 100); - - server.listen(0, "0.0.0.0", closeAndFail); + it("should throw from createServer with wrong password and no cert", () => { + expect(() => + createServer({ + key: passKey, + passphrase: "invalid", + }), + ).toThrow(expect.objectContaining({ code: "ERR_OSSL_BAD_DECRYPT" })); }); - it("should not listen without password", done => { - const { mustCall, mustNotCall } = createCallCheckCtx(done); - - const server: Server = createServer({ - key: passKey, - cert: cert, - }); - - server.on("error", mustCall()); - - let timeout: Timer; - function closeAndFail() { - clearTimeout(timeout); - server.close(); - mustNotCall()(); - } - - timeout = setTimeout(closeAndFail, 100); - - server.listen(0, "0.0.0.0", closeAndFail); + it("should throw from createServer without password", () => { + expect(() => + createServer({ + key: passKey, + cert: cert, + }), + ).toThrow(expect.objectContaining({ code: "ERR_OSSL_BAD_DECRYPT" })); }); }); @@ -1818,6 +1772,27 @@ describe("setSecureContext() on a listening server", () => { } }); + it("a rejected call changes neither the listener nor the context injected sockets get", async () => { + const server: Server = createServer({ ...agent1 }); + const front = net.createServer(raw => server.emit("connection", raw)); + try { + const { port } = await listen(server); + front.listen(0, "127.0.0.1"); + await once(front, "listening"); + // agent3's key does not belong to agent2's certificate. + expect(() => server.setSecureContext({ key: agent3.key, cert: agent2.cert })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH" }), + ); + expect({ + accepted: await handshake({ port, host: "127.0.0.1" }), + injected: await handshake({ port: (front.address() as AddressInfo).port, host: "127.0.0.1" }), + }).toMatchObject({ accepted: { cn: "agent1" }, injected: { cn: "agent1" } }); + } finally { + front.close(); + server.close(); + } + }); + // A cluster worker's listen() completes when the primary answers. A call // made before that has to reach the listener the worker then creates. it("counts in a cluster worker when called before 'listening'", async () => { @@ -1865,6 +1840,47 @@ it("an addContext() wildcard covers the hostname the server is bound to", async } }); +it("setSecureContext() with material the native loader rejects throws synchronously before listen()", async () => { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1520-L1542 + const server: Server = createServer(COMMON_CERT); + expect(() => server.setSecureContext({ key: "garbage", cert: "garbage" })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_PEM_NO_START_LINE" }), + ); + // rsa_private.pem is not the key of the harness certificate. + expect(() => server.setSecureContext({ key: rawKey, cert: COMMON_CERT.cert })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH" }), + ); + + const errors: Error[] = []; + server.on("error", err => errors.push(err)); + server.on("secureConnection", socket => socket.end()); + const listening = Promise.withResolvers(); + server.listen(0, "127.0.0.1", () => listening.resolve()); + server.once("error", listening.reject); + await listening.promise; + let client: TLSSocket | undefined; + try { + const connected = Promise.withResolvers(); + client = connect( + { + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + checkServerIdentity: () => undefined, + }, + () => connected.resolve(), + ); + client.on("error", connected.reject); + await connected.promise; + const expectedCert = new crypto.X509Certificate(COMMON_CERT.cert); + expect(client.getPeerCertificate().fingerprint256).toBe(expectedCert.fingerprint256); + expect(errors).toEqual([]); + } finally { + client?.destroy(); + server.close(); + } +}); + it("SNICallback rejecting with a non-Error value drops the connection (no hang)", async () => { // cb(true) / cb("reason"): Node treats any truthy err as an abort. The // boolean form must not be confused with internal sentinels - the @@ -2524,45 +2540,14 @@ describe("tls.Server secure-context options", () => { } }); - it("surfaces a natively-rejected key on the server 'error' event for a STARTTLS-only server", async () => { - // Node throws this from tls.createServer() itself; bun builds the context - // lazily and reports native load failures on the server 'error' event at - // listen() time, so the STARTTLS wrap must use that same surface instead - // of throwing synchronously out of the user's server.emit('connection'). - const tlsServer = createServer({ key: "not a private key", cert: agent6CertChain }); - const surfaced = Promise.withResolvers(); - tlsServer.on("error", surfaced.resolve); - const emitted: string[] = []; - let raw: net.Socket | undefined; - const rawServer = net.createServer(sock => { - raw = sock; - try { - tlsServer.emit("connection", sock); - emitted.push("returned"); - } catch (e) { - emitted.push("threw"); - surfaced.resolve(e as Error); - } - }); - let client: net.Socket | undefined; - try { - const listening = Promise.withResolvers(); - rawServer.once("error", listening.reject); - rawServer.listen(0, "127.0.0.1", listening.resolve); - await listening.promise; - client = net.connect((rawServer.address() as AddressInfo).port, "127.0.0.1"); - client.on("error", () => {}); - const err = await surfaced.promise; - expect({ emitted, code: err.code, rawDestroyed: raw!.destroyed }).toEqual({ - emitted: ["returned"], - code: "ERR_OSSL_PEM_NO_START_LINE", - rawDestroyed: true, - }); - } finally { - client?.destroy(); - rawServer.close(); - tlsServer.close(); - } + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1383 + it.each([ + ["tls.createServer", createServer], + ["http2.createSecureServer", http2.createSecureServer], + ])("%s() itself throws on a certificate the native loader rejects", (_, create) => { + expect(() => create({ key: agent6Key, cert: "not a certificate" })).toThrow( + expect.objectContaining({ code: "ERR_OSSL_PEM_NO_START_LINE" }), + ); }); it("a failing setSecureContext() leaves the STARTTLS wrap credentials untouched", async () => { diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index c5c1d1d463f3..104682ee7caa 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -375,6 +375,32 @@ test("setDefaultCACertificates() applies to a server's client-cert verification } }); +test("a server's shared SSL_CTX is interned by its client-certificate policy too", () => { + // The shared context carries the client-certificate policy in its verify + // mode, and one SSL_CTX is one session cache. Two servers that share key + // material but not that policy must get separate contexts: otherwise a + // session minted by the permissive server resumes on the mTLS server, and a + // resumed handshake skips client authentication. + const permissive = tls.createServer({ ...tlsCerts }); + const mutual = tls.createServer({ ...tlsCerts, requestCert: true }); + const optionalCert = tls.createServer({ ...tlsCerts, requestCert: true, rejectUnauthorized: false }); + // The same options still intern to one context, so the split above is the + // policy and not a broken digest. + const sameAsPermissive = tls.createServer({ ...tlsCerts }); + const ctx = (s: tls.Server) => (s as any)._sharedCreds.context; + try { + expect(ctx(permissive)).not.toBe(ctx(mutual)); + expect(ctx(permissive)).not.toBe(ctx(optionalCert)); + expect(ctx(mutual)).not.toBe(ctx(optionalCert)); + expect(ctx(sameAsPermissive)).toBe(ctx(permissive)); + // requestCert is constructor-only, so a later call does not name it. + mutual.setSecureContext({ ...tlsCerts }); + expect(ctx(mutual)).not.toBe(ctx(permissive)); + } finally { + for (const s of [permissive, mutual, optionalCert, sameAsPermissive]) s.close(); + } +}); + // `tls.Server.close()` must release the listener's SSL_CTX ref immediately. // It used to be dropped only when the GC finalized the Listener, so a `Server` // the program still references — or any server at process exit — kept its CTX @@ -383,23 +409,35 @@ test("setDefaultCACertificates() applies to a server's client-cert verification // `us_internal_init_listen_socket` and each accepted socket's `SSL_new()` takes // another, so releasing at close() cannot dangle. test("tls.Server.close() releases the listener's SSL_CTX without waiting for GC", async () => { - Bun.gc(true); - const before = sslCtxLiveCount(); - // Hold strong references so the GC can never finalize these Listeners; a // distinct `sessionTimeout` per server gives each its own cache entry. + // tls.createServer() builds the server's shared SecureContext up front, and + // that one lives as long as the Server object (like Node's `_sharedCreds`), + // so the baseline is taken with the servers constructed: the listener's own + // SSL_CTX is the one close() must release. const kept: tls.Server[] = []; for (let i = 0; i < 5; i++) { - const server = tls.createServer({ ...tlsCerts, sessionTimeout: 100 + i }); + kept.push(tls.createServer({ ...tlsCerts, sessionTimeout: 100 + i })); + } + Bun.gc(true); + const before = sslCtxLiveCount(); + + let peak = 0; + for (const server of kept) { server.listen(0, "127.0.0.1"); await once(server, "listening"); + peak = Math.max(peak, sslCtxLiveCount() - before); server.close(); await once(server, "close"); - kept.push(server); } // No Bun.gc() here on purpose: the point is that close() alone frees them. - expect({ leaked: sslCtxLiveCount() - before, servers: kept.length }).toEqual({ leaked: 0, servers: 5 }); + // `peak: 1` proves each listen() did hold a context of its own meanwhile. + expect({ leaked: sslCtxLiveCount() - before, peak, servers: kept.length }).toEqual({ + leaked: 0, + peak: 1, + servers: 5, + }); }); // Releasing at close() must not pull the CTX out from under a socket the diff --git a/test/regression/issue/24924.test.ts b/test/regression/issue/24924.test.ts index b2cd6912a1e5..40e710cbd4c9 100644 --- a/test/regression/issue/24924.test.ts +++ b/test/regression/issue/24924.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { tls as tlsCert } from "harness"; import * as http2 from "http2"; test("Http2Server.setTimeout returns server instance for method chaining", () => { @@ -34,10 +35,7 @@ test("Http2Server.setTimeout allows method chaining with close", () => { }); test("Http2SecureServer.setTimeout returns server instance for method chaining", () => { - const server = http2.createSecureServer({ - key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj\nMzEfYyjiWA4R4/M2bS1+fWIcPm15A8+raZ4dp+/PJE0NOsmuurvzNCS138P6AqQ0\njdsEYDbPszN+X9XYqXqYfKmSBGbGwPqaqCGhqtRaKNl1s1x5dn/7CJPxr/vHNQXL\nJZ+kP1Mb9FsMqmHbYJFuJB/T8fJuMJRKqY7J1W3J+cVvvvQy2eJXqZqVj1/lXZXR\nUH0WqWdQnfnPj3Lh6qKm8Cf8qh3Uug2o3GWG0UE6YwLqNfBCULrOeRLtCqkbMdHb\nF7XvL0kHNvf3eFiVZ8cJ/KcIVIEhiqOQkZj3qMRqGqEP0H9dqYHK1Dj5cLxqxXCL\nLKxKP0SZAgMBAAECggEAZj3P3V3fKKvqZgPpzLQrjwHpBKZOhRWTR+zQfAVqN7rW\nc3MrdOzP7cVLN7CYsPZF7JpXY5/hYvjOXPQr5tqKJZ/+T5hYJ4CUYhJbMlxL0pIR\njXNLJFVz3ZQJNmWAe3h3H0TpE7oZcYR7nHVWKZ4I3WxX1TxT3a8QN6BXGCbJwYEk\n5QI4SLFiV3f3Hh0xQZPPfZGP/M3EVGfEOxZWqkZxkDCj5F9DH+r0J2VxLM5zWMjR\n5xNLkH1TlJ3Lg6w2YWmLKV5KdQcX+fKJJCx7oGhPIkvHCfKJ1MK3Y/QG9TFG3qF0\ndJVWBVLLU7K7iLaBN3jZHJJhZqhGXPxGUMaZ6EfgQQKBgQDmrE3PVB8uZLb0Gjzn\nNx0C+A8MX4J4fW0r5WfXZfL7V7fR9V9P3bN6Q9FVyp3mZJ9T/xHpCQQJ1KhKqS0k\nV7NcJL5ZWU1nX1MxFcJMCYYRVpXVp3Jh0O2Q7tWxR+zYV9JxJL1Q1Q3Z1dFqL9JQ\nK8qVxV3hGgXJFqKGdKxKqKqKqQKBgQDQfWZI/YQqv8JhxVWdJbP8O5J1Z0xVFqQP\n6LF3x6vJ0Z+7xI9QxJpQF9xN1K6hJ9JpF+ZQ7J3V1F0L5Q0J9V0xL+F1J0xF9F1J\nV0J0F1J9V0J0F1J9V0J0F9V1J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1J\nV0J0F1JV0J0F1JQKBgFqF3V1F0L5Q0J9V0xL+F1J0xF9F1JV0J0F1J9V0J0F1J9\nV0J0F9V1J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1Jp\nAoGBAKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqp\nAoGAV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0\nF1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F\n1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1\nJV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1J\nV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n-----END PRIVATE KEY-----", - cert: "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUXqYYXq7XqYq7q7q7q7q7q7q7q7owDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAx\nMDEwMDAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\nHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQC7VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15\nA8+raZ4dp+/PJE0NOsmuurvzNCS138P6AqQ0jdsEYDbPszN+X9XYqXqYfKmSBGbG\nwPqaqCGhqtRaKNl1s1x5dn/7CJPxr/vHNQXLJZ+kP1Mb9FsMqmHbYJFuJB/T8fJu\nMJRKqY7J1W3J+cVvvvQy2eJXqZqVj1/lXZXRUH0WqWdQnfnPj3Lh6qKm8Cf8qh3U\nug2o3GWG0UE6YwLqNfBCULrOeRLtCqkbMdHbF7XvL0kHNvf3eFiVZ8cJ/KcIVIEh\niqOQkZj3qMRqGqEP0H9dqYHK1Dj5cLxqxXCLLKxKP0SZAgMBAAGjUzBRMB0GA1Ud\nDgQWBBQQXqYYXq7XqYq7q7q7q7q7q7q7qzAfBgNVHSMEGDAWgBQQXqYYXq7XqYq7\nq7q7q7q7q7q7qzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC7\nVJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15A8+raZ4dp+/PJE0NOsmuurvz\nNCS138P6AqQ0jdsEYDbPszN+X9XYqXqYfKmSBGbGwPqaqCGhqtRaKNl1s1x5dn/7\nCJPxr/vHNQXLJZ+kP1Mb9FsMqmHbYJFuJB/T8fJuMJRKqY7J1W3J+cVvvvQy2eJX\nqZqVj1/lXZXRUH0WqWdQnfnPj3Lh6qKm8Cf8qh3Uug2o3GWG0UE6YwLqNfBCULrO\neRLtCqkbMdHbF7XvL0kHNvf3eFiVZ8cJ/KcIVIEhiqOQkZj3qMRqGqEP0H9dqYHK\n1Dj5cLxqxXCLLKxKP0SZ\n-----END CERTIFICATE-----", - }); + const server = http2.createSecureServer({ key: tlsCert.key, cert: tlsCert.cert }); try { const result = server.setTimeout(1000); @@ -48,10 +46,7 @@ test("Http2SecureServer.setTimeout returns server instance for method chaining", }); test("Http2SecureServer.setTimeout with callback returns server instance", () => { - const server = http2.createSecureServer({ - key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj\nMzEfYyjiWA4R4/M2bS1+fWIcPm15A8+raZ4dp+/PJE0NOsmuurvzNCS138P6AqQ0\njdsEYDbPszN+X9XYqXqYfKmSBGbGwPqaqCGhqtRaKNl1s1x5dn/7CJPxr/vHNQXL\nJZ+kP1Mb9FsMqmHbYJFuJB/T8fJuMJRKqY7J1W3J+cVvvvQy2eJXqZqVj1/lXZXR\nUH0WqWdQnfnPj3Lh6qKm8Cf8qh3Uug2o3GWG0UE6YwLqNfBCULrOeRLtCqkbMdHb\nF7XvL0kHNvf3eFiVZ8cJ/KcIVIEhiqOQkZj3qMRqGqEP0H9dqYHK1Dj5cLxqxXCL\nLKxKP0SZAgMBAAECggEAZj3P3V3fKKvqZgPpzLQrjwHpBKZOhRWTR+zQfAVqN7rW\nc3MrdOzP7cVLN7CYsPZF7JpXY5/hYvjOXPQr5tqKJZ/+T5hYJ4CUYhJbMlxL0pIR\njXNLJFVz3ZQJNmWAe3h3H0TpE7oZcYR7nHVWKZ4I3WxX1TxT3a8QN6BXGCbJwYEk\n5QI4SLFiV3f3Hh0xQZPPfZGP/M3EVGfEOxZWqkZxkDCj5F9DH+r0J2VxLM5zWMjR\n5xNLkH1TlJ3Lg6w2YWmLKV5KdQcX+fKJJCx7oGhPIkvHCfKJ1MK3Y/QG9TFG3qF0\ndJVWBVLLU7K7iLaBN3jZHJJhZqhGXPxGUMaZ6EfgQQKBgQDmrE3PVB8uZLb0Gjzn\nNx0C+A8MX4J4fW0r5WfXZfL7V7fR9V9P3bN6Q9FVyp3mZJ9T/xHpCQQJ1KhKqS0k\nV7NcJL5ZWU1nX1MxFcJMCYYRVpXVp3Jh0O2Q7tWxR+zYV9JxJL1Q1Q3Z1dFqL9JQ\nK8qVxV3hGgXJFqKGdKxKqKqKqQKBgQDQfWZI/YQqv8JhxVWdJbP8O5J1Z0xVFqQP\n6LF3x6vJ0Z+7xI9QxJpQF9xN1K6hJ9JpF+ZQ7J3V1F0L5Q0J9V0xL+F1J0xF9F1J\nV0J0F1J9V0J0F1J9V0J0F9V1J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1J\nV0J0F1JV0J0F1JQKBgFqF3V1F0L5Q0J9V0xL+F1J0xF9F1JV0J0F1J9V0J0F1J9\nV0J0F9V1J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1Jp\nAoGBAKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqp\nAoGAV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0\nF1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F\n1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1\nJV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1J\nV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n-----END PRIVATE KEY-----", - cert: "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUXqYYXq7XqYq7q7q7q7q7q7q7q7owDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAx\nMDEwMDAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\nHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQC7VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15\nA8+raZ4dp+/PJE0NOsmuurvzNCS138P6AqQ0jdsEYDbPszN+X9XYqXqYfKmSBGbG\nwPqaqCGhqtRaKNl1s1x5dn/7CJPxr/vHNQXLJZ+kP1Mb9FsMqmHbYJFuJB/T8fJu\nMJRKqY7J1W3J+cVvvvQy2eJXqZqVj1/lXZXRUH0WqWdQnfnPj3Lh6qKm8Cf8qh3U\nug2o3GWG0UE6YwLqNfBCULrOeRLtCqkbMdHbF7XvL0kHNvf3eFiVZ8cJ/KcIVIEh\niqOQkZj3qMRqGqEP0H9dqYHK1Dj5cLxqxXCLLKxKP0SZAgMBAAGjUzBRMB0GA1Ud\nDgQWBBQQXqYYXq7XqYq7q7q7q7q7q7q7qzAfBgNVHSMEGDAWgBQQXqYYXq7XqYq7\nq7q7q7q7q7q7qzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC7\nVJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15A8+raZ4dp+/PJE0NOsmuurvz\nNCS138P6AqQ0jdsEYDbPszN+X9XYqXqYfKmSBGbGwPqaqCGhqtRaKNl1s1x5dn/7\nCJPxr/vHNQXLJZ+kP1Mb9FsMqmHbYJFuJB/T8fJuMJRKqY7J1W3J+cVvvvQy2eJX\nqZqVj1/lXZXRUH0WqWdQnfnPj3Lh6qKm8Cf8qh3Uug2o3GWG0UE6YwLqNfBCULrO\neRLtCqkbMdHbF7XvL0kHNvf3eFiVZ8cJ/KcIVIEhiqOQkZj3qMRqGqEP0H9dqYHK\n1Dj5cLxqxXCLLKxKP0SZ\n-----END CERTIFICATE-----", - }); + const server = http2.createSecureServer({ key: tlsCert.key, cert: tlsCert.cert }); const callback = () => {}; try { @@ -63,10 +58,7 @@ test("Http2SecureServer.setTimeout with callback returns server instance", () => }); test("Http2SecureServer.setTimeout allows method chaining with close", () => { - const server = http2.createSecureServer({ - key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj\nMzEfYyjiWA4R4/M2bS1+fWIcPm15A8+raZ4dp+/PJE0NOsmuurvzNCS138P6AqQ0\njdsEYDbPszN+X9XYqXqYfKmSBGbGwPqaqCGhqtRaKNl1s1x5dn/7CJPxr/vHNQXL\nJZ+kP1Mb9FsMqmHbYJFuJB/T8fJuMJRKqY7J1W3J+cVvvvQy2eJXqZqVj1/lXZXR\nUH0WqWdQnfnPj3Lh6qKm8Cf8qh3Uug2o3GWG0UE6YwLqNfBCULrOeRLtCqkbMdHb\nF7XvL0kHNvf3eFiVZ8cJ/KcIVIEhiqOQkZj3qMRqGqEP0H9dqYHK1Dj5cLxqxXCL\nLKxKP0SZAgMBAAECggEAZj3P3V3fKKvqZgPpzLQrjwHpBKZOhRWTR+zQfAVqN7rW\nc3MrdOzP7cVLN7CYsPZF7JpXY5/hYvjOXPQr5tqKJZ/+T5hYJ4CUYhJbMlxL0pIR\njXNLJFVz3ZQJNmWAe3h3H0TpE7oZcYR7nHVWKZ4I3WxX1TxT3a8QN6BXGCbJwYEk\n5QI4SLFiV3f3Hh0xQZPPfZGP/M3EVGfEOxZWqkZxkDCj5F9DH+r0J2VxLM5zWMjR\n5xNLkH1TlJ3Lg6w2YWmLKV5KdQcX+fKJJCx7oGhPIkvHCfKJ1MK3Y/QG9TFG3qF0\ndJVWBVLLU7K7iLaBN3jZHJJhZqhGXPxGUMaZ6EfgQQKBgQDmrE3PVB8uZLb0Gjzn\nNx0C+A8MX4J4fW0r5WfXZfL7V7fR9V9P3bN6Q9FVyp3mZJ9T/xHpCQQJ1KhKqS0k\nV7NcJL5ZWU1nX1MxFcJMCYYRVpXVp3Jh0O2Q7tWxR+zYV9JxJL1Q1Q3Z1dFqL9JQ\nK8qVxV3hGgXJFqKGdKxKqKqKqQKBgQDQfWZI/YQqv8JhxVWdJbP8O5J1Z0xVFqQP\n6LF3x6vJ0Z+7xI9QxJpQF9xN1K6hJ9JpF+ZQ7J3V1F0L5Q0J9V0xL+F1J0xF9F1J\nV0J0F1J9V0J0F1J9V0J0F9V1J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1J\nV0J0F1JV0J0F1JQKBgFqF3V1F0L5Q0J9V0xL+F1J0xF9F1JV0J0F1J9V0J0F1J9\nV0J0F9V1J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1Jp\nAoGBAKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqK\nqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqKqp\nAoGAV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0\nF1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F\n1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1\nJV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1J\nV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV0J0F1JV\n-----END PRIVATE KEY-----", - cert: "-----BEGIN CERTIFICATE-----\nMIIDazCCAlOgAwIBAgIUXqYYXq7XqYq7q7q7q7q7q7q7q7owDQYJKoZIhvcNAQEL\nBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\nGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAxMDEwMDAwMDBaFw0yNTAx\nMDEwMDAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\nHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQC7VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15\nA8+raZ4dp+/PJE0NOsmuurvzNCS138P6AqQ0jdsEYDbPszN+X9XYqXqYfKmSBGbG\nwPqaqCGhqtRaKNl1s1x5dn/7CJPxr/vHNQXLJZ+kP1Mb9FsMqmHbYJFuJB/T8fJu\nMJRKqY7J1W3J+cVvvvQy2eJXqZqVj1/lXZXRUH0WqWdQnfnPj3Lh6qKm8Cf8qh3U\nug2o3GWG0UE6YwLqNfBCULrOeRLtCqkbMdHbF7XvL0kHNvf3eFiVZ8cJ/KcIVIEh\niqOQkZj3qMRqGqEP0H9dqYHK1Dj5cLxqxXCLLKxKP0SZAgMBAAGjUzBRMB0GA1Ud\nDgQWBBQQXqYYXq7XqYq7q7q7q7q7q7q7qzAfBgNVHSMEGDAWgBQQXqYYXq7XqYq7\nq7q7q7q7q7q7qzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQC7\nVJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15A8+raZ4dp+/PJE0NOsmuurvz\nNCS138P6AqQ0jdsEYDbPszN+X9XYqXqYfKmSBGbGwPqaqCGhqtRaKNl1s1x5dn/7\nCJPxr/vHNQXLJZ+kP1Mb9FsMqmHbYJFuJB/T8fJuMJRKqY7J1W3J+cVvvvQy2eJX\nqZqVj1/lXZXRUH0WqWdQnfnPj3Lh6qKm8Cf8qh3Uug2o3GWG0UE6YwLqNfBCULrO\neRLtCqkbMdHbF7XvL0kHNvf3eFiVZ8cJ/KcIVIEhiqOQkZj3qMRqGqEP0H9dqYHK\n1Dj5cLxqxXCLLKxKP0SZ\n-----END CERTIFICATE-----", - }); + const server = http2.createSecureServer({ key: tlsCert.key, cert: tlsCert.cert }); // This should not throw - chaining should work expect(() => { From c7a2de97ff407884ded79a0e2e87acf3e85ab659 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:26:59 +0000 Subject: [PATCH 030/174] tls: one SNI matcher: ASCII case folding, last registration wins, HTTP/3 on the same tree (#37195, #43849) Server names were compared in three places with three sets of rules: the per-listener tree (byte-wise, first registration wins), the HTTP/3 flat array (strcmp, first wins, '*.' covering any number of labels) and App.h's pending list. The entry a name selects carries the certificate and that entry's requestCert/ca policy, so ADMIN.EXAMPLE.COM skipped admin.example.com's policy, and a later duplicate entry's policy was dropped silently. All name semantics now live in sni_tree.cpp: labels compare ASCII case-insensitively and sni_add replaces, handing back the displaced entry. quic.c keeps its contexts in the same tree, so HTTP/3 matches exactly like TCP. With add replacing, nothing removes a name any more: sni_remove, its C, C++ and Rust wrappers and App.h's duplicate rollback go away. domain() picks the last registration, which is the one the tree points at. Drops the LSAN suppression for uws_add_server_name_with_options (stale since #29932), so LSAN now watches the duplicate-entry path. --- docs/runtime/http/tls.mdx | 2 + packages/bun-usockets/src/crypto/openssl.c | 30 +--- packages/bun-usockets/src/crypto/sni_tree.cpp | 81 +++------- packages/bun-usockets/src/internal/internal.h | 5 + packages/bun-usockets/src/libusockets.h | 7 +- packages/bun-usockets/src/quic.c | 52 ++----- packages/bun-usockets/src/quic.h | 2 +- packages/bun-uws/src/App.h | 40 +---- src/runtime/socket/Listener.rs | 25 +-- src/uws_sys/ListenSocket.rs | 14 +- test/js/bun/http/bun-serve-ssl.test.ts | 143 +++++++++++++++++- test/js/bun/http/serve-http3.test.ts | 119 ++++++++++++++- test/js/node/tls/node-tls-context.test.ts | 130 ++++++++++++++++ test/leaksan.supp | 1 - 14 files changed, 450 insertions(+), 201 deletions(-) diff --git a/docs/runtime/http/tls.mdx b/docs/runtime/http/tls.mdx index 0722fd1b9dfc..838f7a448b01 100644 --- a/docs/runtime/http/tls.mdx +++ b/docs/runtime/http/tls.mdx @@ -99,3 +99,5 @@ Bun.serve({ ], }); ``` + +If two entries share a `serverName`, the later entry replaces the earlier one. This matches `tls.Server#addContext()`. A trailing dot is ignored, so `my-server1.com.` and `my-server1.com` name the same host. diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 368d2c1c32f7..0ca2d8fc70e4 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -25,13 +25,6 @@ #include #include -/* These are in sni_tree.cpp */ -void *sni_new(); -void sni_free(void *sni, void (*cb)(void *)); -int sni_add(void *sni, const char *hostname, void *user); -void *sni_remove(void *sni, const char *hostname); -void *sni_find(void *sni, const char *hostname); - #ifdef LIBUS_USE_OPENSSL #include #include @@ -3273,11 +3266,11 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { return SSL_TLSEXT_ERR_OK; } -int us_listen_socket_add_server_name(struct us_listen_socket_t *ls, - const char *hostname_pattern, - SSL_CTX *ctx, void *user) { +void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, + const char *hostname_pattern, + SSL_CTX *ctx, void *user) { SSL_CTX *default_ctx = ls->ssl_ctx; - if (!default_ctx) return -1; + if (!default_ctx) return; if (!ls->sni) { ls->sni = sni_new(); @@ -3295,20 +3288,7 @@ int us_listen_socket_add_server_name(struct us_listen_socket_t *ls, us_ex_idx_ensure(); SSL_CTX_set_ex_data(ctx, us_sni_ex_idx, user); - if (sni_add(ls->sni, hostname_pattern, node)) { - /* Duplicate hostname — propagate so App.h's `if (result != 0)` rollback - * (which frees the per-domain HttpRouter it just built) actually fires. */ - sni_node_destructor(node); - return 1; - } - return 0; -} - -void us_listen_socket_remove_server_name(struct us_listen_socket_t *ls, - const char *hostname_pattern) { - if (!ls->sni) return; - struct sni_node_t *node = (struct sni_node_t *)sni_remove(ls->sni, hostname_pattern); - sni_node_destructor(node); + sni_node_destructor(sni_add(ls->sni, hostname_pattern, node)); } void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, diff --git a/packages/bun-usockets/src/crypto/sni_tree.cpp b/packages/bun-usockets/src/crypto/sni_tree.cpp index 649a047c5918..60d50a7917c2 100644 --- a/packages/bun-usockets/src/crypto/sni_tree.cpp +++ b/packages/bun-usockets/src/crypto/sni_tree.cpp @@ -36,10 +36,23 @@ /* This cannot be shared */ thread_local void (*sni_free_cb)(void *); +/* RFC 6066 section 3: host names compare ASCII case-insensitively */ +struct sni_label_less { + static unsigned char fold(unsigned char c) { + return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; + } + + bool operator()(std::string_view a, std::string_view b) const { + return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y) { + return fold(x) < fold(y); + }); + } +}; + struct sni_node { /* Empty nodes must always hold null */ void *user = nullptr; - std::map> children; + std::map, sni_label_less> children; ~sni_node() { /* A name from JS can have any number of labels, so tear the subtree down @@ -59,8 +72,7 @@ struct sni_node { /* The data of our string_views are managed by us_malloc */ us_free((void *) p.first.data()); - /* Call destructor passed to sni_free only if we hold data. - * This is important since sni_remove does not have sni_free_cb set */ + /* Call destructor passed to sni_free only if we hold data */ if (p.second.get()->user) { sni_free_cb(p.second.get()->user); } @@ -72,56 +84,13 @@ struct sni_node { }; /* Splits the first label off a hostname. `rest` is left holding whatever follows the dot. - * All of sni_add, sni_remove and sni_find must split names the same way. */ + * sni_add and sni_find must split names the same way. */ static std::string_view nextLabel(std::string_view &rest) { std::string_view label = rest.substr(0, rest.find('.', 0)); rest.remove_prefix(std::min(rest.length(), label.length() + 1)); return label; } -// this can only delete ONE single node, but may cull "empty nodes with null as data" -void *removeUser(struct sni_node *root, std::string_view rest) { - - /* The hostname comes from JS and can have any number of labels, so walk down - * with an explicit path instead of one stack frame per label */ - std::vectorchildren)::iterator>> path; - - while (!rest.empty()) { - /* Is this label a child of root? */ - auto it = root->children.find(nextLabel(rest)); - if (it == root->children.end()) { - /* We cannot continue */ - return nullptr; - } - - path.emplace_back(root, it); - root = it->second.get(); - } - - /* We are in the bottom, take the user and mark us for culling on the way up */ - void *removedUser = root->user; - root->user = nullptr; - - /* On the way back up, we may cull empty nodes with no children. - * This ends up being where we remove all nodes */ - for (auto p = path.rbegin(); p != path.rend(); ++p) { - struct sni_node *parent = p->first; - auto it = p->second; - - if (!it->second.get()->children.empty() || it->second.get()->user != nullptr) { - break; - } - - /* The data of our string_views are managed by us_malloc */ - us_free((void *) it->first.data()); - - /* This can only happen with user set to null, otherwise we use sni_free_cb which is unset by sni_remove */ - parent->children.erase(it); - } - - return removedUser; -} - void *getUser(struct sni_node *root, std::string_view rest) { /* Do we have labels to match? Otherwise, return where we stand */ @@ -169,8 +138,8 @@ extern "C" { delete (sni_node *) sni; } - /* Returns non-null if this name already exists */ - int sni_add(void *sni, const char *hostname, void *user) { + /* The last registration of a name wins. Returns the user it displaced, which the caller now owns */ + void *sni_add(void *sni, const char *hostname, void *user) { struct sni_node *root = (struct sni_node *) sni; /* Traverse all labels in hostname */ @@ -190,19 +159,7 @@ extern "C" { root = it->second.get(); } - /* We must never add multiple contexts for the same name, as that would overwrite and leak */ - if (root->user) { - return 1; - } - - root->user = user; - - return 0; - } - - /* Removes the exact match. Wildcards are treated as the verbatim asterisk char, not as an actual wildcard */ - void *sni_remove(void *sni, const char *hostname) { - return removeUser((struct sni_node *) sni, std::string_view(hostname, strlen(hostname))); + return std::exchange(root->user, user); } void *sni_find(void *sni, const char *hostname) { diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index d0364eb665a5..ca79927878a4 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -155,6 +155,11 @@ extern int us_dispatch_new_session(us_socket_r s, struct ssl_session_st *session extern int us_dispatch_server_identity(us_socket_r s, struct ssl_st *ssl); extern struct ssl_ctx_st *us_dispatch_socket_server_name(us_socket_r s, const char *hostname, int *abort_handshake); extern struct us_socket_t *us_dispatch_ssl_raw_tap(us_socket_r s, char *data, int length); +/* crypto/sni_tree.cpp: the one place server names are compared. */ +void *sni_new(); +void sni_free(void *sni, void (*cb)(void *)); +void *sni_add(void *sni, const char *hostname, void *user); +void *sni_find(void *sni, const char *hostname); #ifdef __cplusplus } #endif diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index a11ffb59fa34..088532deec98 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -411,12 +411,11 @@ struct us_listen_socket_t *us_socket_group_listen_fd(us_socket_group_r group, void us_listen_socket_close(struct us_listen_socket_t *ls) nonnull_fn_decl; /* SNI: tree hangs off the listen socket. ssl_ctx is up_ref'd; user is opaque - * (uWS stores a per-domain HttpRouter*). user may be NULL. */ -int us_listen_socket_add_server_name(struct us_listen_socket_t *ls, + * (uWS stores a per-domain HttpRouter*). user may be NULL. The last + * registration of a name replaces the earlier one. */ +void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern, struct ssl_ctx_st *ssl_ctx, void *user) __attribute__((nonnull(1, 2, 3))); -void us_listen_socket_remove_server_name(struct us_listen_socket_t *ls, - const char *hostname_pattern) nonnull_fn_decl; void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; /* Returns an owned reference; the caller must release it. */ diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index d74117b4bb75..d4aced6ef711 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -44,18 +44,13 @@ struct us_quic_hset { int is_server; }; -struct us_quic_sni { - char *name; - SSL_CTX *ctx; -}; - struct us_quic_socket_context_s { struct us_loop_t *loop; lsquic_engine_t *engine; struct lsquic_engine_settings settings; SSL_CTX *ssl_ctx; - struct us_quic_sni *sni; - unsigned int sni_count, sni_cap; + /* serverName -> SSL_CTX*, in the tree TCP listeners use (crypto/sni_tree.cpp). */ + void *sni; int processing; int closing; /* us_quic_socket_context_shutdown ran while `processing` was set; @@ -436,23 +431,6 @@ static void us_quic_udp_on_close(struct us_udp_socket_t *u) { /* ───── SSL ───── */ -/* Exact match, then `*.tail` wildcards (matches "a.tail" but not "tail"). */ -static SSL_CTX *us_quic_match_sni(us_quic_socket_context_t *ctx, const char *sni) { - if (!sni) return ctx->ssl_ctx; - size_t sl = strlen(sni); - for (unsigned i = 0; i < ctx->sni_count; i++) { - if (strcmp(ctx->sni[i].name, sni) == 0) return ctx->sni[i].ctx; - } - for (unsigned i = 0; i < ctx->sni_count; i++) { - const char *n = ctx->sni[i].name; - if (n[0] == '*' && n[1] == '.') { - size_t tl = strlen(n + 1); - if (sl > tl && memcmp(sni + sl - tl, n + 1, tl) == 0) return ctx->sni[i].ctx; - } - } - return ctx->ssl_ctx; -} - static SSL_CTX *us_quic_get_ssl_ctx(void *peer_ctx, const struct sockaddr *local) { (void) local; us_quic_listen_socket_t *ls = (us_quic_listen_socket_t *) peer_ctx; @@ -462,7 +440,8 @@ static SSL_CTX *us_quic_get_ssl_ctx(void *peer_ctx, const struct sockaddr *local static SSL_CTX *us_quic_lookup_cert(void *cert_ctx, const struct sockaddr *local, const char *sni) { (void) local; us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) cert_ctx; - return us_quic_match_sni(ctx, sni); + SSL_CTX *match = sni && ctx->sni ? (SSL_CTX *) sni_find(ctx->sni, sni) : NULL; + return match ? match : ctx->ssl_ctx; } static int us_quic_alpn_select(SSL *ssl, const unsigned char **out, unsigned char *outlen, @@ -875,6 +854,10 @@ us_quic_socket_context_t *us_create_quic_socket_context( return ctx; } +static void us_quic_sni_free(void *ssl_ctx) { + SSL_CTX_free((SSL_CTX *) ssl_ctx); +} + int us_quic_socket_context_add_server_name(us_quic_socket_context_t *ctx, const char *hostname, struct us_bun_socket_context_options_t options) { @@ -884,17 +867,8 @@ int us_quic_socket_context_add_server_name(us_quic_socket_context_t *ctx, us_quic_prepare_ssl_ctx(ssl, &options); SSL_CTX_set_verify(ssl, SSL_CTX_get_verify_mode(ssl) | SSL_CTX_get_verify_mode(ctx->ssl_ctx), SSL_CTX_get_verify_callback(ssl)); - if (ctx->sni_count == ctx->sni_cap) { - unsigned ncap = ctx->sni_cap ? ctx->sni_cap * 2 : 4; - struct us_quic_sni *n = (struct us_quic_sni *) us_realloc(ctx->sni, ncap * sizeof(*n)); - if (!n) { SSL_CTX_free(ssl); return -1; } - ctx->sni = n; ctx->sni_cap = ncap; - } - char *name = us_strdup(hostname); - if (!name) { SSL_CTX_free(ssl); return -1; } - ctx->sni[ctx->sni_count].name = name; - ctx->sni[ctx->sni_count].ctx = ssl; - ctx->sni_count++; + if (!ctx->sni) ctx->sni = sni_new(); + SSL_CTX_free((SSL_CTX *) sni_add(ctx->sni, hostname, ssl)); return 0; } @@ -934,11 +908,7 @@ void us_quic_socket_context_free(us_quic_socket_context_t *ctx) { while (ctx->listeners) us_udp_socket_close(ctx->listeners->udp); if (ctx->engine) { lsquic_engine_destroy(ctx->engine); ctx->engine = NULL; } if (ctx->ssl_ctx) { SSL_CTX_free(ctx->ssl_ctx); ctx->ssl_ctx = NULL; } - for (unsigned i = 0; i < ctx->sni_count; i++) { - us_free(ctx->sni[i].name); - SSL_CTX_free(ctx->sni[i].ctx); - } - us_free(ctx->sni); + if (ctx->sni) sni_free(ctx->sni, us_quic_sni_free); for (us_quic_listen_socket_t *ls = ctx->closed_listeners; ls; ) { us_quic_listen_socket_t *next = ls->next; us_free(ls); diff --git a/packages/bun-usockets/src/quic.h b/packages/bun-usockets/src/quic.h index d64c230b6941..bed47e6bafc3 100644 --- a/packages/bun-usockets/src/quic.h +++ b/packages/bun-usockets/src/quic.h @@ -55,7 +55,7 @@ us_quic_socket_context_t *us_create_quic_socket_context( void us_quic_socket_context_shutdown(us_quic_socket_context_t *ctx); void us_quic_socket_context_free(us_quic_socket_context_t *ctx); -/* Register an additional SSL_CTX for the given SNI hostname (exact or `*.`). */ +/* Register an additional SSL_CTX for the given SNI hostname. Names match as on a TCP listener. */ int us_quic_socket_context_add_server_name(us_quic_socket_context_t *ctx, const char *hostname, struct us_bun_socket_context_options_t options); diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 6396a840d0d3..1985b3deccf5 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -156,22 +156,9 @@ struct TemplatedApp { us_ssl_ctx_enable_http2_alpn(domainCtx, httpContext->getSocketContextData()->allowHttp1); } auto *domainRouter = new HttpRouter::RouterData>(); - int result = 0; forEachListenSocket([&](us_listen_socket_t *ls) { - result |= us_listen_socket_add_server_name(ls, hostname_pattern.c_str(), domainCtx, domainRouter); + us_listen_socket_add_server_name(ls, hostname_pattern.c_str(), domainCtx, domainRouter); }); - if (result != 0) { - /* At least one listener rejected the entry (duplicate hostname). - * Roll back any that succeeded so we don't leave the SNI tree - * pointing at a router we're about to delete. */ - forEachListenSocket([&](us_listen_socket_t *ls) { - us_listen_socket_remove_server_name(ls, hostname_pattern.c_str()); - }); - us_internal_ssl_ctx_unref(domainCtx); - delete domainRouter; - if (success) *success = false; - return std::move(*this); - } /* Queue for any listeners not yet created. We hold one SSL_CTX ref; * each listen socket took its own via SSL_CTX_up_ref. */ pendingServerNames.push_back({hostname_pattern, domainCtx, domainRouter}); @@ -181,26 +168,6 @@ struct TemplatedApp { return std::move(*this); } - TemplatedApp &&removeServerName(const std::string &hostname_pattern) { - if constexpr (SSL) { - /* The SNI tree on each listener stores a *borrowed* router pointer - * (and its own SSL_CTX_up_ref). pendingServerNames is the single - * owner — drop the borrowers first, then free the owner exactly - * once. The old loop deleted the router once per listener. */ - forEachListenSocket([&](us_listen_socket_t *ls) { - us_listen_socket_remove_server_name(ls, hostname_pattern.c_str()); - }); - for (auto it = pendingServerNames.begin(); it != pendingServerNames.end(); ) { - if (it->hostname == hostname_pattern) { - us_internal_ssl_ctx_unref(it->ctx); - delete it->router; - it = pendingServerNames.erase(it); - } else ++it; - } - } - return std::move(*this); - } - TemplatedApp &&missingServerName(MoveOnlyFunction &&handler) { if (!constructorFailed()) { httpContext->getSocketContextData()->missingServerNameHandler = std::move(handler); @@ -616,9 +583,10 @@ struct TemplatedApp { TemplatedApp &&domain(const std::string &serverName) { HttpContextData *httpContextData = httpContext->getSocketContextData(); + /* The last registration of a name is the one the SNI tree points at */ void *domainRouter = nullptr; - for (auto &p : pendingServerNames) { - if (p.hostname == serverName) { domainRouter = p.router; break; } + for (auto p = pendingServerNames.rbegin(); p != pendingServerNames.rend(); ++p) { + if (p->hostname == serverName) { domainRouter = p->router; break; } } if (domainRouter) { httpContextData->currentRouter = (decltype(httpContextData->currentRouter)) domainRouter; diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 056788e3b556..f529111766ab 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -744,7 +744,7 @@ impl Listener { // tolerated — the C SNI tree just truncates at the first one. Build the // `&CStr` via `from_ptr` to allow that instead of asserting via // `ZStr::as_cstr()`. `server_name_z` must outlive the - // remove_server_name/add_server_name calls below. + // add_server_name call below. let server_name_z = bun_core::ZBox::from_bytes(server_name_bytes); // SAFETY: `server_name_z` is NUL-terminated and lives to end of scope. let server_name = unsafe { core::ffi::CStr::from_ptr(server_name_z.as_ptr()) }; @@ -788,24 +788,11 @@ impl Listener { // The C SNI tree SSL_CTX_up_ref()s; ours drops here. // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. - let ls_ref = bun_opaque::opaque_deref_mut(ls); - ls_ref.remove_server_name(server_name); - let ok = ls_ref.add_server_name(server_name, sni_ctx.as_ptr(), core::ptr::null_mut()); - if !ok { - // Old entry was already removed; failing silently would leave the - // hostname with no SNI mapping at all. Surface it. - return Err(global.throw_value( - global - .err( - jsc::ErrorCode::BORINGSSL, - format_args!( - "Failed to register SNI for '{}'", - bstr::BStr::new(server_name_bytes) - ), - ) - .to_js(), - )); - } + bun_opaque::opaque_deref_mut(ls).add_server_name( + server_name, + sni_ctx.as_ptr(), + core::ptr::null_mut(), + ); Ok(JSValue::UNDEFINED) } diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index 705938917c3c..9a0c7c203c04 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -43,7 +43,7 @@ impl ListenSocket { } /// `ssl_ctx` is `SSL_CTX_up_ref`'d for the SNI node; the listener drops - /// that ref on close / `remove_server_name`. `user` is the per-domain handle + /// that ref on close, or when `hostname` is registered again. `user` is the per-domain handle /// `find_server_name_userdata` recovers (uWS uses an `HttpRouter*`; Bun.listen /// passes null). /// @@ -59,17 +59,12 @@ impl ListenSocket { hostname: &core::ffi::CStr, ssl_ctx: *mut SslCtx, user: *mut c_void, - ) -> bool { + ) { // SAFETY: self and hostname are valid for the duration of the call; // caller guarantees `ssl_ctx` is non-null and points at a live SSL_CTX // (C up-refs and stores it); `user` is an opaque caller-owned pointer // stored verbatim by C. - unsafe { us_listen_socket_add_server_name(self, hostname.as_ptr(), ssl_ctx, user) == 0 } - } - - pub fn remove_server_name(&mut self, hostname: &core::ffi::CStr) { - // SAFETY: self and hostname are valid for the duration of the call. - unsafe { us_listen_socket_remove_server_name(self, hostname.as_ptr()) } + unsafe { us_listen_socket_add_server_name(self, hostname.as_ptr(), ssl_ctx, user) } } /// Makes `ctx` the default `SSL_CTX` for sockets accepted from now on. @@ -98,8 +93,7 @@ unsafe extern "C" { hostname: *const c_char, ssl_ctx: *mut SslCtx, user: *mut c_void, - ) -> c_int; - fn us_listen_socket_remove_server_name(ls: *mut ListenSocket, hostname: *const c_char); + ); fn us_listen_socket_set_default_ssl_ctx(ls: *mut ListenSocket, ctx: *mut SslCtx); safe fn us_listen_socket_on_server_name( ls: &mut ListenSocket, diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 34bf709b0e59..13209b02f785 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -168,7 +168,7 @@ describe("Bun.serve per-serverName client certificate policy", () => { cert: readFileSync(join(tlsFixtures, "agent1-cert.pem"), "utf8"), }; - type ClientOptions = { key?: string; cert?: string; session?: Buffer }; + type ClientOptions = { key?: string; cert?: string; session?: Buffer; minVersion?: string; maxVersion?: string }; function request(port: number, servername: string, clientTls: ClientOptions = {}) { const { promise, resolve } = Promise.withResolvers<{ status: string; session: Buffer | undefined }>(); const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false, ...clientTls }); @@ -252,6 +252,50 @@ describe("Bun.serve per-serverName client certificate policy", () => { }); }); + // The client picks the SNI, so a spelling that misses the entry would skip its policy. + test.each(["TLSv1.2", "TLSv1.3"] as const)( + "a strict entry is enforced for every spelling of its name (%s)", + async version => { + const strict = { key: serverKey, cert: serverCert, ca: clientCa, requestCert: true, rejectUnauthorized: true }; + using server = Bun.serve({ + port: 0, + tls: [ + { key: serverKey, cert: serverCert }, + { serverName: "Admin.Example.com", ...strict }, + { serverName: "*.gated.example", ...strict }, + { serverName: "\u00e9.example", ...strict }, + ], + fetch: () => new Response("served"), + }); + const spellings = [ + "admin.example.com", + "ADMIN.EXAMPLE.COM", + "aDmIn.eXaMpLe.CoM", + "admin.example.com.", + "ADMIN.EXAMPLE.COM.", + "a.gated.example", + "A.GATED.EXAMPLE", + "a.Gated.Example.", + ]; + const pinned = { minVersion: version, maxVersion: version }; + const outcomes = await Promise.all( + spellings.map(async name => [ + name, + (await request(server.port, name, pinned)).status, + (await request(server.port, name, { ...pinned, ...untrustedClient })).status, + (await request(server.port, name, { ...pinned, ...trustedClient })).status, + ]), + ); + const closed = "connection closed without a response"; + expect(outcomes).toEqual(spellings.map(name => [name, closed, closed, "HTTP/1.1 200 OK"])); + // Only A-Z folds: U+00E9 and U+00C9 differ by 0x20 in their last UTF-8 byte and stay two names. + expect({ + registered: (await request(server.port, "\u00e9.example", pinned)).status, + other: (await request(server.port, "\u00c9.example", pinned)).status, + }).toEqual({ registered: closed, other: "HTTP/1.1 200 OK" }); + }, + ); + test("a session established on the open default name cannot be resumed to bypass a gated name", async () => { using server = Bun.serve({ port: 0, @@ -280,6 +324,103 @@ describe("Bun.serve per-serverName client certificate policy", () => { gatedResumed: "connection closed without a response", }); }); + + // agent3 (CN agent3) is a second certificate so a test can tell which + // entry served the handshake. + const otherKey = readFileSync(join(tlsFixtures, "agent3-key.pem"), "utf8"); + const otherCert = readFileSync(join(tlsFixtures, "agent3-cert.pem"), "utf8"); + const openEntry = { key: serverKey, cert: serverCert }; + const gatedEntry = { + key: otherKey, + cert: otherCert, + ca: clientCa, + requestCert: true, + rejectUnauthorized: true, + }; + + function peerCN(port: number, servername: string) { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false }); + socket.on("secureConnect", () => { + resolve(socket.getPeerCertificate()?.subject?.CN ?? "-"); + socket.destroy(); + }); + socket.on("error", reject); + socket.on("close", () => reject(new Error("closed before the handshake completed"))); + return promise; + } + + // Each case lists two entries for admin.example.com. The later one sets a + // client certificate policy. Like tls.Server#addContext(), the later entry + // wins: the name serves agent3 and rejects a handshake with no client cert. + const duplicateCases: [string, Parameters[0]["tls"]][] = [ + [ + "a later entry with the same serverName replaces the earlier one", + [ + { ...openEntry }, + { serverName: "admin.example.com", ...openEntry }, + { serverName: "admin.example.com", ...gatedEntry }, + ], + ], + [ + "a serverName with a trailing root dot names the same host", + [ + { ...openEntry }, + { serverName: "admin.example.com", ...openEntry }, + { serverName: "admin.example.com.", ...gatedEntry }, + ], + ], + [ + "a serverName in another case names the same host", + [ + { ...openEntry }, + { serverName: "admin.example.com", ...openEntry }, + { serverName: "ADMIN.Example.com", ...gatedEntry }, + ], + ], + [ + "a later entry replaces the default entry's own serverName", + [ + { ...openEntry, serverName: "admin.example.com" }, + { serverName: "admin.example.com", ...gatedEntry }, + ], + ], + ]; + for (const [label, tlsConfig] of duplicateCases) { + test(label, async () => { + using server = Bun.serve({ + port: 0, + tls: tlsConfig, + fetch: req => new Response(`served ${req.headers.get("host")}`), + }); + const servedCN = await peerCN(server.port, "admin.example.com"); + const { status: gatedNoCert } = await request(server.port, "admin.example.com"); + const { status: gatedTrustedCert } = await request(server.port, "admin.example.com", trustedClient); + const { status: defaultNoCert } = await request(server.port, "localhost"); + expect({ servedCN, gatedNoCert, gatedTrustedCert, defaultNoCert }).toEqual({ + servedCN: "agent3", + gatedNoCert: "connection closed without a response", + gatedTrustedCert: "HTTP/1.1 200 OK", + defaultNoCert: "HTTP/1.1 200 OK", + }); + }); + } + + test("the last of three entries for one serverName wins", async () => { + using server = Bun.serve({ + port: 0, + tls: [ + { ...openEntry }, + { serverName: "admin.example.com", ...gatedEntry }, + { serverName: "admin.example.com", ...openEntry }, + { serverName: "admin.example.com.", key: otherKey, cert: otherCert }, + ], + fetch: () => new Response("served"), + }); + const servedCN = await peerCN(server.port, "admin.example.com"); + const { status: noCert } = await request(server.port, "admin.example.com"); + expect({ servedCN, noCert }).toEqual({ servedCN: "agent3", noCert: "HTTP/1.1 200 OK" }); + }); }); test.each(["Bun.serve", "Bun.listen"] as const)("%s serves every identity of key/cert arrays", async api => { diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index c7738eda2739..2e0632db9267 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1,6 +1,6 @@ import type { ServerWebSocket } from "bun"; import { describe, expect, test } from "bun:test"; -import { createHash, createPrivateKey, randomBytes } from "crypto"; +import { createHash, createPrivateKey, randomBytes, X509Certificate } from "crypto"; import { readFileSync } from "fs"; import { bunEnv, bunExe, isASAN, tempDir, tls } from "harness"; import { connect, QuicEndpoint, type SessionOptions } from "node:quic"; @@ -2031,3 +2031,120 @@ describe("Bun.serve HTTP/3 request handlers run to completion before the callbac }); }); }); + +describe("Bun.serve HTTP/3 SNI", () => { + const tlsFixtures = join(import.meta.dir, "..", "..", "node", "tls", "fixtures"); + const pem = (name: string) => readFileSync(join(tlsFixtures, name), "utf8"); + // Each agent certificate has its own CN, which tells the entries apart. + const identity = (agent: string) => ({ key: pem(`${agent}-key.pem`), cert: pem(`${agent}-cert.pem`) }); + + // fetch() lowercases the URL host. node:quic sends `servername` as written + // and exposes the certificate the server picked. + async function servedCN(port: number, servername: string): Promise { + const session = await connect({ address: "127.0.0.1", port }, { alpn: "h3", servername, verifyPeer: "manual" }); + try { + await session.opened; + const cert = session.peerCertificate; + const x509 = cert instanceof X509Certificate ? cert : new X509Certificate(Buffer.from(cert)); + return x509.subject.match(/CN=([^\s,]+)/)![1]; + } finally { + await session.close(); + } + } + + test("a later entry with the same serverName replaces the earlier one", async () => { + using server = Bun.serve({ + port: 0, + tls: [ + identity("agent1"), + { serverName: "admin.example.com", ...identity("agent2") }, + { serverName: "admin.example.com.", ...identity("agent3") }, + ], + http3: true, + fetch: () => new Response("ok"), + }); + const served = { + admin: await servedCN(server.port, "admin.example.com"), + dottedAdmin: await servedCN(server.port, "admin.example.com."), + other: await servedCN(server.port, "other.example.com"), + }; + // The TCP listener selects the same entry for these names. + expect(served).toEqual({ admin: "agent3", dottedAdmin: "agent3", other: "agent1" }); + }); + + test("serverName entries match regardless of case and a wildcard covers one label", async () => { + using server = Bun.serve({ + port: 0, + tls: [ + identity("agent1"), + { serverName: "Agent2.Example", ...identity("agent2") }, + { serverName: "*.wild.example", ...identity("agent3") }, + ], + http3: true, + fetch: () => new Response("ok"), + }); + const served: Record = {}; + for (const servername of [ + "Agent2.Example", + "agent2.example", + "AGENT2.EXAMPLE", + "a.wild.example", + "A.WILD.EXAMPLE", + "a.b.wild.example", + "wild.example", + "other.example", + ]) { + served[servername] = await servedCN(server.port, servername); + } + // The same names select the same entries on the TCP listener. + expect(served).toEqual({ + "Agent2.Example": "agent2", + "agent2.example": "agent2", + "AGENT2.EXAMPLE": "agent2", + "a.wild.example": "agent3", + "A.WILD.EXAMPLE": "agent3", + "a.b.wild.example": "agent1", + "wild.example": "agent1", + "other.example": "agent1", + }); + }); + + // The client picks the SNI, so a spelling that misses the entry would skip its policy. + test("a strict entry is enforced for every spelling of its name", async () => { + const strict = { ...identity("agent3"), ca: pem("ca1-cert.pem"), requestCert: true, rejectUnauthorized: true }; + using server = Bun.serve({ + port: 0, + tls: [ + identity("agent2"), + { serverName: "Admin.Example.com", ...strict }, + { serverName: "*.gated.example", ...strict }, + ], + http3: true, + fetch: () => new Response("served"), + }); + const client = (agent: string) => ({ + keys: [createPrivateKey(pem(`${agent}-key.pem`))], + certs: [readFileSync(join(tlsFixtures, `${agent}-cert.pem`))], + }); + const spellings = [ + "admin.example.com", + "ADMIN.EXAMPLE.COM", + "aDmIn.eXaMpLe.CoM.", + "a.gated.example", + "A.GATED.EXAMPLE", + "a.Gated.Example.", + ]; + const exchange = (servername: string, options = {}) => + h3Exchange(server.port, requestHeaders("/"), { servername, ...options }); + const outcomes = await Promise.all( + spellings.map(async name => [ + name, + await exchange(name), + await exchange(name, client("agent3")), // issued by ca2 + await exchange(name, client("agent1")), // issued by ca1 + ]), + ); + expect(outcomes).toEqual(spellings.map(name => [name, "closed", "closed", "200 served"])); + expect(await exchange("other.example")).toBe("200 served"); + }); +}); diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 18bce0baa7fd..4db66b77a2c5 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -781,6 +781,136 @@ describe("Bun.serve SNI", () => { }); }); +// Node v26.3.0 compares addContext() names case-sensitively; v26.4.0 and later compile them into /.../i. +describe("SNI matching is case-insensitive", () => { + function servedCN(port: number, servername: string) { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false }, () => { + resolve(socket.getPeerCertificate()?.subject?.CN); + socket.end(); + }); + socket.on("error", reject); + return promise; + } + + it("tls.Server addContext", async () => { + const server = tls.createServer({ key: agent2Key, cert: agent2Cert }, s => s.end()); + server.addContext("a.example.com", SNIContexts["a.example.com"]); // agent1 + server.addContext("UPPER.EXAMPLE.COM", SNIContexts["asterisk.test.com"]); // agent3 + server.addContext("*.test.com", SNIContexts["asterisk.test.com"]); // agent3 + server.addContext("Twice.example.com", SNIContexts["a.example.com"]); // agent1 + server.addContext("twice.example.com", SNIContexts["asterisk.test.com"]); // agent3 + try { + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(0, listening.resolve); + await listening.promise; + const port = (server.address() as AddressInfo).port; + expect({ + exact: await servedCN(port, "a.example.com"), + upper: await servedCN(port, "A.EXAMPLE.COM"), + mixed: await servedCN(port, "A.Example.Com"), + registeredUpper: await servedCN(port, "upper.example.com"), + wildcardUpper: await servedCN(port, "B.TEST.COM"), + lastOfTwoSpellings: await servedCN(port, "Twice.example.com"), + noMatch: await servedCN(port, "other.example.org"), + }).toEqual({ + exact: "agent1", + upper: "agent1", + mixed: "agent1", + registeredUpper: "agent3", + wildcardUpper: "agent3", + lastOfTwoSpellings: "agent3", + noMatch: "agent2", + }); + } finally { + server.close(); + } + }); + + // The context an SNI selects also decides which CA client certificates must chain to. + describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s", version => { + const inAnyCase = ["strict.example.com", "STRICT.EXAMPLE.COM", "sTrIcT.eXaMpLe.CoM"]; + const selectsNothing = (_: string, cb: Function) => cb(null, null); + it.each([ + ["in any case", inAnyCase, undefined], + // Bun only: Node serves the default context for a name with the root dot. + ["with the root dot", ["strict.example.com.", "STRICT.EXAMPLE.COM."], undefined], + // Bun only: in Node a user SNICallback replaces the addContext() entries. + ["behind an SNICallback that selects nothing", inAnyCase, selectsNothing], + ])("a context's client CA applies to its name %s", async (_, spellings, SNICallback) => { + const server = tls.createServer({ + key: agent2Key, + cert: agent2Cert, + ca: [ca1], + requestCert: true, + rejectUnauthorized: true, + SNICallback, + } as any); + server.addContext("Strict.Example.com", { key: agent1Key, cert: agent1Cert, ca: [loadPEM("ca5-cert.pem")] }); + // agent1 chains to ca1, which only the default context trusts; ec10 chains to ca5. + const ec10 = [loadPEM("ec10-key.pem"), loadPEM("ec10-cert.pem")] as const; + async function verdict(servername: string, key: string, cert: string) { + const judged = Promise.race([ + once(server, "secureConnection").then(() => "accepted"), + once(server, "tlsClientError").then(() => "refused"), + ]); + const socket = tls.connect({ + host: "127.0.0.1", + port: (server.address() as AddressInfo).port, + servername, + key, + cert, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + socket.on("error", () => {}); + try { + return await judged; + } finally { + socket.destroy(); + } + } + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const verdicts: string[][] = []; + for (const name of spellings) { + verdicts.push([name, await verdict(name, agent1Key, agent1Cert), await verdict(name, ...ec10)]); + } + expect(verdicts).toEqual(spellings.map(name => [name, "refused", "accepted"])); + expect(await verdict("other.example.com", agent1Key, agent1Cert)).toBe("accepted"); + } finally { + server.close(); + } + }); + }); + + it("Bun.serve tls array", async () => { + using server = Bun.serve({ + port: 0, + tls: [ + { key: agent2Key, cert: agent2Cert }, + { serverName: "a.example.com", ...SNIContexts["a.example.com"] }, + { serverName: "*.test.com", ...SNIContexts["asterisk.test.com"] }, + ], + fetch: () => new Response("OK"), + }); + expect({ + upper: await servedCN(server.port, "A.EXAMPLE.COM"), + mixed: await servedCN(server.port, "a.Example.com"), + wildcardUpper: await servedCN(server.port, "B.TEST.COM"), + noMatch: await servedCN(server.port, "other.example.org"), + }).toEqual({ + upper: "agent1", + mixed: "agent1", + wildcardUpper: "agent3", + noMatch: "agent2", + }); + }); +}); + describe("server certificate chain built from `ca`", () => { it("presents an intermediate known only to the default store (NODE_EXTRA_CA_CERTS)", async () => { // With no `ca`, Node seeds the context's store with the default roots diff --git a/test/leaksan.supp b/test/leaksan.supp index 42535f742fb2..4417bbf25d11 100644 --- a/test/leaksan.supp +++ b/test/leaksan.supp @@ -61,7 +61,6 @@ leak:JSC::moduleLoaderModuleDeclarationInstantiation leak:JSC::arrayProtoFuncSort leak:bindgen_Fmt_jsFmtString leak:runtime.dns_jsc.dns.GetAddrInfoRequest.run -leak:uws_add_server_name_with_options leak:runtime.webcore.Body.Value.fromJS leak:sys_jsc.error_jsc.errorToSystemError leak:runtime.webcore.Blob.getNameString From 6c4d2f70db4dfbde7fd3d2ea2279025d5a60780a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:41:29 +0000 Subject: [PATCH 031/174] tls: select the certificate by server name for connections accepted before close() / stop() (#42355) The listen socket owned the SNI tree and the resolver, and closing it walked every accepted and parked socket to clear their back-pointer. A connection whose ClientHello was processed after close() or a graceful stop() therefore got the default certificate, skipped its entry's requestCert/ca policy, and never reached a user SNICallback. Regression since 1.3.14. The tree and the resolver move into a reference-counted us_server_names_t: one reference for the listen socket, one per accepted SSL (in us_ssl_rare_t) until SSL_free. The close-time walks and us_internal_ssl_socket_left_group go away. The resolver now finds the Listener through the accepted socket's Handlers, so the two Rust dispatchers share one body. --- packages/bun-usockets/src/context.c | 6 +- packages/bun-usockets/src/crypto/openssl.c | 141 ++++++++++-------- packages/bun-usockets/src/internal/internal.h | 12 +- packages/bun-usockets/src/libusockets.h | 8 +- packages/bun-uws/src/App.h | 1 + src/runtime/socket/Listener.rs | 130 ++++++---------- src/uws_sys/ListenSocket.rs | 10 +- test/js/bun/http/bun-serve-ssl.test.ts | 61 ++++++++ test/js/node/tls/node-tls-context.test.ts | 85 +++++++++++ test/js/node/tls/node-tls-server.test.ts | 101 +++++++++++++ 10 files changed, 377 insertions(+), 178 deletions(-) diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 2cd6206381f7..c70d6b20b245 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -308,9 +308,6 @@ struct us_socket_t *us_socket_adopt(struct us_socket_t *s, struct us_socket_grou us_internal_socket_group_link_connecting_socket(group, c); } } - if (old_group != group && new_s->ssl) { - us_internal_ssl_socket_left_group(new_s); - } new_s->group = group; new_s->kind = kind; new_s->timeout = 255; @@ -359,8 +356,7 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, ls->accept_kind = kind; ls->ssl_ctx = ssl_ctx; if (ssl_ctx) us_internal_ssl_ctx_up_ref(ssl_ctx); - ls->sni = NULL; - ls->on_server_name = NULL; + ls->server_names = NULL; ls->socket_ext_size = socket_ext_size; ls->deferred_accept = 0; ls->accept_paused = (options & LIBUS_SOCKET_OPEN_PAUSED) && !ssl_ctx; diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 0ca2d8fc70e4..be3ad407396e 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -198,7 +198,8 @@ struct us_ssl_pending_event_t { /* Allocated for an accepted socket, a node:tls socket's events, a client's first renegotiation, and fetch's session sink. */ struct us_ssl_rare_t { - struct us_listen_socket_t *listener; + /* One reference to the accepting listen socket's names. */ + struct us_server_names_t *server_names; /* Sessions and keylog lines parked inside SSL_read/SSL_do_handshake, in arrival order. */ struct us_ssl_pending_event_t *pending_head, *pending_tail; void *session_sink; @@ -212,6 +213,8 @@ static void us_ctx_ex_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, (void)parent; (void)ptr; (void)ad; (void)index; (void)argl; (void)argp; atomic_fetch_sub(&ssl_ctx_live, 1); } +static struct us_server_names_t *us_server_names_ref(struct us_listen_socket_t *ls); +static void us_server_names_unref(struct us_server_names_t *names); static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, int index, long argl, void *argp) { (void)parent; (void)ad; (void)index; (void)argl; (void)argp; @@ -223,6 +226,7 @@ static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, rare->pending_head = next; } if (rare->session_sink_free) rare->session_sink_free(rare->session_sink); + if (rare->server_names) us_server_names_unref(rare->server_names); us_free(rare); } @@ -703,12 +707,6 @@ void us_internal_ssl_socket_relocated(struct us_loop_t *loop, struct us_socket_t } } -/* The listener cleanup walks its accept group only, so a socket that leaves the group drops the pointer. */ -void us_internal_ssl_socket_left_group(struct us_socket_t *s) { - struct us_ssl_rare_t *rare = s->ssl ? us_ssl_rare(s_ssl(s)) : NULL; - if (rare) rare->listener = NULL; -} - static int BIO_s_custom_read(BIO *bio, char *dst, int length) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)BIO_get_data(bio); @@ -1741,8 +1739,8 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, } else { SSL_set_accept_state(ssl); SSL_set_renegotiate_mode(ssl, ssl_renegotiate_never); - /* sni_cb recovers ls per-SSL — never via the shared SSL_CTX. */ - if (listener) us_ssl_rare_ensure(ssl)->listener = listener; + /* sni_cb recovers the names per-SSL — never via the shared SSL_CTX. */ + if (listener) us_ssl_rare_ensure(ssl)->server_names = us_server_names_ref(listener); } s->ssl = ssl; @@ -3031,9 +3029,44 @@ static void sni_node_destructor(void *user) { us_free(node); } -static struct sni_node_t *resolve_listener_ctx(struct us_listen_socket_t *ls, const char *hostname) { - if (!ls->sni) return NULL; - return (struct sni_node_t *)sni_find(ls->sni, hostname); +/* A ClientHello can arrive after its listen socket closed: one reference for the listen socket, one per accepted SSL. */ +struct us_server_names_t { + unsigned int refs; + /* NULL once the listen socket closed. */ + struct us_listen_socket_t *ls; + /* hostname -> sni_node_t. NULL until the first name is added. */ + void *tree; + /* Returns the SSL_CTX to serve for `hostname` on the in-flight handshake only + * (the caller does not cache it), or NULL to fall through to the tree. */ + struct ssl_ctx_st *(*on_server_name)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket); +}; + +static struct us_server_names_t *us_listen_socket_server_names(struct us_listen_socket_t *ls) { + if (!ls->server_names) { + struct us_server_names_t *names = us_calloc(1, sizeof(*names)); + if (!names) Bun__outOfMemory(); + names->refs = 1; + names->ls = ls; + ls->server_names = names; + } + return ls->server_names; +} + +static struct us_server_names_t *us_server_names_ref(struct us_listen_socket_t *ls) { + struct us_server_names_t *names = us_listen_socket_server_names(ls); + names->refs++; + return names; +} + +static void us_server_names_unref(struct us_server_names_t *names) { + if (--names->refs) return; + if (names->tree) sni_free(names->tree, sni_node_destructor); + us_free(names); +} + +static struct sni_node_t *us_server_names_find(struct us_server_names_t *names, const char *hostname) { + if (!names || !names->tree) return NULL; + return (struct sni_node_t *)sni_find(names->tree, hostname); } #define US_SNI_POLICY_REQUEST_CERT ((uintptr_t)1) @@ -3140,7 +3173,7 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h struct us_socket_t *s = ssl ? us_ssl_socket(ssl) : NULL; if (!s) return ssl_select_cert_success; struct us_ssl_rare_t *rare = us_ssl_rare(ssl); - struct us_listen_socket_t *ls = rare ? rare->listener : NULL; + struct us_server_names_t *names = rare ? rare->server_names : NULL; /* A previous suspension being resumed: consume the stored result. */ if (s->ssl_sni_pending == US_SNI_RESOLVED) { @@ -3157,14 +3190,14 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h * through to the static SNI tree below, exactly like a synchronous * resolver returning null - the resume must not skip the tree fallback * the sync path gets. */ - if (ls) { + if (names) { /* Read the servername from the raw ClientHello, same as the first-call * path below: that is the read the early-callback contract guarantees * (SSL_get_servername happens to be populated by the resume re-drive * today, but the raw parse does not depend on that). */ char resumed_host[256]; if (us_client_hello_servername(hello, resumed_host, sizeof(resumed_host))) { - struct sni_node_t *resumed_node = resolve_listener_ctx(ls, resumed_host); + struct sni_node_t *resumed_node = us_server_names_find(names, resumed_host); if (resumed_node) { us_ssl_apply_selected_ctx(ssl, resumed_node->ctx); } @@ -3183,7 +3216,7 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h /* With no listener resolver, the socket may still carry its own: a * server-side socket adopted into TLS with its own SNICallback. */ - const int no_listener_resolver = (!ls || !ls->on_server_name); + const int no_listener_resolver = (!names || !names->on_server_name); if (no_listener_resolver && !s->ssl_sni_resolver) { return ssl_select_cert_success; } @@ -3206,7 +3239,7 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h int abort_handshake = 0; SSL_CTX *dyn = no_listener_resolver ? us_dispatch_socket_server_name(s, hostname, &abort_handshake) - : ls->on_server_name(ls, hostname, &abort_handshake, s); + : names->on_server_name(names->ls, hostname, &abort_handshake, s); us_internal_ssl_loop_state_restore(saved_loop_state); if (abort_handshake == 1) { @@ -3229,11 +3262,9 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h /* No dynamic selection: fall back to the static SNI tree (the * addContext() entries). An adopted socket has no tree. */ - if (ls) { - struct sni_node_t *node = resolve_listener_ctx(ls, hostname); - if (node) { - us_ssl_apply_selected_ctx(ssl, node->ctx); - } + struct sni_node_t *node = us_server_names_find(names, hostname); + if (node) { + us_ssl_apply_selected_ctx(ssl, node->ctx); } return ssl_select_cert_success; } @@ -3241,12 +3272,12 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h static int sni_cb(SSL *ssl, int *al, void *arg) { (void)al; (void)arg; if (!ssl) return SSL_TLSEXT_ERR_NOACK; - /* The listener is per-SSL (set at accept), not the CTX-level arg — the + /* The names are per-SSL (set at accept), not the CTX-level arg — the * SSL_CTX is shared and may outlive any one listener. */ struct us_ssl_rare_t *rare = us_ssl_rare(ssl); - struct us_listen_socket_t *ls = rare ? rare->listener : NULL; - if (!ls) return SSL_TLSEXT_ERR_OK; - if (ls->on_server_name) { + struct us_server_names_t *names = rare ? rare->server_names : NULL; + if (!names) return SSL_TLSEXT_ERR_OK; + if (names->on_server_name) { /* A dynamic resolver (user SNICallback) exists: us_select_cert_cb already * ran it - and the static-tree fallback - at the earlier * select-certificate stage. Consulting the tree again here would @@ -3258,7 +3289,7 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { if (hostname && hostname[0]) { /* Static SNI tree only (no dynamic resolver registered for this * listener). */ - struct sni_node_t *node = resolve_listener_ctx(ls, hostname); + struct sni_node_t *node = us_server_names_find(names, hostname); if (node) { us_ssl_apply_selected_ctx(ssl, node->ctx); } @@ -3272,10 +3303,11 @@ void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, SSL_CTX *default_ctx = ls->ssl_ctx; if (!default_ctx) return; - if (!ls->sni) { - ls->sni = sni_new(); + struct us_server_names_t *names = us_listen_socket_server_names(ls); + if (!names->tree) { + names->tree = sni_new(); /* Idempotent across listeners sharing this SSL_CTX — the callback reads - * the listener off the SSL, not the arg. */ + * the names off the SSL, not the arg. */ SSL_CTX_set_tlsext_servername_callback(default_ctx, sni_cb); } @@ -3288,13 +3320,12 @@ void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, us_ex_idx_ensure(); SSL_CTX_set_ex_data(ctx, us_sni_ex_idx, user); - sni_node_destructor(sni_add(ls->sni, hostname_pattern, node)); + sni_node_destructor(sni_add(names->tree, hostname_pattern, node)); } void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, const char *hostname_pattern) { - if (!ls->sni) return NULL; - struct sni_node_t *node = (struct sni_node_t *)sni_find(ls->sni, hostname_pattern); + struct sni_node_t *node = us_server_names_find(ls->server_names, hostname_pattern); return node ? node->user : NULL; } @@ -3305,8 +3336,7 @@ void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, * tree's reference must not be handed out as a borrow. */ struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, const char *hostname_pattern) { - if (!ls->sni) return NULL; - struct sni_node_t *node = (struct sni_node_t *)sni_find(ls->sni, hostname_pattern); + struct sni_node_t *node = us_server_names_find(ls->server_names, hostname_pattern); if (!node || !node->ctx) return NULL; SSL_CTX_up_ref(node->ctx); return node->ctx; @@ -3317,10 +3347,11 @@ void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls, if (ls->ssl_ctx == ctx) return; SSL_CTX_up_ref(ctx); /* Carry over the listener-level callbacks registered on the old default. */ - if (ls->sni) { + struct us_server_names_t *names = ls->server_names; + if (names && names->tree) { SSL_CTX_set_tlsext_servername_callback(ctx, sni_cb); } - if (ls->on_server_name) { + if (names && names->on_server_name) { SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); } if (ls->ssl_ctx) { @@ -3331,15 +3362,14 @@ void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls, void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *, int *, struct us_socket_t *)) { - ls->on_server_name = cb; + if (!ls->ssl_ctx) return; + us_listen_socket_server_names(ls)->on_server_name = cb; /* The dynamic resolver may need to suspend the handshake (async * SNICallback); only the early select-certificate callback supports retry, * so register it on the listener's default context. The servername-stage * sni_cb stays registered for the static SNI tree (it is a no-op when the * early callback already installed a context). */ - if (ls->ssl_ctx) { - SSL_CTX_set_select_certificate_cb(ls->ssl_ctx, us_select_cert_cb); - } + SSL_CTX_set_select_certificate_cb(ls->ssl_ctx, us_select_cert_cb); } /* Register a socket-level SNI resolver on an already-attached server-side SSL. @@ -3367,35 +3397,14 @@ const char *us_internal_ssl_sni_servername(struct us_socket_t *s) { } void us_internal_listen_socket_ssl_free(struct us_listen_socket_t *ls) { - /* Accepted sockets carry `ls` in per-SSL ex_data so sni_cb can reach the - * listener's SNI tree. Those sockets may outlive the listener (server.close() - * keeps existing connections per Node semantics), so wipe the back-ref now — - * sni_cb returns OK on NULL. Walk only sockets accepted INTO this listener's - * group; uWS apps with multiple listeners on one group are scoped by the - * `== ls` check. */ - if (ls->accept_group) { - for (struct us_socket_t *s = ls->accept_group->head_sockets; s; s = s->next) { - struct us_ssl_rare_t *rare = s->ssl ? us_ssl_rare(s_ssl(s)) : NULL; - if (rare && rare->listener == ls) rare->listener = NULL; - } - /* Mid-handshake sockets (SSL_in_init → low_prio) are *unlinked* from - * head_sockets while parked in loop->data.low_prio_head, and they're - * exactly the population that will run sni_cb on the next tick. Miss them - * here and sni_cb dereferences `ls` after it's freed. Same group-filter as - * close_all's drain. */ - for (struct us_socket_t *s = ls->accept_group->loop->data.low_prio_head; s; s = s->next) { - struct us_ssl_rare_t *rare = - s->group == ls->accept_group && s->ssl ? us_ssl_rare(s_ssl(s)) : NULL; - if (rare && rare->listener == ls) rare->listener = NULL; - } - } if (ls->ssl_ctx) { us_internal_ssl_ctx_unref(ls->ssl_ctx); ls->ssl_ctx = NULL; } - if (ls->sni) { - sni_free(ls->sni, sni_node_destructor); - ls->sni = NULL; + if (ls->server_names) { + ls->server_names->ls = NULL; + us_server_names_unref(ls->server_names); + ls->server_names = NULL; } } diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index ca79927878a4..6e7a334a934d 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -251,7 +251,6 @@ void us_internal_ssl_attach(us_socket_r s, struct ssl_ctx_st *ssl_ctx, int is_cl /* SSL_free(s->ssl); s->ssl = NULL. Idempotent. */ void us_internal_ssl_detach(us_socket_r s); void us_internal_ssl_socket_relocated(us_loop_r loop, us_socket_r old_s, us_socket_r new_s); -void us_internal_ssl_socket_left_group(us_socket_r s); /* TLS-layer event hooks. loop.c calls these instead of us_dispatch_* when * s->ssl != NULL; they decrypt/encrypt and re-dispatch the plaintext. */ @@ -272,7 +271,7 @@ unsigned int us_internal_ssl_spill_pending(us_socket_r s); void *us_internal_ssl_get_native_handle(us_socket_r s); struct us_bun_verify_error_t us_internal_ssl_verify_error(us_socket_r s); const char *us_internal_ssl_sni_servername(us_socket_r s); -/* SSL_CTX_free(ls->ssl_ctx) + sni_free(ls->sni). Called from us_listen_socket_close. */ +/* Drops the listen socket's references to ls->ssl_ctx and ls->server_names. Called from us_listen_socket_close. */ void us_internal_listen_socket_ssl_free(struct us_listen_socket_t *ls); /* Opaque SSL_CTX_up_ref/SSL_CTX_free so context.c needn't include OpenSSL. */ void us_internal_ssl_ctx_up_ref(struct ssl_ctx_st *ssl_ctx); @@ -503,12 +502,9 @@ struct us_listen_socket_t { /* SSL_CTX for accepted sockets. Borrowed; up_ref'd on listen, freed on * close. NULL → plain TCP. */ struct ssl_ctx_st *ssl_ctx; - /* SNI hostname → {SSL_CTX*, user*} tree. Owned. */ - void *sni; - /* Dynamic SNI resolver: returns the SSL_CTX to serve for `hostname` on the - * in-flight handshake only (the caller does not cache it), or NULL to fall - * through to the default context. */ - struct ssl_ctx_st *(*on_server_name)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket); + /* SNI tree + dynamic resolver, shared with every SSL this listener accepted + * (crypto/openssl.c). NULL until the first name, resolver or TLS accept. */ + struct us_server_names_t *server_names; unsigned int socket_ext_size; /* kind to stamp on accepted sockets. */ unsigned char accept_kind; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 088532deec98..01fc1c5fe653 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -410,9 +410,10 @@ struct us_listen_socket_t *us_socket_group_listen_fd(us_socket_group_r group, __attribute__((nonnull(1, 8))); /* ssl_ctx nullable */ void us_listen_socket_close(struct us_listen_socket_t *ls) nonnull_fn_decl; -/* SNI: tree hangs off the listen socket. ssl_ctx is up_ref'd; user is opaque - * (uWS stores a per-domain HttpRouter*). user may be NULL. The last - * registration of a name replaces the earlier one. */ +/* SNI: the names outlive us_listen_socket_close() for the connections already + * accepted. ssl_ctx is up_ref'd; user is opaque (uWS stores a per-domain + * HttpRouter*). user may be NULL. The last registration of a name replaces the + * earlier one. */ void us_listen_socket_add_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern, struct ssl_ctx_st *ssl_ctx, void *user) __attribute__((nonnull(1, 2, 3))); @@ -431,6 +432,7 @@ void us_listen_socket_set_default_ssl_ctx(struct us_listen_socket_t *ls, int us_ssl_parse_pkcs12(const char *data, size_t len, const char *pass, char **out_key, size_t *out_key_len, char **out_cert, size_t *out_cert_len, char **out_ca, size_t *out_ca_len, const char **err_reason); +/* After us_listen_socket_close(ls) the resolver still runs for accepted connections, with NULL in place of `ls`. */ void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *hostname, int *abort_handshake, struct us_socket_t *socket)) nonnull_fn_decl; /* Resume a handshake suspended by an async SNICallback (the dynamic resolver diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 1985b3deccf5..e7da9db6c7ce 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -292,6 +292,7 @@ struct TemplatedApp { * default serve the request - it never aborts or suspends the handshake. */ (void) abort_handshake; (void) socket; + if (!ls) return nullptr; auto *httpContext = (HttpContext *) us_socket_group_ext(us_listen_socket_group(ls)); httpContext->getSocketContextData()->missingServerNameHandler(hostname); /* The handler is expected to have registered the name via diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index f529111766ab..6ffe022ae40c 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -2014,9 +2014,8 @@ impl WindowsNamedPipeListeningContext { } } -/// `us_dispatch_server_name` for a socket adopted into TLS: no listen socket, -/// so the resolver lives on the SSL and the handler's `this` is the socket's -/// own `data`. Only the fd-adopt path registers this, not the duplex wrap. +/// `us_dispatch_server_name` for a socket adopted into TLS, which has no listen +/// socket. Only the fd-adopt path registers this, not the duplex wrap. /// /// # Safety /// `socket` is the live us_socket_t processing this ClientHello and `hostname` @@ -2027,43 +2026,7 @@ pub(crate) extern "C" fn us_dispatch_socket_server_name( hostname: *const core::ffi::c_char, abort_handshake: *mut core::ffi::c_int, ) -> *mut uws_sys::SslCtx { - jsc::mark_binding!(); - if socket.is_null() || hostname.is_null() { - return core::ptr::null_mut(); - } - let s_ref = uws_sys::us_socket_t::opaque_mut(socket); - if s_ref.kind() != uws_sys::SocketKind::BunSocketTls { - return core::ptr::null_mut(); - } - let tls = match *s_ref.ext::>>() { - Some(tls) => tls, - None => return core::ptr::null_mut(), - }; - // An idle socket can drop its Handlers while the us_socket_t lives on; - // `get_handlers()` would panic. Same guard as `select_alpn_callback`. - if !tls.has_handlers() { - return core::ptr::null_mut(); - } - let handlers = tls.get_handlers(); - let callback = handlers.on_server_name(); - if callback.is_empty() { - return core::ptr::null_mut(); - } - let global = handlers.global_object; - let socket_handle = tls.get_this_value(&global); - // node:tls stores the JS TLSSocket (which carries `_SNICallback`) in the - // native socket's `data`; that is the handler's `this`, mirroring how the - // listener path passes the net.Server. - let this_value = TLSSocket::data_get_cached(socket_handle).unwrap_or(JSValue::UNDEFINED); - // SAFETY: `hostname` is NUL-terminated per the fn contract. - let name = unsafe { core::ffi::CStr::from_ptr(hostname) }; - // Peer-supplied SNI bytes, decoded as Latin-1 like Node's `OneByteString`. - let js_name = EncodedSlice::latin1(name.to_bytes()).to_js(&global); - let result = match callback.call(&global, this_value, &[this_value, js_name, socket_handle]) { - Ok(v) => v, - Err(err) => global.take_exception(err), - }; - decode_sni_result(result, abort_handshake).cast() + dispatch_server_name(socket, hostname, abort_handshake).cast() } /// Shared decoding of what the JS SNI handler returned. See @@ -2113,72 +2076,63 @@ fn decode_sni_result(result: JSValue, abort_handshake: *mut core::ffi::c_int) -> /// `handle.resumeSNI(...)` -> `us_socket_sni_resolve()`. /// /// # Safety -/// `ls` is a live listen socket whose accept-group ext holds a `*mut Listener` -/// and `hostname` is a NUL-terminated string valid for the call. JS-thread -/// only. +/// `socket` is the live us_socket_t processing this ClientHello and `hostname` +/// is NUL-terminated for the call. JS-thread only. extern "C" fn us_dispatch_server_name( - ls: *mut uws_sys::ListenSocket, + _ls: *mut uws_sys::ListenSocket, hostname: *const core::ffi::c_char, abort_handshake: *mut core::ffi::c_int, socket: *mut c_void, +) -> *mut c_void { + dispatch_server_name(socket.cast(), hostname, abort_handshake) +} + +fn dispatch_server_name( + socket: *mut uws_sys::us_socket_t, + hostname: *const core::ffi::c_char, + abort_handshake: *mut core::ffi::c_int, ) -> *mut c_void { jsc::mark_binding!(); - if ls.is_null() || hostname.is_null() { + if socket.is_null() || hostname.is_null() { return core::ptr::null_mut(); } - // The accept group's ext holds the owning `*mut Listener` for the lifetime - // of the listen socket. S008: `ListenSocket` is an `opaque_ffi!` ZST. - let listener_ptr: *mut Listener = bun_opaque::opaque_deref_mut(ls).group().owner::(); - if listener_ptr.is_null() { + let s_ref = uws_sys::us_socket_t::opaque_mut(socket); + if s_ref.kind() != uws_sys::SocketKind::BunSocketTls { + return core::ptr::null_mut(); + } + let tls = match *s_ref.ext::>>() { + Some(tls) => tls, + None => return core::ptr::null_mut(), + }; + // An idle socket can drop its Handlers while the us_socket_t lives on; + // `get_handlers()` would panic. Same guard as `select_alpn_callback`. + if !tls.has_handlers() { return core::ptr::null_mut(); } - // SAFETY: see above — the listen socket keeps the `Listener` alive for the - // duration of this synchronous handshake dispatch. - let listener = unsafe { bun_ptr::ThisPtr::new(listener_ptr) }; - let handlers = &listener.handlers; + // No `Handlers::enter`/`exit` scope here: that protocol tracks the + // accepted-socket callback lifecycle, and running it from inside the + // handshake corrupts `active_connections` for every subsequent accept. + let handlers = tls.get_handlers(); let callback = handlers.on_server_name(); if callback.is_empty() { return core::ptr::null_mut(); } - // No `Handlers::enter`/`exit` scope here: that protocol tracks the - // accepted-socket callback lifecycle, and running it against the listener's - // own handlers from inside the handshake corrupts `active_connections` for - // every subsequent accept. The listener and its handlers are structurally - // alive for this synchronous dispatch - the listen socket cannot be freed - // mid-handshake. let global = handlers.global_object; - // Pass the listener's `data` (the owning net.Server) rather than minting a - // JS wrapper for the Listener itself - `to_js` here would create a second - // cell owning the same Rust struct and whichever is collected first frees - // it out from under the other. - let this_value = listener - .strong_data - .get() - .get() - .unwrap_or(JSValue::UNDEFINED); + // The resume handle an asynchronous SNICallback uses (`handle.resumeSNI(...)`). + // GC-managed, so a resume after the socket died is a safe no-op. + let socket_handle = tls.get_this_value(&global); + // The handler's `this` is the `data` that carries `_SNICallback`: the + // listener's (the net.Server, kept past `stop()` while connections remain) + // for an accepted socket, the socket's own (the TLSSocket) for an adopted one. + let this_value = match handlers.listener() { + Some(listener) => listener.strong_data.get().get(), + None => TLSSocket::data_get_cached(socket_handle), + } + .unwrap_or(JSValue::UNDEFINED); // SAFETY: `hostname` is NUL-terminated per the fn contract. let name = unsafe { core::ffi::CStr::from_ptr(hostname) }; + // Peer-supplied SNI bytes, decoded as Latin-1 like Node's `OneByteString`. let js_name = EncodedSlice::latin1(name.to_bytes()).to_js(&global); - // The accepted socket processing this ClientHello: its JS wrapper is the - // resume handle an asynchronous SNICallback uses (`handle.resumeSNI(...)`) - // to complete the suspended handshake. The wrapper's lifecycle is - // GC-managed, so a resume after the socket died is a safe no-op. - let socket_handle: JSValue = if socket.is_null() { - JSValue::UNDEFINED - } else { - // SAFETY: the C caller passes the live us_socket_t processing this - // ClientHello; for BunSocketTls sockets the ext slot holds the - // TLSSocket wrapper. - let s_ref = uws_sys::us_socket_t::opaque_mut(socket.cast()); - if s_ref.kind() == uws_sys::SocketKind::BunSocketTls { - match *s_ref.ext::>>() { - Some(tls) => tls.get_this_value(&global), - None => JSValue::UNDEFINED, - } - } else { - JSValue::UNDEFINED - } - }; let result = match callback.call(&global, this_value, &[this_value, js_name, socket_handle]) { Ok(v) => v, Err(err) => global.take_exception(err), diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index 9a0c7c203c04..88796909465f 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -2,7 +2,7 @@ use core::ffi::{c_char, c_int, c_void}; use bun_boringssl_sys::OwnedSslCtx; -use crate::{SocketGroup, SslCtx, us_socket_t}; +use crate::{SslCtx, us_socket_t}; bun_opaque::opaque_ffi! { /// Opaque FFI handle for a uSockets listen socket. @@ -36,12 +36,6 @@ impl ListenSocket { )) } - /// Group accepted sockets are linked into. - pub fn group(&mut self) -> &mut SocketGroup { - // SAFETY: self is a valid listen socket; C returns a non-null group. - unsafe { &mut *us_listen_socket_group(self) } - } - /// `ssl_ctx` is `SSL_CTX_up_ref`'d for the SNI node; the listener drops /// that ref on close, or when `hostname` is registered again. `user` is the per-domain handle /// `find_server_name_userdata` recovers (uWS uses an `HttpRouter*`; Bun.listen @@ -73,6 +67,7 @@ impl ListenSocket { unsafe { us_listen_socket_set_default_ssl_ctx(self, ctx.as_ptr()) } } + /// `cb` receives a null `ListenSocket` once this listen socket closed. pub fn on_server_name( &mut self, cb: extern "C" fn(*mut ListenSocket, *const c_char, *mut c_int, *mut c_void) -> *mut c_void, @@ -87,7 +82,6 @@ impl ListenSocket { // `safe fn`. Shims with nullable raw / ctx ptr stay unsafe. unsafe extern "C" { safe fn us_listen_socket_close(ls: &mut ListenSocket); - safe fn us_listen_socket_group(ls: &mut ListenSocket) -> *mut SocketGroup; fn us_listen_socket_add_server_name( ls: *mut ListenSocket, hostname: *const c_char, diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 13209b02f785..4e52ca33c9bd 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "fs"; +import { once } from "node:events"; +import net from "node:net"; import tls from "node:tls"; import { join } from "path"; import privateKey from "../../third_party/jsonwebtoken/priv.pem" with { type: "text" }; @@ -421,6 +423,65 @@ describe("Bun.serve per-serverName client certificate policy", () => { const { status: noCert } = await request(server.port, "admin.example.com"); expect({ servedCN, noCert }).toEqual({ servedCN: "agent3", noCert: "HTTP/1.1 200 OK" }); }); + + test("a handshake still queued at a graceful stop() is served under its serverName entry", async () => { + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: [ + { key: serverKey, cert: serverCert }, + { + serverName: "admin.example.com", + // A certificate of its own (CN=agent1), so the client can tell which entry answered. + ...untrustedClient, + ca: clientCa, + requestCert: true, + rejectUnauthorized: true, + }, + ], + fetch: () => new Response("members only"), + }); + const raws: net.Socket[] = []; + try { + // The loop runs a few handshakes per iteration and queues the rest, so + // ClientHellos sent in one go are not all processed when the first + // handshake completes. + for (let i = 0; i < 32; i++) raws.push(net.connect({ host: "127.0.0.1", port: server.port })); + for (const raw of raws) raw.on("error", () => {}); + await Promise.all(raws.map(raw => once(raw, "connect"))); + + const firstHandshake = Promise.withResolvers(); + // No client presents a certificate. + const outcomes = raws.map(raw => { + const { promise, resolve } = Promise.withResolvers<{ certificate: string; status: string }>(); + let certificate = "no handshake"; + let received = ""; + const socket = tls.connect({ socket: raw, servername: "admin.example.com", rejectUnauthorized: false }, () => { + certificate = socket.getPeerCertificate().subject.CN; + firstHandshake.resolve(); + socket.write("GET / HTTP/1.1\r\nHost: admin.example.com\r\nConnection: close\r\n\r\n"); + }); + socket.on("data", chunk => (received += chunk)); + socket.on("error", () => {}); + socket.on("close", () => resolve({ certificate, status: received.split("\r\n")[0] || "no response" })); + return promise; + }); + // If no handshake completes, every outcome settles and the assertions report them. + await Promise.race([firstHandshake.promise, Promise.all(outcomes)]); + server.stop(); + + const settled = await Promise.all(outcomes); + // Graceful stop may close a connection that has not started its + // handshake ("no handshake"). It must not answer one under another entry. + expect( + settled.filter(o => o.status !== "no response" || !["agent1", "no handshake"].includes(o.certificate)), + ).toEqual([]); + expect(settled.filter(o => o.certificate === "agent1").length).toBeGreaterThan(0); + } finally { + server.stop(true); + for (const raw of raws) raw.destroy(); + } + }); }); test.each(["Bun.serve", "Bun.listen"] as const)("%s serves every identity of key/cert arrays", async api => { diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 4db66b77a2c5..7af480346bed 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -359,6 +359,91 @@ describe("tls.Server", () => { "chain.example.com", ); }); + + // server.close() keeps the connections it already accepted. One that starts + // its handshake afterwards still selects its context by server name. + describe.each(["addContext", "SNICallback"] as const)("a handshake that starts after close() resolves %s", mode => { + it("like one that starts before it", async () => { + // agent1 chains to ca1, the client's agent3 to ca2: the default context + // authorizes the client, the a.example.com context does not. + const tenant = { key: agent1Key, cert: agent1Cert, ca: [ca1] }; + const names: string[] = []; + const server = tls.createServer( + { + key: agent2Key, + cert: agent2Cert, + ca: [ca2], + requestCert: true, + rejectUnauthorized: false, + SNICallback: + mode === "SNICallback" + ? (name, cb) => { + names.push(name); + if (name === "a.example.com") cb(null, tls.createSecureContext(tenant)); + else cb(new Error("unknown tenant")); + } + : undefined, + }, + socket => { + socket.on("error", () => {}); + //@ts-ignore + socket.end(`${socket.servername} authorized=${socket.authorized}`); + }, + ); + server.on("tlsClientError", () => {}); + if (mode === "addContext") server.addContext("a.example.com", tenant); + + const servernames = ["a.example.com", "a.example.com", "a.example.com", "unknown.example.com"]; + let accepted = 0; + const allAccepted = Promise.withResolvers(); + server.on("connection", () => { + if (++accepted === servernames.length) allAccepted.resolve(); + }); + server.on("error", allAccepted.reject); + const raws: net.Socket[] = []; + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + for (const _ of servernames) raws.push(net.connect({ host: "127.0.0.1", port })); + for (const raw of raws) raw.on("error", () => {}); + await Promise.all(raws.map(raw => once(raw, "connect"))); + await allAccepted.promise; + + // No TLS byte has been sent yet. + server.close(); + + const outcomes = await Promise.all( + raws.map((raw, i) => { + const { promise, resolve } = Promise.withResolvers<{ certificate: string; received: string }>(); + let certificate = "no handshake"; + let received = ""; + const socket = tls.connect( + { socket: raw, servername: servernames[i], rejectUnauthorized: false, key: agent3Key, cert: agent3Cert }, + () => (certificate = socket.getPeerCertificate().subject.CN), + ); + socket.on("data", chunk => (received += chunk)); + socket.on("error", () => {}); + socket.on("close", () => resolve({ certificate, received })); + return promise; + }), + ); + + const tenantOutcome = { certificate: "agent1", received: "a.example.com authorized=false" }; + expect(outcomes.slice(0, 3)).toEqual([tenantOutcome, tenantOutcome, tenantOutcome]); + if (mode === "SNICallback") { + // The callback is the allow-list: a name it rejects gets no session. + expect(outcomes[3]).toEqual({ certificate: "no handshake", received: "" }); + expect(names.toSorted()).toEqual(servernames.toSorted()); + } else { + expect(outcomes[3].certificate).toBe("agent2"); + } + } finally { + if (server.listening) server.close(); + for (const raw of raws) raw.destroy(); + } + }); + }); }); // A resumed handshake skips client authentication, so the server may only diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 761f205fdd0c..62f27c949d1e 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3633,3 +3633,104 @@ describe("key/cert arrays", () => { } }); }); + +// close() keeps the connections it accepted, and each selects its context when its ClientHello arrives. +describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names after close() (%s)", version => { + const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); + const agent = (n: number) => ({ key: fixture(`agent${n}-key.pem`), cert: fixture(`agent${n}-cert.pem`) }); + + // `count` connections the server has accepted and that have not sent a TLS byte yet. + async function acceptRaw(server: Server, count: number) { + server.on("secureConnection", socket => socket.on("error", () => {}).end()); + server.on("tlsClientError", () => {}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { port } = server.address() as AddressInfo; + const accepted = Promise.withResolvers(); + let connections = 0; + server.on("connection", () => ++connections === count && accepted.resolve()); + const raws = Array.from({ length: count }, () => net.connect(port, "127.0.0.1").on("error", () => {})); + await accepted.promise; + return raws; + } + + // The CN of the certificate the server presents, or "no handshake". + function servedCN(socket: net.Socket, servername: string) { + const { promise, resolve } = Promise.withResolvers(); + const options = { socket, servername, rejectUnauthorized: false, minVersion: version, maxVersion: version }; + const client = connect(options, () => { + resolve((client.getPeerCertificate() as PeerCertificate).subject.CN); + client.destroy(); + }); + client.on("error", () => {}); + client.on("close", () => resolve("no handshake")); + return promise; + } + + // Bun only: in Node a user SNICallback replaces the addContext() entries, so this is agent2 there. + it("an SNICallback that is pending at close() and then selects nothing falls back to addContext()", async () => { + const pending = Promise.withResolvers(); + const server: Server = createServer({ ...agent(2), SNICallback: (_, cb) => pending.resolve(cb) }); + server.addContext("a.example", agent(3)); + const [raw] = await acceptRaw(server, 1); + const served = servedCN(raw, "a.example"); + const cb = await pending.promise; + server.close(); + cb(null, null); + expect(await served).toBe("agent3"); + }); + + it("close() from inside the SNICallback does not end it for the other connections", async () => { + let calls = 0; + const server: Server = createServer({ + ...agent(2), + SNICallback(_, cb) { + calls++; + if (server.listening) server.close(); + cb(null, tls.createSecureContext(agent(1))); + }, + }); + const raws = await acceptRaw(server, 3); + const served: string[] = []; + for (const raw of raws) served.push(await servedCN(raw, "a.example")); + expect({ served, calls }).toEqual({ served: ["agent1", "agent1", "agent1"], calls: 3 }); + }); + + it("an accepted connection sees addContext() and keeps its default across setSecureContext()", async () => { + const server: Server = createServer(agent(2)); + // Node looks names up only on connections accepted while the server had an entry. + server.addContext("early.example", agent(1)); + const raws = await acceptRaw(server, 2); + server.addContext("late.example", agent(3)); + server.setSecureContext(agent(1)); + server.close(); + expect(await Promise.all([servedCN(raws[0], "late.example"), servedCN(raws[1], "other.example")])).toEqual([ + "agent3", + "agent2", + ]); + }); + + it("the names last as long as the connections, and no longer", async () => { + Bun.gc(true); + const before = sslCtxLiveCount(); + // Nothing here outlives the call but the raw client sockets. + const raws = await (async () => { + const server: Server = createServer({ ...agent(2), sessionTimeout: 4001 }); + for (let i = 0; i < 4; i++) { + server.addContext(`${i}.example`, tls.createSecureContext({ ...agent(1), sessionTimeout: 4002 + i })); + } + const raws = await acceptRaw(server, 4); + server.close(); + return raws; + })(); + Bun.gc(true); + expect(sslCtxLiveCount() - before).toBeGreaterThanOrEqual(5); + expect(await Promise.all(raws.map((raw, i) => servedCN(raw, `${i}.example`)))).toEqual(Array(4).fill("agent1")); + // Finalizers run on GC, so wait for the condition. + for (let i = 0; i < 100 && sslCtxLiveCount() > before; i++) { + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); + } + expect(sslCtxLiveCount()).toBeLessThanOrEqual(before); + }); +}); From bc40063b103408d451fc57d8f092fb803ec717ca Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:56:23 +0000 Subject: [PATCH 032/174] Bun.serve: graceful stop() also drains TLS connections parked in the handshake queue (#42285) closeIdle(true) walks group->head_sockets, but a TLS socket whose handshake waits for the loop's per-iteration budget is unlinked into the low-priority queue, so it never got HTTP_CLOSE_WHEN_IDLE: it stayed keep-alive after stop() and held the drain promise open for as long as the client kept it busy. The sweep now marks the group's parked sockets too. --- packages/bun-uws/src/App.h | 9 ++++ test/js/bun/http/bun-server.test.ts | 69 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index e7da9db6c7ce..30f58d79ec54 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -402,6 +402,15 @@ struct TemplatedApp { } s = next; } + /* A handshake parked in the loop's low-priority queue is unlinked from head_sockets. Never idle: it only needs the mark. */ + if (closeWhenIdle && group->low_prio_count) { + for (s = group->loop->data.low_prio_head; s; s = s->next) { + if (s->group == group) { + auto *data = (HttpResponseData *) ((AsyncSocket *) s)->getAsyncSocketData(); + data->state |= HttpResponseData::HTTP_CLOSE_WHEN_IDLE; + } + } + } if (Http2Context *h2 = httpContext->getSocketContextData()->http2Context) { closed += h2->closeIdle(closeWhenIdle); } diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index ebcdf9406f6a..681936a5fa0c 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -1378,6 +1378,75 @@ describe.concurrent("server.stop() drain promise counts open connections", () => }); }); + test("stop() drains TLS connections whose handshake is queued behind the per-iteration budget", async () => { + // The loop runs five TLS handshakes per iteration and parks the rest of a + // burst in a loop-wide low-priority queue, unlinked from the socket list + // the stop() sweep walks. The first request to arrive stops the server, so + // most of the burst is still parked at that moment. A parked connection + // that the sweep misses stays keep-alive: it answers every later request + // and holds the drain promise open until the client hangs up. The client + // only hangs up after a third answer, so one answer on a connection means + // the server closed it after the first response. + const connections = 30; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + let stopped; + let resolved = false; + const server = Bun.serve({ + port: 0, hostname: "127.0.0.1", tls: ${JSON.stringify(tls)}, + idleTimeout: 255, + fetch() { + stopped ??= server.stop().then(() => { resolved = true; }); + return new Response("ok"); + }, + }); + const GET = "GET / HTTP/1.1\\r\\nHost: x\\r\\n\\r\\n"; + const END = "\\r\\n\\r\\nok"; + const conns = []; + for (let i = 0; i < ${connections}; i++) { + const closed = Promise.withResolvers(); + const conn = { answers: 0, closed: closed.promise }; + let buf = ""; + Bun.connect({ + hostname: "127.0.0.1", port: server.port, tls: { rejectUnauthorized: false }, + socket: { + handshake: s => void s.write(GET), + data(s, d) { + buf += d.toString("latin1"); + while (buf.includes(END)) { + buf = buf.slice(buf.indexOf(END) + END.length); + if (++conn.answers < 3) s.write(GET); + else s.end(); + } + }, + close: () => closed.resolve(), + error: () => {}, + connectError: () => closed.resolve(), + }, + }).catch(() => {}); + conns.push(conn); + } + await Promise.all(conns.map(c => c.closed)); + await stopped; + console.log(JSON.stringify({ resolved, answers: conns.map(c => c.answers).join("") })); + process.exit(0); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, out: JSON.parse(stdout.trim() || "null"), exitCode }).toEqual({ + stderr: "", + out: { resolved: true, answers: Buffer.alloc(connections, "1").toString() }, + exitCode: 0, + }); + }); + test("server.reload() keeps the connection count coherent", async () => { // clearRoutes() used to wipe filterHandlers, so a connection open across // reload left active_connection_count stuck > 0 forever. With the stuck From 8052c9686e29fe031a7b74a013ca45fa2f58f2b8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:56:24 +0000 Subject: [PATCH 033/174] tls: pin that HTTP/3 and TCP select the same serverName entry, document the wildcard rule (#37195, #43849) The HTTP/3 SNI test now asks the TCP listener of the same server for every name and compares. The docs say that names fold case and that a '*' stands for exactly one label on every transport. --- docs/runtime/http/tls.mdx | 2 +- test/js/bun/http/serve-http3.test.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/runtime/http/tls.mdx b/docs/runtime/http/tls.mdx index 838f7a448b01..7ff46040c4c6 100644 --- a/docs/runtime/http/tls.mdx +++ b/docs/runtime/http/tls.mdx @@ -100,4 +100,4 @@ Bun.serve({ }); ``` -If two entries share a `serverName`, the later entry replaces the earlier one. This matches `tls.Server#addContext()`. A trailing dot is ignored, so `my-server1.com.` and `my-server1.com` name the same host. +If two entries share a `serverName`, the later entry replaces the earlier one. This matches `tls.Server#addContext()`. A trailing dot is ignored, so `my-server1.com.` and `my-server1.com` name the same host. Names are compared case-insensitively, and a `*` stands for exactly one label on every transport: `*.my-server1.com` matches `a.my-server1.com` but not `a.b.my-server1.com`. diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 2e0632db9267..286bea043aed 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -3,7 +3,9 @@ import { describe, expect, test } from "bun:test"; import { createHash, createPrivateKey, randomBytes, X509Certificate } from "crypto"; import { readFileSync } from "fs"; import { bunEnv, bunExe, isASAN, tempDir, tls } from "harness"; -import { connect, QuicEndpoint, type SessionOptions } from "node:quic"; +import { once } from "node:events"; +import { connect, QuicEndpoint } from "node:quic"; +import { connect as connectTLS } from "node:tls"; import { join } from "path"; // Native HTTP/3 fetch wrapper. Every request in this file forces @@ -2084,6 +2086,7 @@ describe("Bun.serve HTTP/3 SNI", () => { fetch: () => new Response("ok"), }); const served: Record = {}; + const overTCP: Record = {}; for (const servername of [ "Agent2.Example", "agent2.example", @@ -2095,8 +2098,12 @@ describe("Bun.serve HTTP/3 SNI", () => { "other.example", ]) { served[servername] = await servedCN(server.port, servername); + const socket = connectTLS({ host: "127.0.0.1", port: server.port, servername, rejectUnauthorized: false }); + await once(socket, "secureConnect"); + overTCP[servername] = socket.getPeerCertificate().subject.CN; + socket.destroy(); } - // The same names select the same entries on the TCP listener. + expect(overTCP).toEqual(served); expect(served).toEqual({ "Agent2.Example": "agent2", "agent2.example": "agent2", From 970c4616fabe7976d06ee193b179a140575fe4ee Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:56:25 +0000 Subject: [PATCH 034/174] node:tls: apply addContext() to connections the server wraps itself (#37896) A socket handed in with server.emit('connection') never passes through a listener, so its addContext() entries were ignored and it always got the default certificate. As in Node, the context matcher is now the default SNICallback of the wrap, and a user SNICallback replaces it. The names are matched by the same tree a listener keeps them in (selectServerName), so both paths select the same entry for every spelling of a name. --- src/js/node/tls.ts | 11 +++- src/jsc/bindings/NodeTLS.cpp | 34 ++++++++++ src/jsc/bindings/NodeTLS.h | 1 + test/js/node/tls/node-tls-server.test.ts | 80 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 1f79560fd39d..d5e9816197b3 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -46,6 +46,7 @@ const parseCACertificates = $newCppFunction("NodeTLS.cpp", "parseCACertificates" const getTLSDefaultCiphers = $newCppFunction("NodeTLS.cpp", "getDefaultCiphers", 0); const setTLSDefaultCiphers = $newCppFunction("NodeTLS.cpp", "setDefaultCiphers", 1); +const selectServerName = $newCppFunction("NodeTLS.cpp", "selectServerName", 2); let _VALID_CIPHERS_SET: Set | undefined; function getValidCiphersSet() { if (!_VALID_CIPHERS_SET) { @@ -600,6 +601,7 @@ const ksecureContext = Symbol("ksecureContext"); const kcheckServerIdentity = Symbol("kcheckServerIdentity"); const ksession = Symbol("ksession"); const krenegotiationDisabled = Symbol("renegotiationDisabled"); +const kcontexts = Symbol("kcontexts"); const buntls = Symbol.for("::buntls::"); // net.ts's SNI dispatch uses this to recognize a raw native SecureContext @@ -1070,6 +1072,11 @@ function buildSharedCreds(server, options) { ); } +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1599-L1611 +function SNICallback(servername, callback) { + callback(null, selectServerName(this.server[kcontexts], servername)); +} + function Server(options, secureConnectionListener): void { if (!(this instanceof Server)) { return new Server(options, secureConnectionListener); @@ -1131,7 +1138,7 @@ function Server(options, secureConnectionListener): void { if (serverOptions?.ALPNProtocols) convertALPNProtocols(serverOptions.ALPNProtocols, this); this._sharedCreds = undefined; - const contexts = new Map>(); + const contexts = (this[kcontexts] = new Map>()); this.addContext = function (hostname, context) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1571-L1574 @@ -1391,7 +1398,7 @@ function Server(options, secureConnectionListener): void { isServer: true, requestCert: this._requestCert, rejectUnauthorized: this._rejectUnauthorized, - SNICallback: this._SNICallback, + SNICallback: this._SNICallback ?? (contexts.size ? SNICallback : undefined), ALPNProtocols: this.ALPNProtocols, ALPNCallback: this._ALPNCallback, }); diff --git a/src/jsc/bindings/NodeTLS.cpp b/src/jsc/bindings/NodeTLS.cpp index fe4503aa9b5c..b4fe1c11bbea 100644 --- a/src/jsc/bindings/NodeTLS.cpp +++ b/src/jsc/bindings/NodeTLS.cpp @@ -4,10 +4,13 @@ #include "JavaScriptCore/IteratorOperations.h" #include "JavaScriptCore/JSArrayBufferView.h" +#include "JavaScriptCore/JSMap.h" +#include "JavaScriptCore/JSMapIterator.h" #include "JavaScriptCore/JSObject.h" #include "JavaScriptCore/ObjectConstructor.h" #include "JavaScriptCore/ArrayConstructor.h" #include "libusockets.h" +#include "bun-usockets/src/internal/internal.h" #include "ZigGlobalObject.h" #include "ErrorCode.h" @@ -319,4 +322,35 @@ JSC_DEFINE_HOST_FUNCTION(setDefaultCiphers, (JSC::JSGlobalObject * globalObject, return Bun__setTLSDefaultCiphers(globalObject, callFrame); } +// (names: Map, servername: string) => T | undefined, matched by the tree a listener keeps its names in. +JSC_DEFINE_HOST_FUNCTION(selectServerName, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* names = dynamicDowncast(callFrame->argument(0)); + JSValue servernameValue = callFrame->argument(1); + if (!names || !servernameValue.isString()) return JSValue::encode(jsUndefined()); + // The SNI bytes reach JS decoded as Latin-1. + Latin1CString servername = servernameValue.toWTFString(globalObject).latin1(); + RETURN_IF_EXCEPTION(scope, {}); + + void* tree = sni_new(); + JSC::MarkedArgumentBuffer values; + auto* iterator = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), names, JSC::IterationKind::Entries); + JSValue name, value; + while (iterator->nextKeyValue(globalObject, name, value)) { + auto* string = name.isString() ? asString(name) : nullptr; + if (!string) continue; + auto view = string->view(globalObject); + // No ClientHello can carry a NUL. + if (scope.exception() || view->contains('\0')) continue; + values.append(value); + sni_add(tree, view->utf8().legacyCStringPointer(), reinterpret_cast(static_cast(values.size()))); + } + auto selected = reinterpret_cast(sni_find(tree, byteCast(servername.spanIncludingNullTerminator()).data())); + sni_free(tree, [](void*) {}); + RETURN_IF_EXCEPTION(scope, {}); + return JSValue::encode(selected ? values.at(selected - 1) : jsUndefined()); +} + } // namespace Bun diff --git a/src/jsc/bindings/NodeTLS.h b/src/jsc/bindings/NodeTLS.h index 8c35c58beb7b..84b80c0e7459 100644 --- a/src/jsc/bindings/NodeTLS.h +++ b/src/jsc/bindings/NodeTLS.h @@ -11,5 +11,6 @@ JSC_DECLARE_HOST_FUNCTION(getSystemCACertificates); JSC_DECLARE_HOST_FUNCTION(parseCACertificates); JSC_DECLARE_HOST_FUNCTION(getDefaultCiphers); JSC_DECLARE_HOST_FUNCTION(setDefaultCiphers); +JSC_DECLARE_HOST_FUNCTION(selectServerName); } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 62f27c949d1e..5659986bdb1e 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3734,3 +3734,83 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names after close() (%s)" expect(sslCtxLiveCount()).toBeLessThanOrEqual(before); }); }); + +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L924-L931 +describe.each(["TLSv1.2", "TLSv1.3"] as const)("addContext() on connections the server wraps itself (%s)", version => { + const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); + const agent = (n: number) => ({ key: fixture(`agent${n}-key.pem`), cert: fixture(`agent${n}-cert.pem`) }); + + // Serves `tlsServer` through server.emit("connection"), which no listener's names apply to. + async function inject(tlsServer: Server) { + tlsServer.on("secureConnection", socket => socket.on("error", () => {}).end()); + const front = net.createServer(raw => tlsServer.emit("connection", raw)); + front.listen(0, "127.0.0.1"); + await once(front, "listening"); + const { port } = front.address() as AddressInfo; + return { + async servedCN(servername: string) { + const client = connect({ + port, + host: "127.0.0.1", + servername, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + try { + await once(client, "secureConnect"); + return (client.getPeerCertificate() as PeerCertificate).subject.CN; + } finally { + client.destroy(); + } + }, + [Symbol.dispose]: () => void front.close(), + }; + } + + it("selects the entry that matches the servername", async () => { + const tlsServer = createServer(agent(1)); + tlsServer.addContext("exact.example", agent(2)); + tlsServer.addContext("*.wild.example", agent(3)); + tlsServer.addContext("twice.example", agent(3)); + tlsServer.addContext("twice.example", agent(2)); + using injected = await inject(tlsServer); + expect({ + exact: await injected.servedCN("exact.example"), + wildcard: await injected.servedCN("a.wild.example"), + twice: await injected.servedCN("twice.example"), + twoLabels: await injected.servedCN("a.b.wild.example"), + unknown: await injected.servedCN("other.example"), + }).toEqual({ exact: "agent2", wildcard: "agent3", twice: "agent2", twoLabels: "agent1", unknown: "agent1" }); + }); + + it("a user SNICallback replaces the entries", async () => { + const tlsServer = createServer({ ...agent(1), SNICallback: (_, cb) => cb(null, null) }); + tlsServer.addContext("exact.example", agent(2)); + using injected = await inject(tlsServer); + expect(await injected.servedCN("exact.example")).toBe("agent1"); + }); + + // Bun's listener and this path share one matcher. Node >= 26.4.0 folds case too, and takes no root dot. + it("matches a name like the server's own listener does", async () => { + const tlsServer = createServer(agent(1), socket => socket.on("error", () => {}).end()); + tlsServer.addContext("Exact.Example", agent(2)); + tlsServer.addContext("*.example", agent(3)); + using injected = await inject(tlsServer); + tlsServer.listen(0, "127.0.0.1"); + await once(tlsServer, "listening"); + try { + const { port } = tlsServer.address() as AddressInfo; + for (const servername of ["exact.example", "EXACT.EXAMPLE", "exact.example.", "other.example", "a.b.example"]) { + const client = connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }); + await once(client, "secureConnect"); + const accepted = (client.getPeerCertificate() as PeerCertificate).subject.CN; + client.destroy(); + expect([servername, await injected.servedCN(servername)]).toEqual([servername, accepted]); + } + expect(await injected.servedCN("EXACT.EXAMPLE.")).toBe("agent2"); + } finally { + tlsServer.close(); + } + }); +}); From 00832de9cd3ab46dd969b4fdd3cb75a4756c37c8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:56:26 +0000 Subject: [PATCH 035/174] node:tls: keep ALPN across an SNI SecureContext selection (#33253) BoringSSL reads the server ALPN callback off the current ssl->ctx, and SNI replaces that with SSL_set_SSL_CTX before ALPN is negotiated. Bun installed its selector only on the listener's default SSL_CTX, so after SNICallback or addContext() picked a context, ALPNProtocols was dropped, ALPNCallback never ran and a client with no protocol in common connected anyway. The selector is now installed on every context that enters the SNI machinery. It reads its state from the SSL, so it is inert on a connection that configured no ALPN. --- src/runtime/socket/Listener.rs | 6 +- src/runtime/socket/socket_body.rs | 14 +++ test/js/node/tls/node-tls-context.test.ts | 140 ++++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 6ffe022ae40c..f7e830454cb7 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -786,6 +786,8 @@ impl Listener { return Ok(JSValue::UNDEFINED); }; + super::socket_body::install_sni_alpn_selector(sni_ctx.as_ptr()); + // The C SNI tree SSL_CTX_up_ref()s; ours drops here. // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. bun_opaque::opaque_deref_mut(ls).add_server_name( @@ -2051,7 +2053,9 @@ fn decode_sni_result(result: JSValue, abort_handshake: *mut core::ffi::c_int) -> } if let Some(sc) = result.as_class_ref::() { // The C dispatcher frees this +1 after `SSL_set_SSL_CTX` takes its own. - return sc.ctx.clone().into_raw().cast(); + let ctx = sc.ctx.clone().into_raw(); + super::socket_body::install_sni_alpn_selector(ctx); + return ctx.cast(); } // Anything else is not a SecureContext: Node treats this as an invalid SNI // context and drops the connection. diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 84dfc2309afc..1edee5fede7d 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -281,6 +281,19 @@ extern "C" fn select_alpn_callback( } } +/// BoringSSL reads the server ALPN callback off the *current* `ssl->ctx`, and +/// SNI replaces that with `SSL_set_SSL_CTX` before ALPN negotiation. +pub(in crate::socket) fn install_sni_alpn_selector(ctx: *mut boringssl_sys::SSL_CTX) { + if ctx.is_null() { + return; + } + tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( + SSL_CTX::opaque_ref(ctx), + Some(select_alpn_callback), + ptr::null_mut(), + ); +} + // ────────────────────────────────────────────────────────────────────────── // NewSocket // ────────────────────────────────────────────────────────────────────────── @@ -910,6 +923,7 @@ impl NewSocket { } else { core::ptr::null_mut() }; + install_sni_alpn_selector(ctx_ptr); socket.sni_resolve(ctx_ptr, is_error); Ok(JSValue::UNDEFINED) } diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 7af480346bed..75a669edb25b 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -7,6 +7,7 @@ import { bunEnv, bunExe, tempDir } from "harness"; import { X509Certificate } from "node:crypto"; import { once } from "node:events"; import { readFileSync } from "node:fs"; +import http2 from "node:http2"; import net, { AddressInfo } from "node:net"; import { join } from "node:path"; import { Duplex } from "node:stream"; @@ -723,6 +724,145 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)("session resumption across SNI co }); }); +// Node negotiates ALPN per connection, whichever SecureContext SNI selected. +// https://github.com/oven-sh/bun/issues/17932 +describe.each(["TLSv1.2", "TLSv1.3"] as const)("ALPN after SNI selected a SecureContext (%s)", version => { + const defaults = { key: agent2Key, cert: agent2Cert }; + const selected = { key: agent1Key, cert: agent1Cert }; + type SNICb = (err: Error | null, ctx?: unknown) => void; + const sync = (_: string, cb: SNICb) => cb(null, tls.createSecureContext(selected)); + const deferred = (_: string, cb: SNICb) => void setImmediate(cb, null, tls.createSecureContext(selected)); + + // How the connection comes by its context. Each returns the port to dial. + type Start = (alpn: object, onSecure: (socket: tls.TLSSocket) => void, stack: DisposableStack) => Promise; + const listening = async (server: net.Server, stack: DisposableStack) => { + stack.defer(() => void server.close()); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return (server.address() as AddressInfo).port; + }; + const accepted = + (options: object, setup?: (server: tls.Server) => void): Start => + (alpn, onSecure, stack) => { + const server = tls.createServer({ ...defaults, ...options, ...alpn }, onSecure); + server.on("tlsClientError", () => {}); + setup?.(server); + return listening(server, stack); + }; + const addContext = (server: tls.Server) => server.addContext("alpn.sni.test", selected); + const paths: [string, Start][] = [ + ["a synchronous SNICallback", accepted({ SNICallback: sync })], + ["an asynchronous SNICallback", accepted({ SNICallback: deferred })], + ["addContext()", accepted({}, addContext)], + // Bun only: in Node a user SNICallback replaces the addContext() entries. + [ + "addContext() behind an SNICallback that selects nothing", + accepted({ SNICallback: (_: string, cb: SNICb) => void setImmediate(cb, null, null) }, addContext), + ], + [ + "addContext() on an injected socket", + (alpn, onSecure, stack) => { + const server = tls.createServer({ ...defaults, ...alpn }, onSecure); + server.on("tlsClientError", () => {}); + addContext(server); + return listening( + net.createServer(raw => server.emit("connection", raw)), + stack, + ); + }, + ], + [ + "the SNICallback of a server-side TLSSocket", + (alpn, onSecure, stack) => + listening( + net.createServer(raw => { + const socket = new tls.TLSSocket(raw, { + isServer: true, + secureContext: tls.createSecureContext(defaults), + SNICallback: sync, + ...alpn, + }); + socket.on("error", () => {}); + socket.on("secure", () => onSecure(socket)); + }), + stack, + ), + ], + ]; + + function dial(port: number, ALPNProtocols: string[]) { + return tls.connect({ + port, + host: "127.0.0.1", + servername: "alpn.sni.test", + ALPNProtocols, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + } + + describe.each(paths)("by %s", (_, start) => { + it("negotiates from ALPNProtocols", async () => { + using stack = new DisposableStack(); + const serverSide = Promise.withResolvers(); + const port = await start({ ALPNProtocols: ["h2", "http/1.1"] }, s => serverSide.resolve(s.alpnProtocol), stack); + const client = dial(port, ["http/1.1", "h2"]); + stack.defer(() => void client.destroy()); + await once(client, "secureConnect"); + expect({ + cn: client.getPeerCertificate().subject.CN, + client: client.alpnProtocol, + server: await serverSide.promise, + }).toEqual({ cn: "agent1", client: "h2", server: "h2" }); + }); + + it("runs ALPNCallback", async () => { + using stack = new DisposableStack(); + const seen: unknown[] = []; + const ALPNCallback = (offer: unknown) => (seen.push(offer), "h2"); + const client = dial(await start({ ALPNCallback }, s => s.end(), stack), ["http/1.1", "h2"]); + stack.defer(() => void client.destroy()); + await once(client, "secureConnect"); + expect({ cn: client.getPeerCertificate().subject.CN, client: client.alpnProtocol, seen }).toEqual({ + cn: "agent1", + client: "h2", + seen: [{ servername: "alpn.sni.test", protocols: ["http/1.1", "h2"] }], + }); + }); + + // RFC 7301 section 3.2 + it("refuses a client that offers none of ALPNProtocols", async () => { + using stack = new DisposableStack(); + const client = dial(await start({ ALPNProtocols: ["h2"] }, s => s.end(), stack), ["spdy/3"]); + stack.defer(() => void client.destroy()); + const outcome = Promise.withResolvers(); + client.on("error", err => outcome.resolve((err as NodeJS.ErrnoException).code)); + client.on("secureConnect", () => outcome.resolve(`connected, alpnProtocol=${client.alpnProtocol}`)); + expect(await outcome.promise).toBe("ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL"); + }); + }); + + it("http2.createSecureServer() with an SNICallback speaks h2", async () => { + using stack = new DisposableStack(); + const server = http2.createSecureServer({ ...defaults, SNICallback: sync }, (_, res) => res.end("over h2")); + const port = await listening(server, stack); + const session = http2.connect(`https://127.0.0.1:${port}`, { + servername: "alpn.sni.test", + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + stack.defer(() => session.destroy()); + const failed = once(session, "error").then(([err]) => Promise.reject(err)); + const request = session.request({ ":path": "/" }).setEncoding("utf8"); + let body = ""; + request.on("data", chunk => (body += chunk)); + await Promise.race([once(request, "end"), failed]); + expect(body).toBe("over h2"); + }); +}); + describe("Bun.serve SNI", () => { function doClientRequest(options: any) { return new Promise((resolve, reject) => { From f0e7efda37f9369caeff0774c28e4d908139ee38 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:59:07 +0000 Subject: [PATCH 036/174] node:tls: addContext() with a NUL in the name matches nothing (#37013) The name reached the C tree as a C string, so addContext("a.example\0evil") registered its certificate under a.example. No ClientHello can carry a NUL, so such a pattern now registers nothing, as in Node, which also removes the unsafe CStr::from_ptr that existed to tolerate it. --- src/runtime/socket/Listener.rs | 12 ++++----- test/js/node/tls/node-tls-server.test.ts | 32 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index f7e830454cb7..3351c245748e 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -740,14 +740,12 @@ impl Listener { global.throw_invalid_arguments(format_args!("hostname pattern cannot be empty")) ); } - // NUL-terminate for the C `const char*` parameter. Interior NULs are - // tolerated — the C SNI tree just truncates at the first one. Build the - // `&CStr` via `from_ptr` to allow that instead of asserting via - // `ZStr::as_cstr()`. `server_name_z` must outlive the - // add_server_name call below. + // No ClientHello can carry a NUL, so this pattern matches nothing, as in Node. + if bun_core::strings::contains_char(server_name_bytes, 0) { + return Ok(JSValue::UNDEFINED); + } let server_name_z = bun_core::ZBox::from_bytes(server_name_bytes); - // SAFETY: `server_name_z` is NUL-terminated and lives to end of scope. - let server_name = unsafe { core::ffi::CStr::from_ptr(server_name_z.as_ptr()) }; + let server_name = server_name_z.as_zstr().as_cstr(); let ListenerType::Uws(ls) = this.listener.get() else { return Ok(JSValue::UNDEFINED); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 5659986bdb1e..134ef654ab79 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3814,3 +3814,35 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("addContext() on connections the } }); }); + +it("addContext() with a NUL in the name registers nothing under the part before it", async () => { + const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); + const agent = (n: number) => ({ key: fixture(`agent${n}-key.pem`), cert: fixture(`agent${n}-cert.pem`) }); + const server: Server = createServer(agent(1), socket => socket.on("error", () => {}).end()); + const front = net.createServer(raw => server.emit("connection", raw)); + try { + server.addContext("before.example\0evil", agent(2)); + server.listen(0, "127.0.0.1"); + front.listen(0, "127.0.0.1"); + await Promise.all([once(server, "listening"), once(front, "listening")]); + server.addContext("after.example\0evil", agent(2)); + const served: Record = {}; + for (const [path, { port }] of Object.entries({ accepted: server.address(), injected: front.address() })) { + for (const servername of ["before.example", "after.example"]) { + const client = connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }); + await once(client, "secureConnect"); + served[`${path} ${servername}`] = (client.getPeerCertificate() as PeerCertificate).subject.CN; + client.destroy(); + } + } + expect(served).toEqual({ + "accepted before.example": "agent1", + "accepted after.example": "agent1", + "injected before.example": "agent1", + "injected after.example": "agent1", + }); + } finally { + front.close(); + server.close(); + } +}); From f0dbe418a96b11b22c48d676e5cc7618e7114f9d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:03:39 +0000 Subject: [PATCH 037/174] sql(postgres): send the plaintext startup when sslmode=prefer and the server declines TLS (#33666) The 'N' arm of the SSLRequest answer kept reading and nothing ever sent the StartupMessage, so sslmode=prefer/allow against a server without TLS always ran into connectionTimeout. Start the connection there, as the 'S' arm does after the upgrade, and drop whatever came in the same read as the 'N'. require, verify-ca and verify-full still fail on 'N'. --- src/sql_jsc/postgres/PostgresRequest.rs | 4 +- src/sql_jsc/postgres/PostgresSQLConnection.rs | 2 +- test/js/sql/postgres-tls-ctx-leak.test.ts | 14 +- test/js/sql/tls-sql.test.ts | 129 ++++++++++++++++++ 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index a3af1fcb24ca..88eaf248400f 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -543,7 +543,9 @@ pub(crate) fn on_data( ); return Ok(()); } - continue; + // Nothing the server sent before our StartupMessage is a reply to it. + connection.start(); + return Ok(()); } _ => return Err(AnyPostgresError::UnexpectedMessage), } diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 8191ee47866c..24864c50e359 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -598,7 +598,7 @@ impl PostgresSQLConnection { ); } - fn start(&self) { + pub(crate) fn start(&self) { self.setup_max_lifetime_timer_if_necessary(); self.reset_connection_timeout(); self.send_startup_message(); diff --git a/test/js/sql/postgres-tls-ctx-leak.test.ts b/test/js/sql/postgres-tls-ctx-leak.test.ts index b9ae187fb547..ab675f4471f4 100644 --- a/test/js/sql/postgres-tls-ctx-leak.test.ts +++ b/test/js/sql/postgres-tls-ctx-leak.test.ts @@ -20,9 +20,9 @@ import { listeningServer, pgAuthenticationOk, pgReadyForQuery, pgSSLResponse } f // // This test doesn't need a real Postgres server: the tls_ctx is allocated up // front in PostgresSQLConnection.call() as soon as sslmode != disable, before -// any TLS handshake. A minimal mock server refuses SSL ('N') but then -// immediately sends AuthenticationOk + ReadyForQuery so the client reaches -// `.connected`, letting close() -> disconnect() -> GC -> finalize() -> +// any TLS handshake. A minimal mock server refuses SSL ('N'), then answers the +// plaintext StartupMessage with AuthenticationOk + ReadyForQuery so the client +// reaches `.connected`, letting close() -> disconnect() -> GC -> finalize() -> // deinit() exercise the teardown path. async function countPostgresConnectionsAfterGC(maxWait = 3000): Promise { @@ -40,11 +40,11 @@ async function countPostgresConnectionsAfterGC(maxWait = 3000): Promise } test("Postgres connections with sslmode != disable are finalized after close", async () => { - // 'N' (SSL refused) + AuthenticationOk + ReadyForQuery('I') - const handshake = Buffer.concat([pgSSLResponse("N"), pgAuthenticationOk(), pgReadyForQuery("I")]); - const { server, port } = await listeningServer(socket => { - socket.once("data", () => socket.write(handshake)); + socket.once("data", () => { + socket.write(pgSSLResponse("N")); + socket.once("data", () => socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery("I")]))); + }); socket.on("error", () => {}); }); diff --git a/test/js/sql/tls-sql.test.ts b/test/js/sql/tls-sql.test.ts index 5e45af7c07f5..12aa2ad8dd4b 100644 --- a/test/js/sql/tls-sql.test.ts +++ b/test/js/sql/tls-sql.test.ts @@ -8,7 +8,12 @@ import { listeningServer, pgAuthenticationCleartextPassword, pgAuthenticationOk, + pgCommandComplete, + pgDataRow, + pgErrorResponse, + pgReadFrontendMessages, pgReadyForQuery, + pgRowDescription, pgSSLRequest, pgSSLResponse, } from "./wire-frames"; @@ -463,6 +468,130 @@ test("postgres client aborts the connection when the server declines TLS that wa } }); +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// +// A plain-TCP Postgres that answers an SSLRequest with `sslRequestAnswer`, a StartupMessage with +// AuthenticationOk and a Query with one row, and hangs up on a ClientHello. Returns the frames it saw +// and what the client reported. +async function queryServerWithoutTls( + sslRequestAnswer: Buffer, + query: string, + options: SQL.Options = {}, + PGSSLMODE?: string, +) { + const wire: string[] = []; + const sockets = new Set(); + const { server, port } = await listeningServer(socket => { + sockets.add(socket); + socket.on("error", () => {}); + let buffered = Buffer.alloc(0); + let started = false; + socket.on("data", data => { + buffered = Buffer.concat([buffered, data]); + while (!started) { + if (buffered[0] === 0x16) { + wire.push("ClientHello"); + return void socket.end(); + } + if (buffered.length < 4 || buffered.length < buffered.readInt32BE(0)) return; + const frame = buffered.subarray(0, buffered.readInt32BE(0)); + buffered = buffered.subarray(frame.length); + if (frame.equals(pgSSLRequest())) { + wire.push("SSLRequest"); + socket.write(sslRequestAnswer); + } else { + wire.push("StartupMessage"); + started = true; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + } + } + buffered = pgReadFrontendMessages(buffered, type => { + if (type !== "Q".charCodeAt(0)) return; + wire.push("Query"); + socket.write( + Buffer.concat([ + pgRowDescription([{ name: "x", typeOid: 25 }]), + pgDataRow([Buffer.from("1")]), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), + ]), + ); + }); + }); + }); + + try { + // The constructor reads the environment. + if (PGSSLMODE) process.env.PGSSLMODE = PGSSLMODE; + await using sql = new SQL({ + url: `postgres://u:pw@127.0.0.1:${port}/db${query}`, + adapter: "postgres", + max: 1, + connectionTimeout: 5, + ...options, + }); + delete process.env.PGSSLMODE; + const result = await sql`select 1`.simple().then( + rows => rows[0].x, + e => e.code, + ); + return { wire, result }; + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } +} + +// https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS +test("postgres continues in plaintext after the server declines TLS only when the sslmode allows it", async () => { + const plaintext = { wire: ["SSLRequest", "StartupMessage", "Query"], result: "1" }; + const refused = { wire: ["SSLRequest"], result: "ERR_POSTGRES_TLS_NOT_AVAILABLE" }; + const rows: [query: string, options: SQL.Options, outcome: unknown, PGSSLMODE?: string][] = [ + ["?sslmode=disable", {}, { wire: ["StartupMessage", "Query"], result: "1" }], + ["?sslmode=allow", {}, plaintext], + ["?sslmode=prefer", {}, plaintext], + ["", { ssl: "prefer" }, plaintext], + ["", { tls: "prefer" }, plaintext], + ["", {}, plaintext, "prefer"], + ["?sslmode=require", {}, refused], + ["?sslmode=verify-ca", {}, refused], + ["?sslmode=verify-full", {}, refused], + ["?ssl=true", {}, refused], + ["", {}, refused, "require"], + ["", {}, refused, "verify-full"], + ["?sslmode=prefer", { tls: true }, refused], + ["?sslmode=prefer", { tls: {} }, refused], + ["?sslmode=prefer", { tls: { rejectUnauthorized: false } }, refused], + ["?sslmode=prefer", { ssl: "require" }, refused], + ["?sslmode=prefer", { ssl: "verify-ca" }, refused], + ["?sslmode=prefer", { ssl: "verify-full" }, refused], + ]; + const outcomes = await Promise.all( + rows.map(([query, options, , PGSSLMODE]) => queryServerWithoutTls(pgSSLResponse("N"), query, options, PGSSLMODE)), + ); + expect(rows.map((row, i) => row.with(2, outcomes[i]))).toEqual(rows); +}); + +test("postgres sslmode=prefer takes the 'N', and nothing that comes with it or in its place", async () => { + const injected = pgErrorResponse({ S: "FATAL", C: "XX000", M: "injected before startup" }); + expect( + await Promise.all([ + queryServerWithoutTls(Buffer.concat([pgSSLResponse("N"), injected]), "?sslmode=prefer"), + queryServerWithoutTls(pgAuthenticationCleartextPassword(), "?sslmode=prefer"), + // The server accepts TLS and then hangs up on the handshake. + queryServerWithoutTls(pgSSLResponse("S"), "?sslmode=prefer"), + ]), + ).toEqual([ + { wire: ["SSLRequest", "StartupMessage", "Query"], result: "1" }, + { wire: ["SSLRequest"], result: "ERR_POSTGRES_UNEXPECTED_MESSAGE" }, + { wire: ["SSLRequest", "ClientHello"], result: "ECONNRESET" }, + ]); +}); + // Reads the client's TLS records off the wire, which a container cannot show. A PostgreSQL server // logs "could not receive data from client" for a TLS connection that ends without a close_notify. test.each(["idleTimeout", "maxLifetime"])( From dbffcd741c0f05f68476d3f9d724e373222770be Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:12:02 +0000 Subject: [PATCH 038/174] sql: close a TLS connection without waiting for the peer's close_notify (#41711) #44327 did this for a Postgres connection that failed. close() of a healthy Postgres connection and every MySQL close still used CloseCode::Normal, which over TLS keeps the fd, and the pool slot, until the peer answers: close() never settled against a silent peer, and the next reserve() or query was rejected with the old connection's close. AnySocket::close_now sends close_notify and closes; both adapters use it. It skips the shutdown() while the handshake is in flight: usockets reports that FIN as a failed handshake with no reason, and a Postgres connectionTimeout or close() there aborted debug builds on an Error with an empty message. --- src/sql_jsc/mysql/JSMySQLConnection.rs | 2 +- src/sql_jsc/postgres/PostgresSQLConnection.rs | 25 +- src/uws_sys/socket.rs | 24 ++ src/uws_sys/us_socket_t.rs | 5 + .../sql/sql-close-pending-connection.test.ts | 397 +++++++++++++++++- test/js/sql/tls-sql.test.ts | 107 +++-- 6 files changed, 498 insertions(+), 62 deletions(-) diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 8ed2da29c633..5ee67adffa70 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -663,7 +663,7 @@ impl JSMySQLConnection { .queue .clean(js_reason, self.get_queries_array()); let socket = self.connection.get().socket(); - socket.close(uws::CloseKind::Normal); + socket.close_now(); self.connection_mut().discard_write_buffer(); } diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 24864c50e359..fd0852654b46 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -624,11 +624,9 @@ impl PostgresSQLConnection { let b: u32 = match self.status.get() { // Terminal states: nothing more will happen on this connection, so // allow GC to collect the JS wrapper (and ultimately call deinit()). - // We must still outlive the socket's onClose callback — for SSL - // sockets `close(.normal)` defers the actual close until the peer's - // close_notify arrives, so the struct must stay alive until then. - // The socket's onClose re-enters here (via failWithJSValue's defer) - // with isClosed() == true, at which point GC can proceed. + // We must still outlive the socket's onClose callback, which + // re-enters here (via failWithJSValue's defer) with + // isClosed() == true, at which point GC can proceed. Status::Disconnected | Status::Failed => (!self.socket.get().is_closed()) as u32, _ => 1, }; @@ -1492,25 +1490,12 @@ impl PostgresSQLConnection { fn ref_and_close(&self, js_reason: Option) { // refAndClose is always called when we wanna to disconnect or when we are closed - let socket = self.socket.get(); + let socket = *self.socket.get(); if !socket.is_closed() { // event loop need to be alive to close the socket self.poll_ref.with_mut(|r| r.ref_(self.vm_ctx())); // will unref on socket close - if js_reason.is_none() { - socket.close(uws::CloseKind::Normal); - } else { - // A failed connection does not wait for its peer, which `Normal` does over TLS - // (for a close_notify): a peer gone silent is one way connections fail. It still - // sends its own: an idle or expired connection has a healthy peer, which logs a - // close without one as an error. - socket.shutdown(); - socket.close(uws::CloseKind::FastShutdown); - // Parked behind ciphertext the kernel would not take. - if !socket.is_closed() { - socket.close(uws::CloseKind::Failure); - } - } + socket.close_now(); } // cleanup requests diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 4051f10d6f8d..6ab5036a70d0 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -287,6 +287,16 @@ impl NewSocketHandler { ) } + /// Always true for a connected plain TCP socket. + pub fn is_ssl_handshake_finished(&self) -> bool { + on_socket!(self.socket; + connected s => s.is_ssl_handshake_finished(), + duplex d => d.is_established(), + pipe p => p.is_established(), + else => false, + ) + } + #[inline] pub fn is_closed_or_has_error(&self) -> bool { self.is_closed() || self.is_shutdown() || self.get_error() != 0 @@ -956,6 +966,7 @@ impl AnySocket { fn is_closed(&self) -> bool; fn is_shutdown(&self) -> bool; fn is_established(&self) -> bool; + fn is_ssl_handshake_finished(&self) -> bool; fn close(&self, code: CloseCode); fn write(&self, data: &[u8]) -> i32; fn set_timeout(&self, seconds: c_uint); @@ -964,4 +975,17 @@ impl AnySocket { fn local_port(&self) -> Option; fn get_native_handle(&self) -> Option<*mut c_void>; } + + /// Sends close_notify and closes without waiting for the peer's, which `CloseCode::Normal` does. + pub fn close_now(&self) { + // usockets reports a FIN sent mid-handshake as a failed handshake with no reason. + if matches!(self, AnySocket::SocketTls(_)) && self.is_ssl_handshake_finished() { + self.shutdown(); + } + self.close(CloseCode::FastShutdown); + // usockets parks one fast shutdown behind ciphertext the kernel would not take. + if !self.is_closed() { + self.close(CloseCode::FastShutdown); + } + } } diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index 09f8e8d86611..16cb907f9e1f 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -466,6 +466,10 @@ impl us_socket_t { c::us_socket_is_established(self) > 0 } + pub(crate) fn is_ssl_handshake_finished(&self) -> bool { + c::us_socket_is_ssl_handshake_finished(self) > 0 + } + pub(crate) fn queued_input(&self) -> QueuedInput { match c::us_socket_queued_input(self) { LIBUS_QUEUED_INPUT_DATA => QueuedInput::Data, @@ -573,6 +577,7 @@ mod c { pub(super) safe fn us_socket_verify_error(s: &us_socket_t) -> us_bun_verify_error_t; pub(super) safe fn us_socket_get_error(s: &us_socket_t) -> c_int; pub(super) safe fn us_socket_is_established(s: &us_socket_t) -> i32; + pub(super) safe fn us_socket_is_ssl_handshake_finished(s: &us_socket_t) -> i32; pub(super) safe fn us_socket_queued_input(s: &us_socket_t) -> c_int; /// ssl_ctx is required (the whole point); sni may be null. diff --git a/test/js/sql/sql-close-pending-connection.test.ts b/test/js/sql/sql-close-pending-connection.test.ts index bbcb1d608ab8..2625af577861 100644 --- a/test/js/sql/sql-close-pending-connection.test.ts +++ b/test/js/sql/sql-close-pending-connection.test.ts @@ -16,7 +16,7 @@ // connectionTimeout: 0 disables the connect timer, so close() is the only // thing that can tear the connection down — without the fix these tests hang. -import { SQL } from "bun"; +import { SQL, type Socket } from "bun"; import { expect, mock, test } from "bun:test"; import { blackholePortSource, bunEnv, bunExe, isMusl, isWindows, tls as tlsCert } from "harness"; import net from "node:net"; @@ -25,12 +25,20 @@ import { listeningServer, MYSQL_CLIENT_SSL, MYSQL_DEFAULT_CAPABILITIES, + mysqlAckSessionSetup, mysqlErrPacket, mysqlHandshakeV10, + mysqlOkPacket, + mysqlRawPacket, + mysqlReadPackets, neverAnsweringServer, pgAuthenticationOk, + pgCommandComplete, + pgDataRow, pgErrorResponse, + pgReadFrontendMessages, pgReadyForQuery, + pgRowDescription, pgSSLResponse, } from "./wire-frames"; @@ -232,6 +240,66 @@ for (const [name, scheme, closedCode, timeoutCode] of drivers) { }); } +// A FIN in the middle of the TLS handshake used to come back as a failed handshake with no reason, +// and debug builds assert that an Error has a message. +test("giving up on a connection in the middle of its TLS handshake", async () => { + const rows = drivers.map(([name, scheme]) => [ + scheme, + startTls[name].greeting?.toString("hex") ?? "", + startTls[name].requestLength, + startTls[name].answer?.toString("hex") ?? "", + ]); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const net = require("node:net"); + async function giveUp([scheme, greeting, requestLength, answer], connectionTimeout) { + const clientHello = Promise.withResolvers(); + const server = net.createServer(socket => { + socket.unref(); + socket.write(Buffer.from(greeting, "hex")); + let received = 0; + socket.on("data", chunk => { + if (received < requestLength && received + chunk.length >= requestLength) socket.write(Buffer.from(answer, "hex")); + received += chunk.length; + // The server never speaks TLS. + if (received > requestLength) clientHello.resolve(); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + server.unref(); + const sql = new Bun.SQL({ + url: scheme + "127.0.0.1:" + server.address().port + "/db", + max: 1, + tls: { rejectUnauthorized: false }, + connectionTimeout, + }); + const query = sql\`SELECT 1\`.catch(err => err.code); + if (!connectionTimeout) { + await clientHello.promise; + await sql.close({ timeout: "0" }); + } + return query; + } + const rows = ${JSON.stringify(rows)}; + console.log((await Promise.all(rows.flatMap(row => [giveUp(row, 1), giveUp(row, 0)]))).join("\\n")); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim().split("\n"), stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: drivers.flatMap(([, , closedCode, timeoutCode]) => [timeoutCode, closedCode]), + stderr: "", + exitCode: 0, + signalCode: null, + }); +}); + // https://github.com/oven-sh/bun/issues/39940 // // The per-slot "fired onconnect" marker is per connect cycle. A slot that @@ -326,3 +394,330 @@ test("pool scans tolerate unassigned connection slots during pool start", async server.close(); } }); + +// Closing a TLS connection does not depend on the peer: not on one that never answers the +// close_notify, nor on one that answers late. Bun.listen peers, because a node:tls one always +// ends its own side when the close_notify arrives. +type TlsPeer = { + port: number; + // TLS connections accepted so far + connections: number; + // the first close_notify from a client, and the first connection this side closed + ended: Promise; + closed: Promise; + [Symbol.dispose](): void; +}; + +// the plaintext socket's state: the SSL request bytes so far, then "upgraded" +type RawData = Buffer | "upgraded" | undefined; + +type PeerConnection = { + data(socket: Socket, chunk: Buffer): void; + // what the peer does with the client's close_notify + end(socket: Socket): void; +}; + +// Stays silent. The probe write only fails, and so only closes this side, once +// the client's fd is gone: the kernel answers it with a reset. +const holdOpen = (socket: Socket) => void socket.write("probe"); +// What a server does: it closes its side too. +const closeBack = (socket: Socket) => void socket.end(); + +function tlsPeer( + greeting: Buffer | null, + // the length of the client's plaintext SSL request, once enough of it has arrived to tell + sslRequestLength: (buffered: Buffer) => number | undefined, + // answers the complete plaintext SSL request + onSslRequest: (raw: Socket) => void, + // called once for each connection + connect: () => PeerConnection, +): TlsPeer { + const ended = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + allowHalfOpen: true, + socket: { + open(raw) { + if (greeting) raw.write(greeting); + }, + data(raw, chunk) { + // the raw socket keeps observing bytes after the upgrade + if (raw.data === "upgraded") return; + const buffered = raw.data ? Buffer.concat([raw.data, chunk]) : chunk; + const length = sslRequestLength(buffered); + if (length === undefined || buffered.length < length) { + raw.data = buffered; + return; + } + raw.data = "upgraded"; + onSslRequest(raw); + peer.connections++; + const connection = connect(); + raw.upgradeTLS({ + isServer: true, + initialData: buffered.subarray(length), + tls: { key: tlsCert.key, cert: tlsCert.cert }, + socket: { + handshake() {}, + data: connection.data, + end(socket) { + ended.resolve(); + connection.end(socket); + }, + close() { + closed.resolve(); + }, + error() {}, + }, + }); + }, + close() {}, + error() {}, + }, + }); + const peer: TlsPeer = { + port: listener.port, + connections: 0, + ended: ended.promise, + closed: closed.promise, + [Symbol.dispose]() { + listener.stop(true); + }, + }; + return peer; +} + +const postgresTls = (connect: () => PeerConnection) => + // the 8-byte SSLRequest; the client sends nothing else until it sees 'S' + tlsPeer( + null, + () => 8, + raw => raw.write(pgSSLResponse("S")), + connect, + ); + +// `startupReply` answers the StartupMessage +function postgresTlsPeer( + end: PeerConnection["end"], + startupReply: Buffer = Buffer.concat([pgAuthenticationOk(), pgReadyForQuery("I")]), +): TlsPeer { + return postgresTls(() => { + let started = false; + return { + data(socket) { + if (started) return; + started = true; + socket.write(startupReply); + }, + end, + }; + }); +} + +// `authReply` answers the HandshakeResponse that carries sequence id `seq` +function mysqlTlsPeer( + end: PeerConnection["end"], + authReply: (seq: number) => Buffer = seq => mysqlOkPacket(seq + 1), +): TlsPeer { + return tlsPeer( + mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_SSL }), + // the SSLRequest packet: a 3-byte payload length, a sequence id, the payload + buffered => (buffered.length >= 4 ? 4 + (buffered[0] | (buffered[1] << 8) | (buffered[2] << 16)) : undefined), + () => {}, + () => { + let buffered: Buffer = Buffer.alloc(0); + let authed = false; + return { + data(socket, chunk) { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { + if (!authed) { + authed = true; + socket.write(authReply(seq)); + return; + } + mysqlAckSessionSetup(socket, payload); + }); + }, + end, + }; + }, + ); +} + +const postgresTlsUrl = (port: number) => `postgres://u:p@127.0.0.1:${port}/db?sslmode=require`; +const mysqlTlsUrl = (port: number) => `mysql://root:pw@127.0.0.1:${port}/db`; + +const tlsDrivers = [ + ["postgres", postgresTlsPeer, postgresTlsUrl], + ["mysql", mysqlTlsPeer, mysqlTlsUrl], +] as const; + +for (const [name, peer, url] of tlsDrivers) { + test.concurrent(`${name}: close() settles against a TLS peer that never answers close_notify`, async () => { + using server = peer(holdOpen); + const sql = new SQL({ url: url(server.port), max: 1, tls: { rejectUnauthorized: false } }); + await sql.connect(); + await sql.close({ timeout: 0 }); + await server.ended; + await server.closed; + }); + + test.concurrent( + `${name}: idleTimeout eviction settles against a TLS peer that never answers close_notify`, + async () => { + using server = peer(holdOpen); + const closed = Promise.withResolvers(); + await using sql = new SQL({ + url: url(server.port), + max: 1, + idleTimeout: 0.05, + tls: { rejectUnauthorized: false }, + onclose: () => closed.resolve(), + }); + await sql.connect(); + await closed.promise; + await server.ended; + await server.closed; + }, + ); + + // Until the server's answer to the close_notify arrived, the next reserve() waited for the + // slot of the closed reservation and was rejected with its close. + test.concurrent(`${name}: reserve() after close() of a reserved TLS connection gets a new connection`, async () => { + using server = peer(closeBack); + await using sql = new SQL({ url: url(server.port), max: 1, tls: { rejectUnauthorized: false } }); + const first = await sql.reserve(); + await first.close(); + const second = await sql.reserve(); + await second.release(); + expect(server.connections).toBe(2); + }); + + // The peer has stopped reading and the kernel takes only part of a query, so the rest is + // ciphertext that waits for the socket to become writable. The close does not wait with it. + // Not concurrent: one socket of the event loop at a time holds such a remainder. + test.each([ + // The kernel answers a write to a closed socket with a reset. An open one waits for the rest of the TLS record. + ["is gone before the peer reads on", (client: net.Socket) => void client.write("\x17"), "ECONNRESET"], + ["leaves the peer what the kernel took, then a FIN", (client: net.Socket) => void client.resume(), "end"], + ])(`${name}: a TLS connection closed with a query half sent %s`, async (_, act, expected) => { + using server = peer(closeBack); + const accepted = Promise.withResolvers(); + const proxy = await listeningServer(client => { + const upstream = net.connect(server.port, "127.0.0.1"); + upstream.on("error", () => {}); + client.pipe(upstream).pipe(client); + accepted.resolve(client); + }); + try { + const sql = new SQL({ url: url(proxy.port), max: 1, tls: { rejectUnauthorized: false } }); + await sql.connect(); + const client = await accepted.promise; + client.unpipe(); + client.pause(); + const outcome = Promise.withResolvers(); + let received = 0; + client.on("data", chunk => (received += chunk.length)); + client.pause(); + client.on("error", (error: NodeJS.ErrnoException) => outcome.resolve(error.code!)); + client.on("end", () => outcome.resolve("end")); + + const query = sql.unsafe(`select '${Buffer.alloc(12 << 20, "x")}'`).simple(); + query.catch(() => {}); + // The query is written at the end of this turn of the event loop. + await new Promise(setImmediate); + await sql.close({ timeout: "0" }); + act(client); + expect(await outcome.promise).toBe(expected); + if (expected === "end") expect(received).toBeGreaterThan(64 * 1024); + } finally { + proxy.server.close(); + } + }); +} + +// A ReadyForQuery before any Authentication message, resp. an auth reply whose header byte no +// auth packet uses: the client fails the connection from inside the TLS data dispatch. +const protocolViolations = [ + [ + "postgres", + () => postgresTlsPeer(holdOpen, pgReadyForQuery("I")), + postgresTlsUrl, + "ERR_POSTGRES_UNEXPECTED_MESSAGE", + ], + [ + "mysql", + () => mysqlTlsPeer(holdOpen, seq => mysqlRawPacket(seq + 1, Buffer.from([0x42]))), + mysqlTlsUrl, + "ERR_MYSQL_UNEXPECTED_PACKET", + ], +] as const; + +for (const [name, peer, url, code] of protocolViolations) { + test.concurrent( + `${name}: a protocol violation closes the socket against a TLS peer that never answers close_notify`, + async () => { + using server = peer(); + await using sql = new SQL({ url: url(server.port), max: 1, tls: { rejectUnauthorized: false } }); + const settled = await sql.connect().then( + () => "connected", + e => e.code, + ); + expect(settled).toBe(code); + await server.ended; + await server.closed; + }, + ); +} + +// A backend that runs a query answers it first and the client's close_notify after it. The next +// caller used to wait for that, and was rejected with the error the late answer raised. +test.concurrent("postgres: a query after close() of a busy reserved TLS connection gets a new connection", async () => { + const running = Promise.withResolvers(); + const row = Buffer.concat([ + pgRowDescription([{ name: "x", typeOid: 25 }]), + pgDataRow([Buffer.from("1")]), + pgCommandComplete("SELECT 1"), + pgReadyForQuery("I"), + ]); + using server = postgresTls(() => { + let started = false; + let buffered: Buffer = Buffer.alloc(0); + let busy = false; + return { + data(socket, chunk) { + if (!started) { + started = true; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery("I")])); + return; + } + buffered = pgReadFrontendMessages(Buffer.concat([buffered, chunk]), (type, body) => { + if (type !== 0x51 /* Query */) return; + if (body.includes("pg_sleep")) { + busy = true; + running.resolve(); + } else { + socket.write(row); + } + }); + }, + end(socket) { + if (busy) socket.write(row); + socket.end(); + }, + }; + }); + await using sql = new SQL({ url: postgresTlsUrl(server.port), max: 1, tls: { rejectUnauthorized: false } }); + const reserved = await sql.reserve(); + const slow = reserved`select pg_sleep(5)`.simple().then( + () => "resolved", + e => e.code, + ); + await running.promise; + await reserved.close(); + expect(await sql`select 1 as x`.simple()).toEqual([{ x: "1" }]); + expect(await slow).toBe("ERR_POSTGRES_CONNECTION_CLOSED"); + expect(server.connections).toBe(2); +}); diff --git a/test/js/sql/tls-sql.test.ts b/test/js/sql/tls-sql.test.ts index 12aa2ad8dd4b..23a19a41a268 100644 --- a/test/js/sql/tls-sql.test.ts +++ b/test/js/sql/tls-sql.test.ts @@ -6,6 +6,12 @@ import path from "node:path"; import tls from "node:tls"; import { listeningServer, + MYSQL_CLIENT_SSL, + MYSQL_DEFAULT_CAPABILITIES, + mysqlAckSessionSetup, + mysqlHandshakeV10, + mysqlOkPacket, + mysqlReadPackets, pgAuthenticationCleartextPassword, pgAuthenticationOk, pgCommandComplete, @@ -594,49 +600,70 @@ test("postgres sslmode=prefer takes the 'N', and nothing that comes with it or i // Reads the client's TLS records off the wire, which a container cannot show. A PostgreSQL server // logs "could not receive data from client" for a TLS connection that ends without a close_notify. -test.each(["idleTimeout", "maxLifetime"])( - "postgres sends a close_notify when %s closes a TLS connection", - async option => { - // TLS 1.2 leaves a record's type in the clear. - const ALERT = 21; - const terminator = tls.createServer({ ...tlsCert, maxVersion: "TLSv1.2" }, socket => { - socket.on("error", () => {}); +test.each([ + ["postgres", "idleTimeout"], + ["postgres", "maxLifetime"], + ["postgres", "close"], + ["mysql", "idleTimeout"], + ["mysql", "close"], +] as const)("%s sends a close_notify when %s closes a TLS connection", async (adapter, option) => { + // TLS 1.2 leaves a record's type in the clear. + const ALERT = 21; + const terminator = tls.createServer({ ...tlsCert, maxVersion: "TLSv1.2" }, socket => { + socket.on("error", () => {}); + if (adapter === "postgres") { socket.once("data", () => socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()]))); + return; + } + let buffered = Buffer.alloc(0); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { + if (!mysqlAckSessionSetup(socket, payload)) socket.write(mysqlOkPacket(seq + 1)); + }); }); - await new Promise(resolve => terminator.listen(0, "127.0.0.1", resolve)); - - let records = Buffer.alloc(0); - const closed = Promise.withResolvers(); - const { port, server } = await listeningServer(client => { - client.on("error", () => {}); - client.on("close", () => closed.resolve()); - client.once("data", () => { - client.write(pgSSLResponse("S")); - const upstream = net.connect((terminator.address() as net.AddressInfo).port, "127.0.0.1"); - upstream.on("error", () => {}); - client.on("data", (chunk: Buffer) => { - records = Buffer.concat([records, chunk]); - }); - client.pipe(upstream).pipe(client); + }); + await new Promise(resolve => terminator.listen(0, "127.0.0.1", resolve)); + + // The client's plaintext request for TLS: the SSLRequest message, resp. packet. + const requestLength = adapter === "postgres" ? 8 : 36; + let records = Buffer.alloc(0); + const closed = Promise.withResolvers(); + const { port, server } = await listeningServer(client => { + client.on("error", () => {}); + client.on("close", () => closed.resolve()); + if (adapter === "mysql") { + client.write(mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_SSL })); + } + client.once("data", request => { + if (adapter === "postgres") client.write(pgSSLResponse("S")); + const upstream = net.connect((terminator.address() as net.AddressInfo).port, "127.0.0.1"); + upstream.on("error", () => {}); + records = request.subarray(requestLength); + upstream.write(records); + client.on("data", chunk => { + records = Buffer.concat([records, chunk]); }); + client.pipe(upstream).pipe(client); }); + }); - try { - await using sql = new SQL({ - url: `postgres://postgres@127.0.0.1:${port}/bun_sql_test`, - max: 1, - tls: { rejectUnauthorized: false }, - [option]: 0.05, - }); - await sql.connect(); - await closed.promise; + try { + await using sql = new SQL({ + url: `${adapter}://postgres@127.0.0.1:${port}/bun_sql_test`, + max: 1, + tls: { rejectUnauthorized: false }, + // The lifetime starts before the handshake, and has to outlast it. + ...(option !== "close" && { [option]: option === "maxLifetime" ? 0.5 : 0.05 }), + }); + await sql.connect(); + if (option === "close") await sql.close(); + await closed.promise; - const types: number[] = []; - for (let i = 0; i + 5 <= records.length; i += 5 + records.readUInt16BE(i + 3)) types.push(records[i]); - expect(types.at(-1)).toBe(ALERT); - } finally { - server.close(); - terminator.close(); - } - }, -); + const types: number[] = []; + for (let i = 0; i + 5 <= records.length; i += 5 + records.readUInt16BE(i + 3)) types.push(records[i]); + expect(types.at(-1)).toBe(ALERT); + } finally { + server.close(); + terminator.close(); + } +}); From fe93b45f20568da717b72a5439a009d5c2f6f85d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:30:42 +0000 Subject: [PATCH 039/174] sql: a setting that only asks for TLS never lowers a stated verify-ca/verify-full, and always sends SNI (#44498) parseOptions lost a request to verify the server in two places. PGSSLMODE was read only while the mode was still disable, and the name of a TLS_* URL variable had already made it require. `options.tls || options.ssl` dropped `ssl: "verify-full"` next to `tls: true`, an object or a weaker mode. Both now keep the stricter mode. Which place wins when two name a mode is unchanged: options over the URL over the environment. `tls: true`, `tls: {}` and `tls: { rejectUnauthorized: false }` sent no SNI: they were promoted to require after the step that fills in serverName, which skips a mode of disable. The promotion now comes first. The object handed to the native constructor has no prototype, so an inherited rejectUnauthorized or serverName is not read. --- docs/runtime/sql.mdx | 6 +- src/js/internal/sql/shared.ts | 44 ++-- .../js/sql/adapter-env-var-precedence.test.ts | 222 +++++++++++++++++- test/js/sql/sql-tls-ip-literal-host.test.ts | 111 ++++++++- 4 files changed, 359 insertions(+), 24 deletions(-) diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index 7335d527fbdb..a41caea500e5 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -588,6 +588,10 @@ Without a connection URL, Bun checks these individual parameters: | `PGDATABASE` | - | username | Database name | | `PGSSLMODE` | - | `disable` | SSL mode (`disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full`) | +`PGSSLMODE` also applies next to a connection URL. A URL from a `TLS_*` variable uses at least `require`; `PGSSLMODE=verify-ca` and `PGSSLMODE=verify-full` raise that. + +An `sslmode` in the URL takes priority over both, and a mode in the `tls` or `ssl` option takes priority over the URL. `tls: true` and a `tls` object ask for at least `require` and never lower a stricter mode. + ### SQLite Environment Variables You can configure SQLite connections with `DATABASE_URL` when it contains a SQLite-compatible URL: @@ -926,7 +930,7 @@ const sql = new SQL({ | SSL Mode | Description | | ------------- | -------------------------------------------------------------------------------------------------------------------- | | `disable` | No SSL/TLS used. Connections fail if server requires SSL. Default mode if none specified. | -| `prefer` | Tries SSL first, falls back to non-SSL if SSL fails. | +| `prefer` | Tries SSL first, falls back to non-SSL if the server does not support it. | | `require` | Requires SSL without certificate verification. Fails if SSL cannot be established. | | `verify-ca` | Verifies server certificate is signed by trusted CA. Fails if verification fails. | | `verify-full` | Most secure mode. Verifies certificate and hostname match. Protects against untrusted certificates and MITM attacks. | diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index f73adbf19ad5..c8871e36915d 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -121,7 +121,7 @@ const enum SSLMode { } export type { SSLMode }; -function normalizeSSLMode(value: string): SSLMode { +function normalizeSSLMode(value: string | undefined): SSLMode { if (!value) { return SSLMode.disable; } @@ -1861,10 +1861,11 @@ function parseOptions( // The rest of this function is logic specific to postgres/mysql/mariadb (they have the same options object) let sslMode: SSLMode = sslModeFromConnectionDetails || SSLMode.disable; - if (sslMode === SSLMode.disable) { + if (adapter === "postgres") { // libpq honours PGSSLMODE as the default; a URL ?sslmode= below overrides it. - const envSslMode = adapter === "postgres" ? env.PG_SSLMODE || env.PGSSLMODE : undefined; - if (envSslMode) sslMode = normalizeSSLMode(envSslMode); + const envSslMode = normalizeSSLMode(env.PG_SSLMODE || env.PGSSLMODE); + // The name of a TLS_* URL variable asks for at least `require`. + if (envSslMode > sslMode) sslMode = envSslMode; } let url = _url; @@ -2072,7 +2073,6 @@ function parseOptions( } else { tls = tlsOption || tls; } - const explicitTls = tls; max = options.max; idleTimeout ??= options.idleTimeout; @@ -2151,26 +2151,32 @@ function parseOptions( } } - if ($isObject(tls) && sslMode < SSLMode.verify_ca) { - if (tls.rejectUnauthorized === true || (tls.rejectUnauthorized !== false && (tls.ca || tls.caFile))) { + // Explicit tls/ssl options request an encrypted connection: if the server + // declines TLS, the connection is aborted instead of continuing in plaintext. + // Certificate verification is only enabled when explicitly requested + // (ca, caFile, rejectUnauthorized, or a verify-* sslmode). + if (tls && sslMode <= SSLMode.prefer) { + sslMode = SSLMode.require; + } + + const tlsObject = { __proto__: null, ...(tls as Exclude) }; + if (sslMode < SSLMode.verify_ca) { + const { rejectUnauthorized } = tlsObject; + if (rejectUnauthorized === true || (rejectUnauthorized !== false && (tlsObject.ca || tlsObject.caFile))) { sslMode = SSLMode.verify_full; } } - if (sslMode !== SSLMode.disable && !(tls as Exclude)?.serverName) { - if (hostname) { - tls = { ...(tls as Exclude), serverName: hostname }; - } else if (tls) { - tls = true; - } + // `tls` is read before its alias; a mode the alias names still counts. + if (options.tls && typeof options.ssl === "string") { + const aliasMode = normalizeSSLMode(options.ssl); + if (aliasMode > sslMode) sslMode = aliasMode; } - // Explicit tls/ssl options request an encrypted connection: if the server - // declines TLS, the connection is aborted instead of continuing in plaintext. - // Certificate verification is only enabled when explicitly requested - // (ca, caFile, rejectUnauthorized, or a verify-* sslmode). - if (explicitTls && sslMode <= SSLMode.prefer) { - sslMode = SSLMode.require; + // The native constructor rejects any other `tls`. + if (sslMode !== SSLMode.disable && (tls === undefined || tls === true || $isObject(tls))) { + tlsObject.serverName ||= hostname; + tls = tlsObject; } port = Number(port); diff --git a/test/js/sql/adapter-env-var-precedence.test.ts b/test/js/sql/adapter-env-var-precedence.test.ts index 560b2b9bdb1c..a3933eb61e46 100644 --- a/test/js/sql/adapter-env-var-precedence.test.ts +++ b/test/js/sql/adapter-env-var-precedence.test.ts @@ -424,6 +424,39 @@ describe("SQL adapter environment variable precedence", () => { expect(options.options.sslMode).toBe(2); expect(options.options.tls).toBeTypeOf("object"); }); + + // The name of the variable asks for at least `require`. + describe.each(["TLS_DATABASE_URL", "TLS_POSTGRES_DATABASE_URL"])("next to a URL from %s", urlVariable => { + test.each([ + ["PGSSLMODE", "verify-ca", 3], + ["PG_SSLMODE", "verify-full", 4], + ])("%s=%s selects sslMode %d", (modeVariable, mode, expected) => { + process.env[urlVariable] = "postgres://user@host:5432/db"; + process.env[modeVariable] = mode; + + expect(new SQL().options).toMatchObject({ + adapter: "postgres", + sslMode: expected, + tls: { serverName: "host" }, + }); + }); + + test("PGSSLMODE=verify-ca is kept when the options give a CA", () => { + process.env[urlVariable] = "postgres://user@host:5432/db"; + process.env.PGSSLMODE = "verify-ca"; + + expect(new SQL({ tls: { ca: "x" } }).options.sslMode).toBe(3); + }); + + test("an invalid PGSSLMODE throws", () => { + process.env[urlVariable] = "postgres://user@host:5432/db"; + process.env.PGSSLMODE = "bogus"; + + expect(() => new SQL()).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE", message: expect.stringContaining("sslmode") }), + ); + }); + }); }); describe("TLS settings from the connection URL query string", () => { @@ -493,7 +526,7 @@ describe("SQL adapter environment variable precedence", () => { test("an explicit tls option takes priority over ?ssl=false", () => { const options = new SQL("postgres://u@h:5432/db?ssl=false", { tls: true }); expect(options.options.sslMode).toBe(2); - expect(options.options.tls).toBe(true); + expect(options.options.tls).toEqual({ serverName: "h" }); }); test.each(["mysql://u:p@h/db?ssl=bogus", 'mysql://u:p@h/db?ssl={"rejectUnauthorized":true}'])( @@ -594,6 +627,193 @@ describe("SQL adapter environment variable precedence", () => { }); }); + describe("the tls option object", () => { + test.each([true, {}, { rejectUnauthorized: false }])("tls: %p names the host, like tls: 'require'", tls => { + for (const adapter of ["postgres", "mysql"] as const) { + const { options } = new SQL({ adapter, hostname: "h", tls }); + expect(options.sslMode).toBe(2); + expect(options.tls).toEqual({ ...(tls as object), serverName: "h" }); + } + }); + + test.each([ + ["rejectUnauthorized", false], + ["serverName", "other.example"], + ])("an inherited %s is not read", (key, value) => { + Object.defineProperty(Object.prototype, key, { value, configurable: true, enumerable: true }); + try { + const { options } = new SQL({ adapter: "postgres", hostname: "h", tls: { ca: "x" } }); + expect(options.sslMode).toBe(4); + expect({ ...(options.tls as object) }).toEqual({ ca: "x", serverName: "h" }); + expect(Object.getPrototypeOf(options.tls)).toBeNull(); + } finally { + delete Object.prototype[key]; + } + }); + }); + + describe("the SSL mode stated in two places", () => { + const MODES = ["disable", "allow", "prefer", "require", "verify-ca", "verify-full"] as const; + const RESOLVED = ["disable", "prefer", "require", "verify-ca", "verify-full"] as const; + type Mode = (typeof MODES)[number]; + type Setting = { + label: string; + // The mode the setting names. Without one it only asks for TLS. + mode?: Mode; + env?: Record; + urlVariable?: string; + query?: string; + options?: SQL.Options; + }; + const option = (key: "ssl" | "tls"): Setting[] => [ + ...MODES.map(mode => ({ label: mode, mode, options: { [key]: mode } })), + { label: "true", options: { [key]: true } }, + { label: "{}", options: { [key]: {} } }, + ]; + // From the lowest priority to the highest. + const SOURCES: [string, Setting[]][] = [ + ["PGSSLMODE", MODES.map(mode => ({ label: mode, mode, env: { PGSSLMODE: mode } }))], + ["URL variable", [{ label: "TLS_DATABASE_URL", urlVariable: "TLS_DATABASE_URL" }]], + [ + "URL query", + [ + ...MODES.map(mode => ({ label: mode, mode, query: `?sslmode=${mode}` })), + { label: "ssl=true", query: "?ssl=true" }, + ], + ], + ["ssl", option("ssl")], + ["tls", option("tls")], + ]; + + function resolve(scheme: "postgres" | "mysql", ...settings: Setting[]) { + const { env, urlVariable = "DATABASE_URL", query = "" } = Object.assign({}, ...settings) as Setting; + const options = Object.assign({}, ...settings.map(setting => setting.options)); + Object.assign(process.env, env, { [urlVariable]: `${scheme}://u@h/db${query}` }); + try { + return RESOLVED[new SQL(options).options.sslMode!]; + } finally { + for (const key of [...Object.keys(env ?? {}), urlVariable]) delete process.env[key]; + } + } + + function table(scheme: "postgres" | "mysql", sources = SOURCES) { + let out = ""; + sources.forEach(([rowSource, rows], i) => { + for (const [columnSource, columns] of sources.slice(i + 1)) { + out += + `\n${`${rowSource} \\ ${columnSource}`.padEnd(26)}${columns.map(c => c.label.padEnd(12)).join("")}`.trimEnd(); + for (const row of rows) { + out += + `\n${row.label.padEnd(26)}${columns.map(c => resolve(scheme, row, c).padEnd(12)).join("")}`.trimEnd(); + } + out += "\n"; + } + }); + return out; + } + + test("every pair of places", () => { + expect(table("postgres")).toMatchInlineSnapshot(` + " + PGSSLMODE \\ URL variable TLS_DATABASE_URL + disable require + allow require + prefer require + require require + verify-ca verify-ca + verify-full verify-full + + PGSSLMODE \\ URL query disable allow prefer require verify-ca verify-full ssl=true + disable disable prefer prefer require verify-ca verify-full require + allow disable prefer prefer require verify-ca verify-full require + prefer disable prefer prefer require verify-ca verify-full require + require disable prefer prefer require verify-ca verify-full require + verify-ca disable prefer prefer require verify-ca verify-full verify-ca + verify-full disable prefer prefer require verify-ca verify-full verify-full + + PGSSLMODE \\ ssl disable allow prefer require verify-ca verify-full true {} + disable disable prefer prefer require verify-ca verify-full require require + allow disable prefer prefer require verify-ca verify-full require require + prefer disable prefer prefer require verify-ca verify-full require require + require disable prefer prefer require verify-ca verify-full require require + verify-ca disable prefer prefer require verify-ca verify-full verify-ca verify-ca + verify-full disable prefer prefer require verify-ca verify-full verify-full verify-full + + PGSSLMODE \\ tls disable allow prefer require verify-ca verify-full true {} + disable disable prefer prefer require verify-ca verify-full require require + allow disable prefer prefer require verify-ca verify-full require require + prefer disable prefer prefer require verify-ca verify-full require require + require disable prefer prefer require verify-ca verify-full require require + verify-ca disable prefer prefer require verify-ca verify-full verify-ca verify-ca + verify-full disable prefer prefer require verify-ca verify-full verify-full verify-full + + URL variable \\ URL query disable allow prefer require verify-ca verify-full ssl=true + TLS_DATABASE_URL disable prefer prefer require verify-ca verify-full require + + URL variable \\ ssl disable allow prefer require verify-ca verify-full true {} + TLS_DATABASE_URL disable prefer prefer require verify-ca verify-full require require + + URL variable \\ tls disable allow prefer require verify-ca verify-full true {} + TLS_DATABASE_URL disable prefer prefer require verify-ca verify-full require require + + URL query \\ ssl disable allow prefer require verify-ca verify-full true {} + disable disable prefer prefer require verify-ca verify-full require require + allow disable prefer prefer require verify-ca verify-full require require + prefer disable prefer prefer require verify-ca verify-full require require + require disable prefer prefer require verify-ca verify-full require require + verify-ca disable prefer prefer require verify-ca verify-full verify-ca verify-ca + verify-full disable prefer prefer require verify-ca verify-full verify-full verify-full + ssl=true disable prefer prefer require verify-ca verify-full require require + + URL query \\ tls disable allow prefer require verify-ca verify-full true {} + disable disable prefer prefer require verify-ca verify-full require require + allow disable prefer prefer require verify-ca verify-full require require + prefer disable prefer prefer require verify-ca verify-full require require + require disable prefer prefer require verify-ca verify-full require require + verify-ca disable prefer prefer require verify-ca verify-full verify-ca verify-ca + verify-full disable prefer prefer require verify-ca verify-full verify-full verify-full + ssl=true disable prefer prefer require verify-ca verify-full require require + + ssl \\ tls disable allow prefer require verify-ca verify-full true {} + disable disable prefer prefer require verify-ca verify-full require require + allow prefer prefer prefer require verify-ca verify-full require require + prefer prefer prefer prefer require verify-ca verify-full require require + require require require require require verify-ca verify-full require require + verify-ca verify-ca verify-ca verify-ca verify-ca verify-ca verify-full verify-ca verify-ca + verify-full verify-full verify-full verify-full verify-full verify-full verify-full verify-full verify-full + true disable prefer prefer require verify-ca verify-full require require + {} disable prefer prefer require verify-ca verify-full require require + " + `); + + // MySQL resolves the same modes, and does not read PGSSLMODE. + const [[, pgsslmode], ...others] = SOURCES; + expect(table("mysql", others)).toBe(table("postgres", others)); + for (const [, settings] of others) { + for (const setting of settings) { + for (const env of pgsslmode) expect(resolve("mysql", setting, env)).toBe(resolve("mysql", setting)); + } + } + }); + + test("a setting that only asks for TLS never lowers the mode another one names", () => { + const lowered: string[] = []; + for (const [weakSource, weakSettings] of SOURCES) { + for (const weak of weakSettings.filter(setting => !setting.mode)) { + for (const [source, settings] of SOURCES.filter(([source]) => source !== weakSource)) { + for (const named of settings.filter(setting => setting.mode)) { + const resolved = resolve("postgres", weak, named); + if (RESOLVED.indexOf(resolved) < RESOLVED.indexOf(resolve("postgres", named))) { + lowered.push(`${source} ${named.label} + ${weakSource} ${weak.label} = ${resolved}`); + } + } + } + } + } + expect(lowered).toEqual([]); + }); + }); + describe("Adapter-Protocol Validation", () => { test("should work with explicit adapter and URL without protocol", () => { const options = new SQL("user:pass@host:3306/db", { adapter: "mysql" }); diff --git a/test/js/sql/sql-tls-ip-literal-host.test.ts b/test/js/sql/sql-tls-ip-literal-host.test.ts index db8c9127c020..54e4117f4d29 100644 --- a/test/js/sql/sql-tls-ip-literal-host.test.ts +++ b/test/js/sql/sql-tls-ip-literal-host.test.ts @@ -32,6 +32,8 @@ type MockServer = { port: number; /** SNI of every completed TLS handshake, in order; `false` when the client sent none. */ servernames: (string | false)[]; + /** How many connections sent their login over TLS. */ + logins: number; close(): void; }; @@ -60,10 +62,14 @@ async function postgresServer(host: string): Promise { // until it has the one-byte answer. rawSocket.write(pgSSLResponse("S")); const socket = upgrade(rawSocket, chunk.subarray(8), servernames); - socket.once("data", () => socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()]))); + socket.once("data", () => { + mock.logins++; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + }); }); }, host); - return { port, servernames, close: () => server.close() }; + const mock = { port, servernames, logins: 0, close: () => server.close() }; + return mock; } /** Advertises CLIENT_SSL, upgrades after the SSLRequest packet, then accepts the login. */ @@ -88,6 +94,7 @@ async function mysqlServer(host: string): Promise { buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { if (!authed) { authed = true; + mock.logins++; socket.write(mysqlOkPacket(seq + 1)); return; } @@ -97,7 +104,8 @@ async function mysqlServer(host: string): Promise { }; rawSocket.on("data", onPlainData); }, host); - return { port, servernames, close: () => server.close() }; + const mock = { port, servernames, logins: 0, close: () => server.close() }; + return mock; } /** "CONNECTED", or the error that `connect()` rejected with. */ @@ -161,3 +169,100 @@ describe.concurrent.each([ }); }); }); + +describe.each([ + ["PostgreSQL", "postgres", postgresServer, "TLS_POSTGRES_DATABASE_URL"], + ["MySQL", "mysql", mysqlServer, "TLS_MYSQL_DATABASE_URL"], +] as const)("%s TLS by every way of asking for it", (_, scheme, startServer, urlVariable) => { + type Row = [query: string, options: SQL.Options, env?: Record]; + + /** Dials a new server as "localhost". With `env`, the URL is in `urlVariable` and not in the options. */ + async function dial([query, options, env]: Row) { + const server = await startServer("127.0.0.1"); + const url = `${scheme}://u@localhost:${server.port}/db${query}`; + const variables = { ...env, ...(env && { [urlVariable]: url }) }; + // The constructor reads the environment. + Object.assign(process.env, variables); + let sql: SQL; + try { + sql = new SQL({ ...(!env && { url }), max: 1, ...options }); + } finally { + for (const key in variables) delete process.env[key]; + } + try { + const outcome = await sql.connect().then( + () => "CONNECTED", + e => e.code, + ); + return { outcome, servernames: server.servernames, logins: server.logins }; + } finally { + await sql.close({ timeout: 0 }); + server.close(); + } + } + const dialAll = async (rows: Row[]) => + Object.fromEntries(await Promise.all(rows.map(async row => [JSON.stringify(row), await dial(row)]))); + const expectAll = (rows: Row[], outcome: object) => + Object.fromEntries(rows.map(row => [JSON.stringify(row), outcome])); + + test("the host name is sent as SNI", async () => { + const rows: Row[] = [ + ["", { tls: true }], + ["", { tls: {} }], + ["", { tls: { rejectUnauthorized: false } }], + ["", { ssl: true }], + ["", { tls: "require" }], + ["?sslmode=require", {}], + ["?ssl=true", {}], + ["", {}, {}], + ]; + expect(await dialAll(rows)).toEqual( + expectAll(rows, { outcome: "CONNECTED", servernames: ["localhost"], logins: 1 }), + ); + }); + + // The certificate is self-signed, and these give no `ca`. + test("verify-ca and verify-full refuse an untrusted certificate, wherever they are stated", async () => { + const rows = (["verify-ca", "verify-full"] as const).flatMap((mode): Row[] => [ + [`?sslmode=${mode}`, {}], + [`?sslmode=${mode}`, { tls: true }], + [`?sslmode=${mode}&ssl=true`, {}], + [`?sslmode=${mode}`, {}, {}], + ["", { tls: mode }], + ["", { ssl: mode }], + ["", { ssl: mode }, {}], + ["", { ssl: mode, tls: true }], + ["", { ssl: mode, tls: {} }], + ["", { ssl: mode, tls: "require" }], + ["?sslmode=require", { ssl: mode, tls: true }], + ...(scheme === "postgres" + ? ([ + ["", {}, { PGSSLMODE: mode }], + ["", { tls: true }, { PGSSLMODE: mode }], + ["?ssl=true", {}, { PGSSLMODE: mode }], + ] as Row[]) + : []), + ]); + expect(await dialAll(rows)).toEqual( + expectAll(rows, { outcome: "DEPTH_ZERO_SELF_SIGNED_CERT", servernames: [], logins: 0 }), + ); + }); + + test("what verify-ca and verify-full check once the certificate is trusted", async () => { + const ca = localhostTls.cert; + const connected = (servername: string) => ({ outcome: "CONNECTED", servernames: [servername], logins: 1 }); + expect( + await Promise.all([ + dial(["", { ssl: "verify-full", tls: { ca } }]), + dial(["", { ssl: "verify-full", tls: { ca, serverName: "other.example" } }]), + dial(["?sslmode=verify-ca", { tls: { ca, serverName: "other.example" } }]), + dial(["", { ssl: "verify-full", tls: { rejectUnauthorized: false } }]), + ]), + ).toEqual([ + connected("localhost"), + { outcome: "ERR_TLS_CERT_ALTNAME_INVALID", servernames: [], logins: 0 }, + connected("other.example"), + connected("localhost"), + ]); + }); +}); From d4eaa6351ca4a5b673703bd53aa9fd57be15146a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:36:00 +0000 Subject: [PATCH 040/174] redis, sql: RedisClient sends SNI and honours tls.serverName; Bun.SQL accepts tls.servername (#42054) RedisClient never set an SNI and matched the certificate against the URL host only, so tls.serverName did nothing: a server behind an SNI router saw no name, and a certificate for another name than the one dialed could not be accepted or refused by name. One configured_hostname() (tls.serverName, else the URL host) is now the SNI, unless it is an IP literal, and the name both checks match. It replaces identity_hostname(), whose SSL_get_servername branch could not be taken. Bun.SQL filled in serverName over a tls.servername, the node:tls spelling that the native SSLConfig already accepts. tls.checkServerIdentity, the other half of #42054, is not part of this. --- docs/runtime/redis.mdx | 1 + packages/bun-types/bun.d.ts | 5 + packages/bun-types/redis.d.ts | 3 +- src/boringssl_sys/boringssl.rs | 6 ++ src/js/internal/sql/shared.ts | 2 +- src/runtime/valkey_jsc/js_valkey.rs | 65 ++++++------ src/runtime/valkey_jsc/valkey.rs | 10 ++ test/js/sql/sql-tls-ip-literal-host.test.ts | 6 ++ test/js/valkey/valkey-tls-verify.test.ts | 105 +++++++++++++++++++- 9 files changed, 164 insertions(+), 39 deletions(-) diff --git a/docs/runtime/redis.mdx b/docs/runtime/redis.mdx index 889730de774f..f3b5fb7dc89c 100644 --- a/docs/runtime/redis.mdx +++ b/docs/runtime/redis.mdx @@ -409,6 +409,7 @@ const client = new RedisClient("redis://localhost:6379", { // ca: "path/to/ca.pem", // cert: "path/to/cert.pem", // key: "path/to/key.pem", + // serverName: "redis.example.com", // SNI and the name the certificate must match (default: the URL host) // } }); ``` diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 0389dcd7afc5..074e9bca5565 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4767,6 +4767,11 @@ declare module "bun" { */ serverName?: string; + /** + * Alias of {@link serverName}, the `node:tls` spelling. `serverName` wins if both are set. + */ + servername?: string; + /** * Sets `OPENSSL_RELEASE_BUFFERS` to 1. * Reduces overall performance but saves some memory. diff --git a/packages/bun-types/redis.d.ts b/packages/bun-types/redis.d.ts index 79b394fd2fb2..c6683373bc41 100644 --- a/packages/bun-types/redis.d.ts +++ b/packages/bun-types/redis.d.ts @@ -35,7 +35,8 @@ declare module "bun" { enableOfflineQueue?: boolean; /** - * TLS options + * TLS options. `serverName` sets the SNI and the name the certificate is + * verified against, which default to the URL host. */ tls?: boolean | Bun.TLSOptions; diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index fcf5ac41b303..8dd78f0040d3 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -514,6 +514,12 @@ impl SSL { } } + /// Client side: the SNI host name to send, set before the handshake. `false` if BoringSSL rejects it. + pub fn set_servername(&mut self, hostname: &core::ffi::CStr) -> bool { + // SAFETY: `self` is a live SSL; BoringSSL copies `hostname`. + unsafe { SSL_set_tlsext_host_name(self, hostname.as_ptr()) == 1 } + } + /// The peer's leaf certificate, borrowed from this SSL's cert chain. pub fn peer_leaf_certificate(&mut self) -> Option<&mut X509> { // SAFETY: the chain and its entries are owned by this SSL and outlive diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index c8871e36915d..5e02d51846be 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -2175,7 +2175,7 @@ function parseOptions( // The native constructor rejects any other `tls`. if (sslMode !== SSLMode.disable && (tls === undefined || tls === true || $isObject(tls))) { - tlsObject.serverName ||= hostname; + tlsObject.serverName ||= tlsObject.servername || hostname; tls = tlsObject; } diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index 0959fdd2da80..6e75314b8541 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -1723,6 +1723,14 @@ impl JSValkeyClient { // SocketHandler // ─────────────────────────────────────────────────────────────────────────── +struct UpdatePollRefOnDrop<'a>(&'a JSValkeyClient); + +impl Drop for UpdatePollRefOnDrop<'_> { + fn drop(&mut self) { + self.0.update_poll_ref(); + } +} + /// uWS socket-event handler for the Valkey client (kind = `.valkey[_tls]`). pub(crate) struct SocketHandler; @@ -1748,6 +1756,14 @@ impl SocketHandler { if client.tls.reject_unauthorized(client.vm) { socket.set_inline_reject(); } + // RFC 6066 section 3: an IP literal is not sent as SNI. + let sni = Self::configured_hostname(this); + if !sni.is_empty() + && !bun_core::ip_address::is_ip_address(sni) + && let Some(ssl) = socket.ssl_mut() + { + ssl.set_servername(bun_core::ZBox::from_bytes(sni).as_cstr()); + } } this.client_mut().socket = Self::socket(socket); this.client_mut().on_open(Self::socket(socket)) @@ -1760,37 +1776,20 @@ impl SocketHandler { ) -> boringssl::ServerIdentity { let client = this.client.get(); let rejects = client.tls.reject_unauthorized(client.vm); - let hostname = rejects.then(|| Self::identity_hostname(this, ssl)); - boringssl::server_identity(ssl, hostname.as_deref()) + let hostname = rejects.then(|| Self::configured_hostname(this)); + boringssl::server_identity(ssl, hostname) } - /// The name to match: the SNI servername, else the URL host. Empty for a unix socket, which has none. - fn identity_hostname( - this: &JSValkeyClient, - ssl_ptr: *mut boringssl::c::SSL, - ) -> std::borrow::Cow<'_, [u8]> { - let servername = if ssl_ptr.is_null() { - None - } else { - // SAFETY: a non-null `ssl_ptr` is the socket's live `SSL*`. - unsafe { boringssl::c::SSL_get_servername(ssl_ptr, 0).as_ref() } + /// The SNI and the name to match: `tls.serverName`, else the URL host. Empty for a unix socket with neither. + fn configured_hostname(this: &JSValkeyClient) -> &[u8] { + let client = this.client.get(); + let hostname = match (client.tls.server_name(), &client.address) { + (Some(server_name), _) => server_name, + (None, valkey::Address::Host { host, .. }) => &host[..], + (None, valkey::Address::Unix(_)) => b"", }; // URL.host() keeps the brackets of an IPv6 literal ("[::1]"); without them it matches IP SAN entries. - if let Some(servername) = servername { - // SAFETY: NUL-terminated, owned by the SSL: copied. - let servername = - unsafe { bun_core::ffi::cstr(std::ptr::from_ref(servername).cast()) }.to_bytes(); - bun_core::ip_address::strip_ipv6_brackets(servername) - .to_vec() - .into() - } else { - match &this.client.get().address { - valkey::Address::Host { host, .. } => { - bun_core::ip_address::strip_ipv6_brackets(&host[..]).into() - } - valkey::Address::Unix(_) => (&b""[..]).into(), - } - } + bun_core::ip_address::strip_ipv6_brackets(hostname) } fn fail_handshake_with_altname_error( @@ -1834,7 +1833,7 @@ impl SocketHandler { ); let handshake_success = success == 1; let _guard = this.ref_guard(); - let _update = scopeguard::guard(BackRef::new(this), |p| p.update_poll_ref()); + let _update = UpdatePollRefOnDrop(this); let vm = this.client.get().vm; let ssl_ptr: *mut boringssl::c::SSL = socket.ssl().unwrap_or(core::ptr::null_mut()); if handshake_success { @@ -1847,24 +1846,24 @@ impl SocketHandler { // Certificate chain is valid; verify the hostname matches the // certificate. - let hostname = Self::identity_hostname(this, ssl_ptr); + let hostname = Self::configured_hostname(this); // With no `SSL*` there is no certificate to match: fail closed. let identity_ok = hostname.is_empty() || (!ssl_ptr.is_null() && uws::check_server_identity( // SAFETY: non-null, so the dispatched socket's live `SSL*`. unsafe { &mut *ssl_ptr }, - &hostname, + hostname, )); if !identity_ok { - return Self::fail_handshake_with_altname_error(this, vm, &hostname); + return Self::fail_handshake_with_altname_error(this, vm, hostname); } } this.client_mut().start()?; } else if ssl_error.error_no == uws::us_bun_verify_error_t::HOSTNAME_MISMATCH { // The same check, made inside the handshake (`server_identity`). - let hostname = Self::identity_hostname(this, ssl_ptr); - return Self::fail_handshake_with_altname_error(this, vm, &hostname); + let hostname = Self::configured_hostname(this); + return Self::fail_handshake_with_altname_error(this, vm, hostname); } else { // if we are here is because the server rejected us, and the error_no is the cause of // this no matter if reject_unauthorized is false, because we were disconnected by the diff --git a/src/runtime/valkey_jsc/valkey.rs b/src/runtime/valkey_jsc/valkey.rs index dcea10b1bb6d..1eeea8d7bd94 100644 --- a/src/runtime/valkey_jsc/valkey.rs +++ b/src/runtime/valkey_jsc/valkey.rs @@ -134,6 +134,16 @@ impl TLS { _ => false, } } + + /// `tls.serverName`: replaces the URL host as SNI and as the name the certificate must match. + pub(crate) fn server_name(&self) -> Option<&[u8]> { + match self { + TLS::Custom(ssl_config) => ssl_config + .server_name_bytes() + .filter(|name| !name.is_empty()), + _ => None, + } + } } // Call sites only ever compare against `TLS::None` / `TLS::Enabled`; `SSLConfig` diff --git a/test/js/sql/sql-tls-ip-literal-host.test.ts b/test/js/sql/sql-tls-ip-literal-host.test.ts index 54e4117f4d29..ca56b6df748c 100644 --- a/test/js/sql/sql-tls-ip-literal-host.test.ts +++ b/test/js/sql/sql-tls-ip-literal-host.test.ts @@ -255,14 +255,20 @@ describe.each([ await Promise.all([ dial(["", { ssl: "verify-full", tls: { ca } }]), dial(["", { ssl: "verify-full", tls: { ca, serverName: "other.example" } }]), + dial(["", { ssl: "verify-full", tls: { ca, servername: "other.example" } }]), dial(["?sslmode=verify-ca", { tls: { ca, serverName: "other.example" } }]), + dial(["?sslmode=verify-ca", { tls: { ca, servername: "other.example" } }]), + dial(["?sslmode=verify-ca", { tls: { ca, serverName: "localhost", servername: "other.example" } }]), dial(["", { ssl: "verify-full", tls: { rejectUnauthorized: false } }]), ]), ).toEqual([ connected("localhost"), { outcome: "ERR_TLS_CERT_ALTNAME_INVALID", servernames: [], logins: 0 }, + { outcome: "ERR_TLS_CERT_ALTNAME_INVALID", servernames: [], logins: 0 }, + connected("other.example"), connected("other.example"), connected("localhost"), + connected("localhost"), ]); }); }); diff --git a/test/js/valkey/valkey-tls-verify.test.ts b/test/js/valkey/valkey-tls-verify.test.ts index b363530bb623..a84f846fb50a 100644 --- a/test/js/valkey/valkey-tls-verify.test.ts +++ b/test/js/valkey/valkey-tls-verify.test.ts @@ -56,25 +56,50 @@ function fakeServer(serverOpts: tls.TlsOptions): tls.Server { return server; } -async function withServer(serverOpts: tls.TlsOptions, fn: (port: number) => Promise, host?: string): Promise { +async function withServer( + serverOpts: tls.TlsOptions, + fn: (port: number, server: tls.Server) => Promise, + host?: string, +): Promise { const server = fakeServer(serverOpts); server.listen(0, host); await once(server, "listening"); try { - return await fn((server.address() as AddressInfo).port); + return await fn((server.address() as AddressInfo).port, server); } finally { server.close(); } } -async function withUnixServer(serverOpts: tls.TlsOptions, fn: (socketPath: string) => Promise): Promise { +/** The SNI of every handshake the server completes; `false` when the client sent none. */ +function recordServernames(server: tls.Server): (string | false)[] { + const names: (string | false)[] = []; + server.on("secureConnection", socket => names.push(socket.servername || false)); + return names; +} + +async function ping(url: string, tlsOptions: Bun.RedisOptions["tls"]): Promise { + const client = new RedisClient(url, { autoReconnect: false, connectionTimeout: 5000, tls: tlsOptions }); + try { + return await client.send("PING", []); + } catch (e: any) { + return e.code; + } finally { + client.close(); + } +} + +async function withUnixServer( + serverOpts: tls.TlsOptions, + fn: (socketPath: string, server: tls.Server) => Promise, +): Promise { using dir = tempDir("valkey-tls-unix", {}); const socketPath = path.join(String(dir), "r.sock"); const server = fakeServer(serverOpts); server.listen(socketPath); await once(server, "listening"); try { - return await fn(socketPath); + return await fn(socketPath, server); } finally { server.close(); } @@ -240,3 +265,75 @@ describe("RedisClient TLS hostname verification", () => { }); }); }); + +describe("RedisClient tls.serverName", () => { + const localhost = { key: localhostTls.key, cert: localhostTls.cert }; + + test("the URL host is the SNI, unless it is an IP literal", async () => { + await withServer(localhost, async (port, server) => { + const servernames = recordServernames(server); + expect(await ping(`rediss://localhost:${port}`, { ca: localhostTls.cert })).toBe("PONG"); + expect(await ping(`rediss://127.0.0.1:${port}`, { ca: localhostTls.cert })).toBe("PONG"); + expect(await ping(`rediss://localhost:${port}`, { ca: localhostTls.cert, serverName: "127.0.0.1" })).toBe("PONG"); + expect(servernames).toEqual(["localhost", false, false]); + }); + }); + + test("tls: true sends the URL host as SNI", async () => { + const servernames: string[] = []; + const SNICallback: tls.TlsOptions["SNICallback"] = (servername, callback) => { + servernames.push(servername); + callback(null); + }; + await withServer({ ...localhost, SNICallback }, async port => { + expect(await ping(`rediss://localhost:${port}`, true)).toBe("DEPTH_ZERO_SELF_SIGNED_CERT"); + expect(servernames).toEqual(["localhost"]); + }); + }); + + test("every dial sends the SNI", async () => { + await withServer(localhost, async (port, server) => { + const servernames = recordServernames(server); + const client = new RedisClient(`rediss://localhost:${port}`, { tls: { ca: localhostTls.cert } }); + try { + await client.connect(); + client.close(); + await client.connect(); + } finally { + client.close(); + } + expect(servernames).toEqual(["localhost", "localhost"]); + }); + }); + + // `servername` is the node:tls spelling. The server's certificate is for agent1 only. + test.each(["serverName", "servername"] as const)( + "%s is the SNI and the name the certificate is verified against", + async key => { + await withServer({ key: serverKey, cert: serverCert }, async (port, server) => { + const servernames = recordServernames(server); + expect(await ping(`rediss://127.0.0.1:${port}`, { ca, [key]: "agent1" })).toBe("PONG"); + expect(servernames).toEqual(["agent1"]); + }); + }, + ); + + test("a serverName the certificate is not for is refused, whatever the URL host", async () => { + await withServer(localhost, async port => { + expect(await ping(`rediss://localhost:${port}`, { ca: localhostTls.cert, serverName: "agent1" })).toBe( + "ERR_TLS_CERT_ALTNAME_INVALID", + ); + }); + }); + + test.skipIf(isWindows)("a unix socket has the name serverName gives it", async () => { + await withUnixServer({ key: serverKey, cert: serverCert }, async (socketPath, server) => { + const servernames = recordServernames(server); + expect(await ping(`redis+tls+unix://${socketPath}`, { ca, serverName: "agent1" })).toBe("PONG"); + expect(await ping(`redis+tls+unix://${socketPath}`, { ca, serverName: "agent2" })).toBe( + "ERR_TLS_CERT_ALTNAME_INVALID", + ); + expect(servernames).toEqual(["agent1"]); + }); + }); +}); From a5237033bedd1e80ad8e4b372b27964141f038c7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:08:01 +0000 Subject: [PATCH 041/174] bindgen: a dictionary refuses an array (#41490) Bun.serve takes `tls: [...]`. Given to Bun.listen, Bun.connect, upgradeTLS or fetch, the array landed in the dictionary arm of the union with no member set, parsed as "no TLS options", and the socket was plain TCP with no error. convertDictionary now throws "must be an object" for an array. --- src/codegen/bindgenv2/internal/dictionary.ts | 7 +++++ test/js/bun/net/socket.test.ts | 31 ++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/codegen/bindgenv2/internal/dictionary.ts b/src/codegen/bindgenv2/internal/dictionary.ts index ef0251ecec82..f6bd38c721fa 100644 --- a/src/codegen/bindgenv2/internal/dictionary.ts +++ b/src/codegen/bindgenv2/internal/dictionary.ts @@ -206,6 +206,7 @@ export function dictionary( #include "root.h" #include "Generated${name}.h" #include "Bindgen/IDLConvert.h" + #include #include template<> Bun::Bindgen::Generated::${name} @@ -221,6 +222,12 @@ export function dictionary( ctx.throwNotObject(globalObject, throwScope); return {}; } + bool isArray = JSC::isArray(&globalObject, value); + RETURN_IF_EXCEPTION(throwScope, {}); + if (isArray) [[unlikely]] { + ctx.throwNotObject(globalObject, throwScope); + return {}; + } ::Bun::Bindgen::Generated::${name} result; ${joinIndented( 10, diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 129ef96d0c8d..3f5d443aa34a 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1047,6 +1047,37 @@ it("should throw on empty unix path from truthy non-string value", () => { expect(() => Bun.connect({ unix: [] as any, socket })).toThrow("SocketOptions.unix must be a string"); }); +describe.each([ + ["entries", () => [tls, { serverName: "a.test", ...tls }]], + ["empty", () => []], + ["Proxy around an array", () => new Proxy([tls], {})], +])("tls option is an array (%s)", (_label, makeTls) => { + const socket = { data() {}, open() {}, close() {} }; + const message = "TLSOptions must be an object"; + + it("Bun.listen throws instead of starting a plaintext listener", () => { + expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls: makeTls() as any, socket })).toThrow(message); + }); + + it("Bun.connect throws instead of opening a plaintext connection", () => { + expect(() => Bun.connect({ hostname: "127.0.0.1", port: 1, tls: makeTls() as any, socket })).toThrow(message); + }); + + it("upgradeTLS throws", async () => { + using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket }); + const raw = await Bun.connect({ hostname: "127.0.0.1", port: listener.port, socket }); + try { + expect(() => raw.upgradeTLS({ tls: makeTls() as any, socket })).toThrow(message); + } finally { + raw.terminate(); + } + }); + + it("fetch rejects", async () => { + expect(fetch("https://127.0.0.1:1/", { tls: makeTls() as any })).rejects.toThrow(message); + }); +}); + it("reading .listener on a closed client socket does not use-after-free handlers", async () => { // Client-mode Handlers is heap-allocated per-connect and freed in // markInactive once the socket closes. `socket.listener` read From d5be1aa30f87bb0e1bbfd06b5e861fb79e776377 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:08:03 +0000 Subject: [PATCH 042/174] tls: refuse a NUL in keyFile/certFile/caFile/dhParamsFile and pin the path to the cwd of the call (#37005, #40984) handle_path handed the JS string on as a C string, so "k.pem\0x" loaded k.pem. It also kept a relative path as typed, while the file is opened later and both SSL_CTX caches key on the string: after process.chdir() the same relative caFile reused the context built from the other directory's file. The path is now checked for a NUL, joined on the cached cwd, probed and copied once. On Windows a non-ASCII result keeps the string as typed, since BoringSSL opens it with a narrow fopen. --- src/runtime/socket/SSLConfig.rs | 34 ++++++---- test/js/bun/http/bun-serve-ssl.test.ts | 29 +++++++++ test/js/node/tls/ssl-ctx-cache.test.ts | 90 +++++++++++++++++++++++++- test/js/web/fetch/fetch.tls.test.ts | 40 +++++++++++- 4 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 1132693c7aff..5f4634c4409f 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -303,19 +303,29 @@ fn handle_path( field: &'static str, string: &bun_core::String, ) -> JsResult<*const c_char> { - let name = string.to_owned_slice_z(); - // `bun_sys::access` routes to `access(2)` on POSIX and - // `GetFileAttributesW` on Windows (via `sys_uv`), so this is the - // cross-platform existence probe. - if bun_sys::access(&name, bun_sys::posix::F_OK).is_err() { - // Error path: free_sensitive(name) — zero before drop. Route through - // the canonical helper so the secure-zero core stays single-sourced. - // SAFETY: `zbox_into_raw` yields a `default_alloc::malloc`-backed, - // NUL-terminated buffer whose ownership we now hold exclusively. - unsafe { bun_core::free_sensitive(zbox_into_raw(&name)) }; - return Err(global.throw_invalid_arguments(format_args!("Unable to access {} path", field))); + let name = string.to_utf8(); + let name = name.slice(); + if bun_core::strings::contains_char(name, 0) { + return Err(global + .err( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!("TLSOptions.{field} must be a path without null bytes"), + ) + .throw()); } - Ok(zbox_into_raw(&name)) + // Opened later (for fetch, on the HTTP thread), and the SSL_CTX caches key on this string. + let mut path = bun_paths::AutoAbsPathChecked::init_top_level_dir(); + if name.is_empty() + || path.join(&[name]).is_err() + || bun_sys::access(path.slice_z(), bun_sys::posix::F_OK).is_err() + { + return Err(global.throw_invalid_arguments(format_args!("Unable to access {field} path"))); + } + // BoringSSL's fopen is narrow on Windows: a non-ASCII cwd must not be baked into the path. + if cfg!(windows) && !bun_core::strings::is_all_ascii(path.slice()) { + return Ok(dupe_z(name)); + } + Ok(dupe_z(path.slice())) } fn handle_file_for_field( diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 4e52ca33c9bd..a09227e1a176 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "fs"; import { once } from "node:events"; import net from "node:net"; + +import { tempDir, tls as tlsCert } from "harness"; import tls from "node:tls"; import { join } from "path"; import privateKey from "../../third_party/jsonwebtoken/priv.pem" with { type: "text" }; @@ -543,3 +545,30 @@ test.each(["Bun.serve", "Bun.listen"] as const)( } }, ); + +test("keyFile/certFile/caFile/dhParamsFile reject a path with a NUL byte instead of loading its prefix", () => { + using dir = tempDir("bun-serve-ssl-nul", { "key.pem": tlsCert.key, "cert.pem": tlsCert.cert }); + const keyFile = join(String(dir), "key.pem"); + const certFile = join(String(dir), "cert.pem"); + const nul = "\0-does-not-exist"; + const thrown = (field: string) => + expect.objectContaining({ + code: "ERR_INVALID_ARG_VALUE", + message: `TLSOptions.${field} must be a path without null bytes`, + }); + for (const [field, options] of [ + ["keyFile", { keyFile: keyFile + nul, certFile }], + ["certFile", { keyFile, certFile: certFile + nul }], + ["caFile", { keyFile, certFile, caFile: certFile + nul }], + ["dhParamsFile", { keyFile, certFile, dhParamsFile: certFile + nul }], + ] as const) { + expect(() => Bun.serve({ port: 0, tls: options, fetch: () => new Response("unreachable") })).toThrow(thrown(field)); + expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls: options, socket: { data() {} } })).toThrow( + thrown(field), + ); + expect(() => tls.createSecureContext(options as tls.SecureContextOptions)).toThrow(thrown(field)); + } + expect(() => Bun.serve({ port: 0, tls: { keyFile: "", certFile }, fetch: () => new Response() })).toThrow( + "Unable to access keyFile path", + ); +}); diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index 104682ee7caa..8b8e2f14be02 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -4,12 +4,13 @@ // CTX, not one per connection. The cache holds zero refs — when the last // real owner drops, BoringSSL's ex_data free callback tombstones the entry. import { expect, test } from "bun:test"; +import { X509Certificate } from "node:crypto"; import { once } from "node:events"; import tls from "node:tls"; // debug-only export import { sslCtxLiveCount } from "bun:internal-for-testing"; -import { tempDir, tls as tlsCerts } from "harness"; -import { readFileSync, writeFileSync } from "node:fs"; +import { bunEnv, bunExe, expiredTls, tempDir, tls as tlsCerts } from "harness"; +import { readFileSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; async function withServer(fn: (port: number) => Promise) { @@ -211,6 +212,91 @@ test("file-backed config: in-place rotation invalidates cache (mtime+size in dig }); }); +test("file-backed config: a relative path is keyed on the cwd at the time of the call", async () => { + // Same name, size and mtime in both directories, so only the resolved path tells the files apart in the digest. + const pad = (a: string, b: string) => a.padEnd(Math.max(a.length, b.length), "\n"); + using dir = tempDir("ssl-ctx-cafile-cwd", { + "valid/cert.pem": pad(tlsCerts.cert, expiredTls.cert), + "valid/key.pem": pad(tlsCerts.key, expiredTls.key), + "expired/cert.pem": pad(expiredTls.cert, tlsCerts.cert), + "expired/key.pem": pad(expiredTls.key, tlsCerts.key), + }); + for (const file of ["valid/cert.pem", "valid/key.pem", "expired/cert.pem", "expired/key.pem"]) { + utimesSync(join(String(dir), file), 1_700_000_000, 1_700_000_000); + } + + using server = Bun.serve({ port: 0, hostname: "127.0.0.1", tls: tlsCerts, fetch: () => new Response("OK") }); + const script = ` + import tls from "node:tls"; + import { once } from "node:events"; + const pinned = []; + function viaBunConnect() { + return new Promise(resolve => { + Bun.connect({ + hostname: "127.0.0.1", + port: ${server.port}, + tls: { caFile: "cert.pem" }, + socket: { + open(s) { pinned.push(s); }, + handshake(s, ok, err) { resolve(ok ? "OK" : err.code); }, + error(s, err) { resolve(err.code); }, + connectError(s, err) { resolve(err.code); }, + data() {}, + close() {}, + }, + }).catch(err => resolve(err.code)); + }); + } + function viaNodeTls() { + return new Promise(resolve => { + const s = tls.connect({ host: "127.0.0.1", port: ${server.port}, caFile: "cert.pem", servername: "localhost" }); + pinned.push(s); + s.once("secureConnect", () => resolve("OK")); + s.once("error", err => resolve(err.code)); + }); + } + const mtls = tls.createServer({ key: process.env.KEY, cert: process.env.CERT, requestCert: true, rejectUnauthorized: false }); + await once(mtls.listen(0, "127.0.0.1"), "listening"); + async function clientCertificate() { + const seen = once(mtls, "secureConnection"); + pinned.push( + await Bun.connect({ + hostname: "127.0.0.1", + port: mtls.address().port, + tls: { keyFile: "key.pem", certFile: "cert.pem", rejectUnauthorized: false }, + socket: { data() {} }, + }), + ); + return (await seen)[0].getPeerCertificate().valid_to; + } + const out = {}; + for (const [name, attempt] of [["connect", viaBunConnect], ["tls", viaNodeTls], ["clientCert", clientCertificate]]) { + out[name] = []; + for (const cwd of ["valid", "expired", "valid"]) { + process.chdir(process.env.DIR + "/" + cwd); + out[name].push(await attempt()); + } + } + console.log(JSON.stringify(out)); + process.exit(0); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, DIR: String(dir), KEY: tlsCerts.key, CERT: tlsCerts.cert }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const validTo = (pem: string) => new X509Certificate(pem).validTo; + expect(stdout ? JSON.parse(stdout) : stderr).toEqual({ + connect: ["OK", "DEPTH_ZERO_SELF_SIGNED_CERT", "OK"], + tls: ["OK", "DEPTH_ZERO_SELF_SIGNED_CERT", "OK"], + clientCert: [validTo(tlsCerts.cert), validTo(expiredTls.cert), validTo(tlsCerts.cert)], + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); + test("addCACert on one user-facing context does not affect another with identical options", () => { const a = tls.createSecureContext({}); const b = tls.createSecureContext({}); diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 7f396ef9ed0e..7eb209d740d2 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isIPv6, isWindows, nodeExe, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isIPv6, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; import { once } from "node:events"; import { readFileSync } from "node:fs"; import net from "node:net"; @@ -1667,4 +1667,42 @@ describe.concurrent("fetch-tls", () => { expect(stderr).toContain("Warning: Ignoring extra certs from"); } }); + + it("resolves a relative tls.caFile against the cwd at fetch() time", async () => { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: validTls, + fetch: () => new Response("OK", { headers: { Connection: "close" } }), + }); + using dir = tempDir("fetch-tls-cafile-cwd", { + "trusts-server/ca.pem": validTls.cert, + "trusts-server/cwd/.keep": "", + "other-ca/ca.pem": expiredTls.cert, + "other-ca/cwd/.keep": "", + }); + const script = ` + const out = []; + for (const cwd of ["trusts-server/cwd", "other-ca/cwd", "trusts-server/cwd"]) { + process.chdir(process.env.DIR + "/" + cwd); + out.push( + await fetch(process.env.SERVER, { keepalive: false, tls: { caFile: "../ca.pem" } }).then( + res => res.text(), + err => err.code, + ), + ); + } + console.log(JSON.stringify(out)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, SERVER: `https://127.0.0.1:${server.port}`, DIR: String(dir) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout ? JSON.parse(stdout) : stderr).toEqual(["OK", "DEPTH_ZERO_SELF_SIGNED_CERT", "OK"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); }); From ff9fb67569bddad3465c173d17644c139b4d0654 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:08:04 +0000 Subject: [PATCH 043/174] crypto: X509Certificate.ca is false without basicConstraints (#31811) BoringSSL's X509_check_ca answers 1 for a self-signed v1 certificate where OpenSSL answers 2, and Node tests for 1. --- src/jsc/bindings/ncrypto.cpp | 4 ++- test/js/node/crypto/x509-subclass.test.ts | 34 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 6164f8fccb0d..7a019159d030 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -1032,7 +1032,9 @@ bool X509View::isCA() const { ClearErrorOnReturn clearErrorOnReturn; if (cert_ == nullptr) return false; - return X509_check_ca(const_cast(cert_)) == 1; + // BoringSSL also answers 1 for a v1 root, where OpenSSL answers 2. + X509* cert = const_cast(cert_); + return X509_check_ca(cert) == 1 && (X509_get_extension_flags(cert) & EXFLAG_BCONS); } bool X509View::isIssuedBy(const X509View& issuer) const diff --git a/test/js/node/crypto/x509-subclass.test.ts b/test/js/node/crypto/x509-subclass.test.ts index 464ea0a3da05..6e9c50d44c42 100644 --- a/test/js/node/crypto/x509-subclass.test.ts +++ b/test/js/node/crypto/x509-subclass.test.ts @@ -129,3 +129,37 @@ describe("X509Certificate#checkIssued", () => { expect(() => agent1.checkIssued("" as any)).toThrow(); }); }); + +// https://github.com/oven-sh/bun/issues/31810 +test("X509Certificate#ca is true only with basicConstraints CA:TRUE", () => { + const v1NoExtensions = new X509Certificate(`-----BEGIN CERTIFICATE----- +MIICAjCCAagCCQDFYI3zR8B/izAKBggqhkjOPQQDAjAPMQ0wCwYDVQQDDAR0ZXN0 +MB4XDTI2MDUyODE3MDYwNVoXDTM2MDUyNTE3MDYwNVowDzENMAsGA1UEAwwEdGVz +dDCCAUswggEDBgcqhkjOPQIBMIH3AgEBMCwGByqGSM49AQECIQD/////AAAAAQAA +AAAAAAAAAAAAAP///////////////zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP// +/////////////AQgWsY12Ko6k+ez671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDE +nTYIhucEk2pmeOETnSa3gZ9+kARBBGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPSh +OUXYmMKWT+NC4v4af5uO5+tKfA+eFivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAA +AP//////////vOb6racXnoTzucrC/GMlUQIBAQNCAAR1hd/nVei+or93b6B6lA0v +U52t80TD/E7NVfub7GJbHxbCX48zQH8YzMEsi/C/0G6N0/kf/ilwVuZXzwPVuTM/ +MAoGCCqGSM49BAMCA0gAMEUCIFTzv2XBNlegDgPaDlhmcxwOx9FaIfy/9SF6+qmV +7IPSAiEAkQ1u46qvg4y2tr47yLzr0PbtPVYgjNS7VYLNDWf/btw= +-----END CERTIFICATE----- +`); + const v3CaTrue = new X509Certificate(`-----BEGIN CERTIFICATE----- +MIIBjDCCATGgAwIBAgIUd0nA46zMcwqssVnVDNJ0F1APFuIwCgYIKoZIzj0EAwIw +EzERMA8GA1UEAwwITXlSb290Q0EwHhcNMjYwNjA0MTYyMDM2WhcNMzYwNjAxMTYy +MDM2WjATMREwDwYDVQQDDAhNeVJvb3RDQTBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABH5Mm74kubMd96Z5D09xITJcBhAiByKbnzyMRgcA14MlMmRXP9N812rhsyM6 +drajhDSLmtoeaLfdf+0YnndMppWjYzBhMB0GA1UdDgQWBBT/fNxTnDHehlFQZpEt +DhbKDuhG+DAfBgNVHSMEGDAWgBT/fNxTnDHehlFQZpEtDhbKDuhG+DAPBgNVHRMB +Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNJADBGAiEAwON8 +/nnAceDckQ0nD3etz11m120RFm0z3yGyPs2jmBQCIQCS7+FmRZYmXNAoqZFGtBmL +ALUH18cGAJfxlfxayzf4DA== +-----END CERTIFICATE----- +`); + expect(v1NoExtensions.ca).toBe(false); + expect(v1NoExtensions.toLegacyObject().ca).toBe(false); + expect(v3CaTrue.ca).toBe(true); + expect(v3CaTrue.toLegacyObject().ca).toBe(true); +}); From c6394de91a16c1b2c65a27731cd8816e424da139 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:08:05 +0000 Subject: [PATCH 044/174] tls, net: an IPv6 address with a zone id is matched on its address (#43982) net.isIP takes "fe80::1%eth0", but both name matchers and the five SNI gates parsed it with a parser that does not, so the host was neither an IP nor a DNS name: the certificate never matched and the literal went out as SNI. IP SANs are now compared as octets, and canonicalizeIP uses the strict parser instead of ares_inet_pton, which read "127.1" as 127.1.0.0. net.BlockList and net.SocketAddress failed to parse such an address, and BlockList.check() answers false for what it cannot parse, so net.connect({ host: "::1%lo", blockList }) reached a blocked address. The address now ends at the zone id, as in uv_inet_pton. --- src/boringssl/lib.rs | 63 ++---- src/bun_core/ip_address.rs | 38 ++++ src/http/ProxyTunnel.rs | 2 +- src/http/lib.rs | 2 +- .../websocket_client/WebSocketProxyTunnel.rs | 2 +- .../WebSocketUpgradeClient.rs | 2 +- src/js/node/tls.ts | 2 +- src/runtime/dns_jsc/cares_jsc.rs | 7 +- src/runtime/socket/SocketAddress.rs | 7 +- src/sql_jsc/jsc.rs | 2 +- test/js/bun/http/proxy.test.ts | 38 ++++ test/js/node/net/node-net.test.ts | 37 +++- ...-tls-connect-hostname-verification.test.ts | 65 ++++++ test/js/node/tls/node-tls-internals.test.ts | 48 +++++ test/js/sql/sql-tls-ip-literal-host.test.ts | 9 + test/js/web/fetch/fetch.tls.wildcard.test.ts | 198 +++++++++++++++++- 16 files changed, 462 insertions(+), 60 deletions(-) diff --git a/src/boringssl/lib.rs b/src/boringssl/lib.rs index 4d68b9ba1912..f057188028e5 100644 --- a/src/boringssl/lib.rs +++ b/src/boringssl/lib.rs @@ -3,6 +3,7 @@ #![warn(unused_must_use)] use core::ffi::{c_int, c_void}; +use core::net::IpAddr; use core::ptr; use std::cell::Cell; @@ -167,37 +168,15 @@ pub use bun_sys::posix::INET6_ADDRSTRLEN; // Canonical cross-platform AF_* surface — handles the Windows ws2def.h split. use bun_sys::posix::AF::{INET as AF_INET, INET6 as AF_INET6}; -/// converts IP string to canonicalized IP string -/// return null when the IP is invalid +/// Node.js's `canonicalizeIP`: the canonical text of the address that libuv's `uv_inet_pton` reads in `addr_str`, without the zone id of an IPv6 address. `None` for any other text. pub fn canonicalize_ip<'a>( addr_str: &[u8], out_ip: &'a mut [u8; INET6_ADDRSTRLEN + 1], ) -> Option<&'a [u8]> { - if addr_str.len() >= INET6_ADDRSTRLEN { - return None; + match bun_core::ip_address::parse_uv_inet_pton(addr_str)? { + IpAddr::V4(ip) => canonical_ip_octets(&ip.octets(), out_ip), + IpAddr::V6(ip) => canonical_ip_octets(&ip.octets(), out_ip), } - let mut ip_std_text = [0u8; INET6_ADDRSTRLEN + 1]; - // we need a null terminated string as input - out_ip[..addr_str.len()].copy_from_slice(addr_str); - out_ip[addr_str.len()] = 0; - - let mut af: c_int = AF_INET; - // get the standard text representation of the IP - // SAFETY: out_ip is NUL-terminated above; ip_std_text is large enough for any address. - unsafe { - if c_ares::ares_inet_pton(af, out_ip.as_ptr().cast(), ip_std_text.as_mut_ptr().cast()) <= 0 - { - af = AF_INET6; - if c_ares::ares_inet_pton(af, out_ip.as_ptr().cast(), ip_std_text.as_mut_ptr().cast()) - <= 0 - { - return None; - } - } - } - // out_ip will contain the null-terminated canonicalized IP - // SAFETY: ip_std_text holds the in_addr/in6_addr written by ares_inet_pton above. - unsafe { c_ares::ntop(af, ip_std_text.as_ptr().cast(), &mut out_ip[..]) } } /// Canonical text form of raw IP address octets (4 or 16 bytes), e.g. `::1`. @@ -451,7 +430,8 @@ fn is_hostname(host: &[u8]) -> bool { pub fn check_x509_server_identity(x509: &mut boring::X509, hostname: &[u8]) -> bool { // As in Node.js, a host is an IP address only as typed, and only in the strict form of `net.isIP`: `ares_inet_pton` reads "127.1" as 127.1.0.0 and takes "1.2.3.4/8". - let host_is_ip = bun_core::ip_address::parse_strict(unfqdn(hostname)).is_some(); + let typed = unfqdn(hostname); + let mut host_ip = bun_core::ip_address::parse_strict(typed); let ascii_hostname; // CVE-2026-48618: IDNA maps "。" to ".", so a non-ASCII host is matched on its UTS #46 form, as in `tls.checkServerIdentity`. let hostname = if strings::first_non_ascii(hostname).is_some() { @@ -465,20 +445,17 @@ pub fn check_x509_server_identity(x509: &mut boring::X509, hostname: &[u8]) -> b hostname }; let hostname = unfqdn(hostname); - let host_is_dns_name = !host_is_ip && is_hostname(hostname); + let host_is_dns_name = host_ip.is_none() && is_hostname(hostname); + if host_ip.is_none() && !host_is_dns_name { + // `net.isIP` also takes an IPv6 address with a zone id (`fe80::1%eth0`), and a certificate names the address alone. No hostname has a `%`, so only a host that is neither pays for the search. + host_ip = bun_core::ip_address::parse_zoned_ipv6_host(typed).map(IpAddr::V6); + } let mut has_dns_san = false; match x509.subject_alt_names() { boring::SanLookup::Invalid => return false, boring::SanLookup::Absent => {} boring::SanLookup::Names(names) => { - let mut host_ip_buf = [0u8; INET6_ADDRSTRLEN + 1]; - let host_ip: Option<&[u8]> = if host_is_ip { - Some(canonicalize_ip(hostname, &mut host_ip_buf).unwrap_or(hostname)) - } else { - None - }; - let mut cert_ip_buf = [0u8; INET6_ADDRSTRLEN + 1]; for entry in names.subject_alt_names() { match entry { boring::SubjectAltName::Dns(name) => { @@ -488,12 +465,14 @@ pub fn check_x509_server_identity(x509: &mut boring::X509, hostname: &[u8]) -> b } } boring::SubjectAltName::Ip(octets) => { - if let (Some(host_ip), Some(cert_ip)) = - (host_ip, canonical_ip_octets(octets, &mut cert_ip_buf)) - { - if host_ip == cert_ip { - return true; - } + // As in Node.js, `::ffff:1.2.3.4` is not `1.2.3.4`: the 16 octets are not the 4. + let is_host_ip = match host_ip { + Some(IpAddr::V4(ip)) => ip.octets() == *octets, + Some(IpAddr::V6(ip)) => ip.octets() == *octets, + None => false, + }; + if is_host_ip { + return true; } } boring::SubjectAltName::Uri(_) => {} @@ -770,7 +749,7 @@ pub fn write_server_identity_mismatch_reason( const NO_DNS: &str = "Cert does not contain a DNS name"; let hostname = unfqdn(hostname); let host = HostName(hostname); - let host_is_ip = bun_core::ip_address::parse_strict(hostname).is_some(); + let host_is_ip = bun_core::ip_address::is_ip_host(hostname); let Some(x509) = ssl_ptr.peer_leaf_certificate() else { return out.write_str(NO_DNS); diff --git a/src/bun_core/ip_address.rs b/src/bun_core/ip_address.rs index 6f61ac73ee44..3a2e9765d2f2 100644 --- a/src/bun_core/ip_address.rs +++ b/src/bun_core/ip_address.rs @@ -49,6 +49,44 @@ fn parse_strict_v6(input: &[u8]) -> Option { crate::fmt::parse_ascii::(input) } +/// An IPv6 address with a zone id: `fe80::1%eth0` is the address `fe80::1` and the zone `eth0`. The split is at the first `%`, and the address is in the strict form of `parse_strict`. The zone names an interface of this machine: it is not part of the address, and it is not checked here. +fn parse_zoned_ipv6(input: &[u8]) -> Option<(Ipv6Addr, &[u8])> { + let (address, zone) = crate::strings::split_once_char(input, b'%')?; + Some((parse_strict_v6(address)?, zone)) +} + +/// libuv's `uv_inet_pton` for either family, which Node.js reads an address with: `parse_strict`, or an IPv6 address up to the first `%`. The text is a C string there, so it ends at a NUL. +pub fn parse_uv_inet_pton(input: &[u8]) -> Option { + if let Some(ip) = parse_strict(input) { + return Some(ip); + } + let text = match crate::strings::split_once_char(input, 0) { + Some((text, _)) => { + if let Some(ip) = parse_strict(text) { + return Some(ip); + } + text + } + None => input, + }; + Some(IpAddr::V6(parse_zoned_ipv6(text)?.0)) +} + +/// An IPv6 host with a zone id. Node.js's `net.isIP` takes the zone only when it is one or more of letters, digits, `-`, `.` and `:`. +pub fn parse_zoned_ipv6_host(host: &[u8]) -> Option { + let (address, zone) = parse_zoned_ipv6(host)?; + let is_zone_id = !zone.is_empty() + && zone + .iter() + .all(|&b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b':')); + is_zone_id.then_some(address) +} + +/// Whether Node.js's `net.isIP` takes `host`: `is_ip_address`, or `parse_zoned_ipv6_host`. The TLS name check matches such a host on the IP SANs of the certificate, and RFC 6066 takes no such host as SNI. +pub fn is_ip_host(host: &[u8]) -> bool { + is_ip_address(host) || parse_zoned_ipv6_host(host).is_some() +} + /// Parses what the platform resolver treats as a numeric host: dotted-quad, IPv6 (an optional `%zone` is stripped, not validated), and the `inet_aton` shorthand `getaddrinfo` accepts but `is_ip_address` rejects (`127.1`, `2130706433`, `0x7f000001`, `0177.0.0.1`). The Windows resolver reads no shorthand. pub fn to_ip_address(input: &[u8]) -> Option { // A `%zone` suffix belongs to a numeric v6 host; resolving the zone is the caller's business. diff --git a/src/http/ProxyTunnel.rs b/src/http/ProxyTunnel.rs index 2f4e6f990116..de48ac6b2e81 100644 --- a/src/http/ProxyTunnel.rs +++ b/src/http/ProxyTunnel.rs @@ -211,7 +211,7 @@ fn on_open(ctx: *mut HTTPClient) { // SAFETY: `ssl_ptr` is the live SSL handle from the tunnel's SSLWrapper. let ssl = unsafe { &mut *ssl_ptr.as_ptr() }; - if bun_core::ip_address::is_ip_address(_hostname) { + if bun_core::ip_address::is_ip_host(_hostname) { // SNI is null (IP literal — no SNI). crate::configure_http_client_with_alpn(ssl, core::ptr::null(), AlpnOffer::H1); } else { diff --git a/src/http/lib.rs b/src/http/lib.rs index 36df14880bfa..60b4f3402540 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1867,7 +1867,7 @@ impl<'a> HTTPClient<'a> { // below with `null` SNI in the IP case. let mut owned: Vec; // drops on scope exit let host_z: *const core::ffi::c_char = - if !bun_core::ip_address::is_ip_address(raw_hostname) { + if !bun_core::ip_address::is_ip_host(raw_hostname) { // SAFETY: TEMP_HOSTNAME only accessed from HTTP thread let temp = scratch::temp_hostname(); if raw_hostname.len() < temp.len() { diff --git a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs index ece018083f9a..cb0d0e17cdda 100644 --- a/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs +++ b/src/http_jsc/websocket_client/WebSocketProxyTunnel.rs @@ -246,7 +246,7 @@ impl WebSocketProxyTunnel { // is driven. if let Some(ssl_ptr) = ssl { if let Some(hostname) = this.sni_hostname.as_deref() { - if !bun_core::ip_address::is_ip_address(hostname) { + if !bun_core::ip_address::is_ip_host(hostname) { // Set SNI hostname let hostname_z = bun_core::ZBox::from_vec_with_nul(hostname.to_vec()); // Route through bun_http's diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 43face7b4ce0..4aa437f8f1ef 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -700,7 +700,7 @@ where if let Some(ssl) = socket.ssl_mut() { bun_http::configure_http_client_with_alpn( ssl, - if bun_core::ip_address::is_ip_address(hostname.as_bytes()) { + if bun_core::ip_address::is_ip_host(hostname.as_bytes()) { core::ptr::null() } else { hostname.as_ptr() diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index d5e9816197b3..d38d26ba5f5b 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -397,7 +397,7 @@ function checkServerIdentity(hostname, cert) { // As in Node.js (https://github.com/nodejs/node/commit/1d87a24050), a host is an IP address only as typed. if (net.isIP(hostname)) { - // canonicalizeIP() is undefined for "::1%lo" and for a malformed IP SAN, and undefined must not match undefined. + // canonicalizeIP() drops the zone id of "fe80::1%eth0". It is undefined for a malformed IP SAN, and undefined must not match undefined. const ip = canonicalizeIP(hostname); valid = ip !== undefined && ArrayPrototypeIncludes.$call(ips, ip); if (!valid) reason = `IP: ${hostname} is not in the cert's list: ` + ArrayPrototypeJoin.$call(ips, ", "); diff --git a/src/runtime/dns_jsc/cares_jsc.rs b/src/runtime/dns_jsc/cares_jsc.rs index 53c71c072f45..b2c195118c16 100644 --- a/src/runtime/dns_jsc/cares_jsc.rs +++ b/src/runtime/dns_jsc/cares_jsc.rs @@ -6,7 +6,7 @@ use core::ffi::c_int; use ::bstr::BStr; use bun_cares_sys::c_ares_draft as c_ares; -use bun_core::{self as bstr, strings}; +use bun_core as bstr; use bun_jsc::{ CallFrame, JSGlobalObject, JSValue, JsResult, StringJsc, SystemError, bun_string_jsc, }; @@ -868,11 +868,6 @@ fn bun_canonicalize_ip(global_this: &JSGlobalObject, callframe: &CallFrame) -> J let addr_arg = arguments[0].to_utf8(global_this)?; let addr_str = addr_arg.slice(); - // CIDR not allowed - if strings::index_of_char(addr_str, b'/').is_some() { - return Ok(JSValue::UNDEFINED); - } - let mut ip_addr = [0u8; bun_boringssl::INET6_ADDRSTRLEN + 1]; let Some(slice) = bun_boringssl::canonicalize_ip(addr_str, &mut ip_addr) else { return Ok(JSValue::UNDEFINED); diff --git a/src/runtime/socket/SocketAddress.rs b/src/runtime/socket/SocketAddress.rs index cdd3064a25ce..c24337cd8e89 100644 --- a/src/runtime/socket/SocketAddress.rs +++ b/src/runtime/socket/SocketAddress.rs @@ -407,7 +407,12 @@ impl SocketAddress { }; if let Some(address_str) = options.address { presentation = address_str; - let slice = presentation.to_owned_slice_z(); + let mut slice = presentation.to_owned_slice_z(); + // As in libuv's `uv_inet_pton`, the address ends at a zone id. + if let Some((address, _)) = strings::split_once_char(slice.as_bytes(), b'%') { + slice = bun_core::ZBox::from_bytes(address); + presentation = BunString::DEAD; + } pton( global, inet::AF_INET6, diff --git a/src/sql_jsc/jsc.rs b/src/sql_jsc/jsc.rs index 378017437dcb..b30a81b1f93d 100644 --- a/src/sql_jsc/jsc.rs +++ b/src/sql_jsc/jsc.rs @@ -522,7 +522,7 @@ pub mod api { let name = unsafe { bun_core::ffi::cstr(server_name) }; let bare = bun_core::ip_address::strip_ipv6_brackets(name.to_bytes()); let bracketed = bare.len() != name.to_bytes().len(); - (!bracketed && !bun_core::ip_address::is_ip_address(bare)).then_some(name) + (!bracketed && !bun_core::ip_address::is_ip_host(bare)).then_some(name) } /// `SSLConfig.fromJS(vm, global, value)` — VM is accepted but diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 58753c65dac5..9c899788dc40 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -917,6 +917,44 @@ test("HTTPS proxy tunnel sends a tls.serverName that is IP shorthand as SNI", as } }); +// As on a direct connection, an IPv6 address with a zone id is an IP address +// inside the tunnel: it is matched on the IP SAN of its address, and it is not +// sent as SNI. The certificate is the harness one, with the IP SAN ::1. +test("HTTPS proxy tunnel matches a tls.serverName with a zone id on its address and sends no SNI", async () => { + const seen: (string | null)[] = []; + const target = tls.createServer(tlsCert, socket => { + socket.on("error", () => {}); + seen.push(socket.servername || null); + socket.once("data", () => socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")); + }); + target.listen(0); + await once(target, "listening"); + try { + const url = `https://localhost:${(target.address() as net.AddressInfo).port}/`; + const options = { proxy: httpProxyServer.url, keepalive: false }; + const serverNames = [ + ["::1%lo", { ca: tlsCert.cert }], + ["fe80::1%eth0", { rejectUnauthorized: false }], + // Not a zone id for net.isIP, so not an IP address. + ["fe80::1%br_lan", { rejectUnauthorized: false }], + ] as const; + for (const [serverName, verify] of serverNames) { + const res = await fetch(url, { ...options, tls: { serverName, ...verify } }); + expect(`${res.status} ${await res.text()}`).toBe("200 ok"); + } + expect(seen).toEqual([null, null, "fe80::1%br_lan"]); + + expect( + await fetch(url, { ...options, tls: { serverName: "::2%lo", ca: tlsCert.cert } }).then( + res => res.status, + err => err.code, + ), + ).toBe("ERR_TLS_CERT_ALTNAME_INVALID"); + } finally { + target.close(); + } +}); + test("HTTPS proxy tunnel keep-alive does not share tunnel across different credentials", async () => { using target = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("ok") }); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index ae4fe18576a0..e2df05ba14bc 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -30,7 +30,7 @@ import { isIPv6, Server, Socket, - // @ts-expect-error legacy alias + SocketAddress, Stream, } from "node:net"; import { join } from "node:path"; @@ -127,6 +127,41 @@ describe("net.BlockList subnet rules", () => { }); }); +describe("net.BlockList and an IPv6 zone id", () => { + it("check() matches on the address alone", () => { + const blockList = new BlockList(); + blockList.addAddress("fe80::1", "ipv6"); + blockList.addAddress("1.2.3.4"); + expect(blockList.check("fe80::1%eth0", "ipv6")).toBe(true); + expect(blockList.check("fe80::1%", "ipv6")).toBe(true); + expect(blockList.check("::ffff:1.2.3.4%lo", "ipv6")).toBe(true); + expect(blockList.check(new SocketAddress({ address: "fe80::1%eth0", family: "ipv6" }))).toBe(true); + expect(blockList.check("fe80::2%eth0", "ipv6")).toBe(false); + expect(blockList.check("1.2.3.4%lo")).toBe(false); + }); + + it("a rule drops it", () => { + const blockList = new BlockList(); + blockList.addAddress("fe80::9%eth0", "ipv6"); + blockList.addRange("fe80::10%eth0", "fe80::20%eth1", "ipv6"); + blockList.addSubnet("fd00::%eth0", 64, "ipv6"); + expect(blockList.rules).toEqual([ + "Subnet: IPv6 fd00::/64", + "Range: IPv6 fe80::10-fe80::20", + "Address: IPv6 fe80::9", + ]); + expect(new SocketAddress({ address: "fe80::9%eth0", family: "ipv6" }).address).toBe("fe80::9"); + }); + + it("connect() refuses a blocked address", async () => { + const blockList = new BlockList(); + blockList.addAddress("::1", "ipv6"); + const socket = connect({ host: "::1%lo", port: 1, blockList }); + const [error] = await once(socket, "error"); + expect(error.code).toBe("ERR_IP_BLOCKED"); + }); +}); + describe("net.Socket read", () => { var unix_servers = 0; for (let [message, label] of [ diff --git a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts index 013578da9e8b..054f0526eb4c 100644 --- a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts +++ b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts @@ -164,6 +164,71 @@ describe("tls.connect over an existing socket verifies the certificate against o }); }); + // The zone id names an interface of the client. It is not part of the address + // that the certificate lists, and an IP address is not sent as SNI. + test("matches an IPv6 address with a zone id in options.host on its address", async () => { + const sni: string[] = []; + const serverOptions: tls.TlsOptions = { + key: ipSanCert.key, + cert: ipSanCert.cert, + SNICallback(servername, done) { + sni.push(servername); + done(null); + }, + }; + await withRawSocketTo(serverOptions, async raw => { + const { promise, resolve, reject } = Promise.withResolvers<{ + calledWith: string | undefined; + authorized: boolean; + }>(); + let calledWith: string | undefined; + const socket = tls.connect({ + socket: raw, + host: "::1%lo", + ca: ipSanCert.cert, + checkServerIdentity(hostname, cert) { + calledWith = hostname; + return tls.checkServerIdentity(hostname, cert); + }, + }); + socket.on("secureConnect", () => { + resolve({ calledWith, authorized: socket.authorized }); + socket.destroy(); + }); + socket.on("error", err => { + socket.destroy(); + reject(err); + }); + assert.deepStrictEqual(await promise, { calledWith: "::1%lo", authorized: true }); + }); + assert.deepStrictEqual(sni, []); + }); + + test("rejects an IPv6 address with a zone id that the certificate does not list", async () => { + await withRawSocketTo({ key: ipSanCert.key, cert: ipSanCert.cert }, async raw => { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = tls.connect({ socket: raw, host: "::2%lo", ca: ipSanCert.cert }, () => { + const detail = { authorized: socket.authorized, authorizationError: socket.authorizationError }; + socket.destroy(); + reject(Object.assign(new Error("secureConnect fired for a certificate that does not list the IP"), detail)); + }); + socket.on("error", err => { + socket.destroy(); + resolve(err); + }); + const err = await promise; + assert.deepStrictEqual( + { code: err.code, host: err.host, message: err.message }, + { + code: "ERR_TLS_CERT_ALTNAME_INVALID", + host: "::2%lo", + message: + "Hostname/IP does not match certificate's altnames: IP: ::2%lo is not in the cert's list: 127.0.0.1, ::1", + }, + ); + }); + }); + test("rejects a certificate that is only valid for localhost when options.host is an IP", async () => { await withRawSocketTo({ key: localhostOnlyKey, cert: localhostOnlyCert }, async raw => { const { promise, resolve, reject } = Promise.withResolvers(); diff --git a/test/js/node/tls/node-tls-internals.test.ts b/test/js/node/tls/node-tls-internals.test.ts index 9c0041919dab..6afde0fff8c0 100644 --- a/test/js/node/tls/node-tls-internals.test.ts +++ b/test/js/node/tls/node-tls-internals.test.ts @@ -26,6 +26,54 @@ describe("NodeTLS.cpp", () => { expect(canonicalizeIP("::ffff:192.168.1.1")).toBe("::ffff:192.168.1.1"); }); + // Node.js parses with libuv's uv_inet_pton: a dotted quad, or an IPv6 address + // up to the first "%". The zone id is not in the result and is not checked. + // The text is a C string there, so it ends at a NUL. Every expected value + // was taken from Node.js v26.3.0. + test("canonicalizeIP reads an address in the strict form and drops a zone id", () => { + const long = `fe80::1%${Buffer.alloc(64, "z").toString()}`; + const expected: Record = { + "::1%lo": "::1", + "fe80::1%eth0": "fe80::1", + "FE80:0:0:0:0:0:0:1%eth0": "fe80::1", + "::ffff:1.2.3.4%lo": "::ffff:1.2.3.4", + "1:2:3:4:5:6:7::%lo": "1:2:3:4:5:6:7:0", + "fe80::1%12": "fe80::1", + "fe80::1%": "fe80::1", + "fe80::1%eth0%eth1": "fe80::1", + "fe80::1%br_lan": "fe80::1", + "fe80::1%eth0/64": "fe80::1", + "fe80::1%et\0h0": "fe80::1", + "::1\0": "::1", + "::1\0%lo": "::1", + "1.2.3.4\0/8": "1.2.3.4", + [long]: "fe80::1", + // An IPv4 address takes no zone, and the address before the zone is in the strict form. + "1.2.3.4%lo": null, + "::1.2.3%lo": null, + "::01.2.3.4%lo": null, + "fe80::12345%lo": null, + "fe80::1 %eth0": null, + "fe80::1/64%eth0": null, + "10.0.0\0%a/b": null, + "%lo": null, + // ares_inet_pton reads each of these: "127.1" as 127.1.0.0, "10" as 10.0.0.0. + "127.1": null, + "10": null, + "0x7f000001": null, + "127.000.000.001": null, + "1.2.3": null, + "01.2.3.4": null, + "::1.2.3": null, + "::01.2.3.4": null, + "1.2.3.4/8": null, + "::1/64": null, + "127.1\0": null, + }; + const actual = Object.keys(expected).map(text => [text, canonicalizeIP(text) ?? null]); + expect(Object.fromEntries(actual)).toEqual(expected); + }); + test("rootCertificates", () => { expect(rootCertificates).toBeInstanceOf(Array); expect(rootCertificates.length).toBeGreaterThan(0); diff --git a/test/js/sql/sql-tls-ip-literal-host.test.ts b/test/js/sql/sql-tls-ip-literal-host.test.ts index ca56b6df748c..e9a8ccbb93dc 100644 --- a/test/js/sql/sql-tls-ip-literal-host.test.ts +++ b/test/js/sql/sql-tls-ip-literal-host.test.ts @@ -161,6 +161,15 @@ describe.concurrent.each([ }); }); + // The zone id names an interface of the client, so the certificate has the address alone. + test("an IPv6 address with a zone id in tls.serverName is verified against the IP SAN of the address and is not sent as SNI", async () => { + await withServer("127.0.0.1", async server => { + const url = `${scheme}://u@127.0.0.1:${server.port}/db`; + expect(await connect(url, { ca: localhostTls.cert, serverName: "::1%lo" })).toBe("CONNECTED"); + expect(server.servernames).toEqual([false]); + }); + }); + test("a DNS name in tls.serverName is still sent as SNI", async () => { await withServer("127.0.0.1", async server => { const url = `${scheme}://u@127.0.0.1:${server.port}/db`; diff --git a/test/js/web/fetch/fetch.tls.wildcard.test.ts b/test/js/web/fetch/fetch.tls.wildcard.test.ts index 1a0d341d612f..eae745ed9713 100644 --- a/test/js/web/fetch/fetch.tls.wildcard.test.ts +++ b/test/js/web/fetch/fetch.tls.wildcard.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test"; import crypto from "node:crypto"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; import tls from "node:tls"; // This test verifies that wildcard certificate hostname matching follows RFC 6125 Section 6.4.3: @@ -737,10 +739,10 @@ describe("TLS certificate name matching: fetch() / checkServerIdentity / checkHo // native matcher used ares_inet_pton, which also reads "127.1" (as // 127.1.0.0), "10", hex, zero-padded octets and a trailing "/bits". A // resolver reads "127.1" as 127.0.0.1, so such a name has two readings. - // net.isIP also takes an IPv6 address with a zone, which canonicalizeIP() - // does not read. It does not read a malformed iPAddress SAN either (5 bytes - // here, printed as ""), and undefined must not match undefined. - describe.concurrent("IP shorthand and zone ids are not an IP address", () => { + // canonicalizeIP() does not read a malformed iPAddress SAN (5 bytes here, + // printed as ""), and no host matches it. This certificate has no + // IPv6 SAN. + describe.concurrent("IP shorthand is not an IP address", () => { const ipCert = makeCert("x", [ ["ip", "127.0.0.1"], ["ip", "127.1.0.0"], @@ -769,6 +771,194 @@ describe("TLS certificate name matching: fetch() / checkServerIdentity / checkHo }); }); + // The zone id of an IPv6 address ("fe80::1%eth0") names the interface of the + // client that reaches a link-local peer. It is not part of the address, so a + // certificate names the address alone, and both matchers compare the host + // without its zone. net.isIP takes one or more of letters, digits, "-", "." + // and ":" after the "%". A host with any other zone is not an IP address, and + // an IPv4 address takes no zone. + describe.concurrent("an IPv6 host with a zone id matches the IP SAN of its address", () => { + // makeCert takes the octets of an iPAddress SAN in dotted decimal. + const v6 = (...groups: number[]) => groups.flatMap(group => [group >> 8, group & 0xff]).join("."); + const zoneCert = makeCert("x", [ + ["ip", v6(0, 0, 0, 0, 0, 0, 0, 1)], + ["ip", v6(0xfe80, 0, 0, 0, 0, 0, 0, 1)], + ["ip", v6(0, 0, 0, 0, 0, 0xffff, 0x0102, 0x0304)], + ["ip", v6(0, 0, 0, 0, 0, 0, 0x0102, 0x0300)], + ["ip", "5.6.7.8"], + ]); + const sans = "::1, fe80::1, ::ffff:1.2.3.4, ::1.2.3.0, 5.6.7.8"; + + async function connectError(material: { cert: string; key: string }, serverName: string) { + await using server = Bun.serve({ port: 0, tls: material, fetch: () => new Response("ok") }); + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: material.cert, serverName }, + socket: { + handshake(s) { + const error = s.getAuthorizationError(); + resolve({ code: error?.code, message: error?.message }); + }, + data() {}, + error(_s, err) { + reject(err); + }, + connectError(_s, err) { + reject(err); + }, + close() { + reject(new Error("closed before the handshake callback")); + }, + }, + }); + try { + return await promise; + } finally { + socket.end(); + } + } + + it.each([ + ["::1%lo", true], + ["fe80::1%eth0", true], + ["FE80:0:0:0:0:0:0:1%eth0", true], + // Windows names a zone by the index of the interface. + ["fe80::1%12", true], + ["fe80::1%wlan-0.1:2", true], + ["fe80::1%eth0.", true], + ["::ffff:1.2.3.4%lo", true], + ["::1.2.3.0%lo", true], + // Longer than the 45 bytes of the longest address. + [`fe80::1%${Buffer.alloc(64, "z").toString()}`, true], + ["fe80::2%eth0", false], + // "::ffff:5.6.7.8" is not "5.6.7.8". + ["::ffff:5.6.7.8%lo", false], + ["5.6.7.8%lo", false], + ["fe80::1%", false], + ["fe80::1%eth0%eth1", false], + ["fe80::1%a/b", false], + ["fe80::1%eth0/64", false], + ["fe80::1%br_lan", false], + ["fe80::1% eth0", false], + // ares_inet_pton reads "::1.2.3" as ::1.2.3.0. + ["::1.2.3%lo", false], + ])("%j", async (host, match) => { + expect({ csi: csi(zoneCert.x509, host), fetch: await fetchOk(zoneCert, host) }).toEqual({ + csi: match, + fetch: match ? { ok: true } : { ok: false, code: "ERR_TLS_CERT_ALTNAME_INVALID" }, + }); + }); + + // A zoned host is an IP address and nothing else. It matches no DNS SAN and + // no CN, also when the certificate has the host as typed there. Bun 1.4.2 + // accepts those certificates through fetch and Bun.connect. As in Node.js, + // the error names the host as typed, with its zone. + describe("a zoned host that is not an IP SAN of the certificate", () => { + const host = "fe80::1%eth0"; + it.each([ + ["another address", zoneCert, "fe80::2%eth0", sans], + [ + "the host and its address as DNS SANs", + makeCert("x", [ + ["dns", host], + ["dns", "fe80::1"], + ]), + host, + "", + ], + ["the host as the CN", makeCert(host, []), host, ""], + ["the address as the CN", makeCert("fe80::1", []), host, ""], + [ + "the host as the CN, beside another address", + makeCert(host, [["ip", v6(0xfe80, 0, 0, 0, 0, 0, 0, 2)]]), + host, + "fe80::2", + ], + ])("%s", async (_label, material, host, list) => { + const reason = `IP: ${host} is not in the cert's list: ${list}`; + const error = tls.checkServerIdentity(host, material.x509.toLegacyObject()) as any; + expect({ + csi: { code: error?.code, reason: error?.reason, host: error?.host }, + fetch: await fetchOk(material, host), + connect: await connectError(material, host), + }).toEqual({ + csi: { code: "ERR_TLS_CERT_ALTNAME_INVALID", reason, host }, + fetch: { ok: false, code: "ERR_TLS_CERT_ALTNAME_INVALID" }, + connect: { + code: "ERR_TLS_CERT_ALTNAME_INVALID", + message: `Hostname/IP does not match certificate's altnames: ${reason}`, + }, + }); + }); + }); + + // tls.checkServerIdentity takes any object as the certificate. A real + // certificate prints an IP SAN from its octets, in full. In another text, + // canonicalizeIP() read "127.1" as 127.1.0.0, as ares_inet_pton does. Every + // expected value was taken from Node.js v26.3.0, which reads the text as a + // C string, up to a NUL. + it.each([ + ["::1.2.3.0%lo", "IP Address:::1.2.3", false], + ["::1.2.3.0", "IP Address:::1.2.3", false], + ["127.1.0.0", "IP Address:127.1", false], + ["10.0.0.0", "IP Address:10", false], + ["127.0.0.1", "IP Address:0x7f000001", false], + ["1.2.3.4", "IP Address:01.2.3.4", false], + ["1.2.3.4", "IP Address:1.2.3.4/32", false], + ["127.1.0.0", "IP Address:127.1\0", false], + ["::1", "IP Address:::1\0junk", true], + ["fe80::1", "IP Address:fe80::1%eth0", true], + ["fe80::1%eth0", "IP Address:fe80::1%eth1", true], + ["::1%x.example.com", "IP Address:::1", true], + ])("tls.checkServerIdentity(%j) with the IP SAN text %j", (host, subjectaltname, match) => { + const cert = { subjectaltname, subject: {} } as tls.PeerCertificate; + expect(tls.checkServerIdentity(host, cert) === undefined).toBe(match); + }); + + // RFC 6066 takes no IP address as the server name in SNI. fetch asked + // ares_inet_pton, which does not read a zoned host as an address and reads + // "127.1" as one. A host is sent as SNI when the matcher takes it as a DNS + // name, as in Node.js. + it("fetch sends SNI for a DNS name only", async () => { + const names: string[] = []; + const server = tls.createServer( + { + key: zoneCert.key, + cert: zoneCert.cert, + SNICallback(name, done) { + names.push(name); + done(null); + }, + }, + socket => { + socket.on("error", () => {}); + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); + }, + ); + server.on("tlsClientError", () => {}); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const url = `https://127.0.0.1:${(server.address() as AddressInfo).port}/`; + const results: string[] = []; + for (const serverName of ["fe80::1%eth0", "5.6.7.8", "::1", "name.test", "127.1", "fe80::1%br_lan"]) { + // @ts-expect-error Bun extension + const response = fetch(url, { tls: { ca: zoneCert.cert, serverName }, keepalive: false }); + results.push(await response.then(r => r.text()).catch(e => e.code)); + } + const rejected = "ERR_TLS_CERT_ALTNAME_INVALID"; + expect({ results, names }).toEqual({ + results: ["ok", "ok", "ok", rejected, rejected, rejected], + names: ["name.test", "127.1", "fe80::1%br_lan"], + }); + } finally { + server.close(); + } + }); + }); + // tls.serverName is a C string in the native TLS configuration. SNI and the // native matcher would see "exact.test" for "exact.test\0.wild.test", so // fetch and Bun.connect refuse the option. node:tls does what Node.js does: From 5dc09f5d3d6e11db1feaa960fa0910ab8bdae320 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:10:27 +0000 Subject: [PATCH 045/174] tls: a server asks for a client certificate with requestCert only (#33483) us_ssl_ctx_build_raw set SSL_VERIFY_PEER | FAIL_IF_NO_PEER_CERT in its caFile and ca branches, and looked at request_cert only without a CA. So Bun.serve/Bun.listen({ tls: { key, cert, ca } }) refused every client that had no certificate, served any client that had one (nothing checks the chain without requestCert), and node:tls/https sent a CertificateRequest nobody asked for. The mode is now set once, from request_cert, as in Node's SetVerifyMode. That makes dead the JS that forced rejectUnauthorized to false on a server without requestCert. https.createServer takes requestCert === true, like tls.createServer. A client raises its own SSL to VERIFY_PEER. The copy of that code in SSLWrapper replaced the store even when the context held user CAs, so tls.connect over a Duplex ignored addCACert(): both engines now call one C helper. BREAKING for Bun.serve / Bun.listen / HTTP/3: `ca` without `requestCert` no longer demands a certificate. It never verified one. --- packages/bun-usockets/src/crypto/openssl.c | 60 ++++++------------ packages/bun-usockets/src/libusockets.h | 4 +- src/js/node/_http_server.ts | 14 ++--- src/js/node/tls.ts | 3 +- src/runtime/socket/socket_body.rs | 2 +- src/uws/lib.rs | 71 +++++----------------- src/uws_sys/SocketContext.rs | 10 +-- test/js/bun/http/serve-http3.test.ts | 37 +++++++++++ test/js/node/tls/fetch-tls-cert.test.ts | 5 +- test/js/node/tls/node-tls-connect.test.ts | 47 ++++++++++++++ test/js/node/tls/node-tls-server.test.ts | 36 +++++++++-- 11 files changed, 163 insertions(+), 126 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index be3ad407396e..52881044a3bd 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1316,11 +1316,6 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, ssl_ctx_build_fail(ssl_context); return NULL; } - SSL_CTX_set_verify(ssl_context, - options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) - : SSL_VERIFY_PEER, - us_verify_callback); - } else if (options.ca && options.ca_count > 0) { us_ex_idx_ensure(); SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); @@ -1341,24 +1336,21 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, return NULL; } ERR_clear_error(); - SSL_CTX_set_verify(ssl_context, - options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) - : SSL_VERIFY_PEER, - us_verify_callback); } else { /* No user CA: seed the shared default root store, like Node's * addRootCerts() when `ca` is absent - the handshake-time auto-chain and * (for requestCert) client verification both read it. The getter up-refs, * so set_cert_store owns exactly one reference per context. */ SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store()); - if (options.request_cert) { - SSL_CTX_set_verify(ssl_context, - options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) - : SSL_VERIFY_PEER, - us_verify_callback); - } } + /* The callback is set at SSL_VERIFY_NONE too: lsquic's SNI switch calls SSL_set_verify(ssl, mode, NULL), which keeps it. */ + SSL_CTX_set_verify(ssl_context, + !options.request_cert ? SSL_VERIFY_NONE + : options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) + : SSL_VERIFY_PEER, + us_verify_callback); + if (options.dh_params_file_name) { DH *dh_2048 = NULL; FILE *paramfile = fopen(options.dh_params_file_name, "r"); @@ -1620,7 +1612,7 @@ SSL_CTX *us_ssl_ctx_from_options(struct us_bun_socket_context_options_t options, /* SecureContext is mode-neutral (Node lets one back both tls.connect and * tls.createServer), so we can't bake client-vs-server into the CTX. CTX - * verify_mode comes purely from options (ca/request_cert/reject_unauthorized) + * verify_mode comes purely from options (request_cert/reject_unauthorized) * in build_raw — for a server that decides whether CertificateRequest is * sent, so we MUST NOT force VERIFY_PEER here. The per-SSL client override * (verify mode + trust store) lives in us_internal_ssl_attach. */ @@ -1693,6 +1685,16 @@ int us_socket_alpn_is_h2(struct us_socket_t *s) { /* ── Per-socket SSL attach/detach ────────────────────────────────────────── */ +/* A client verifies per SSL: raising the mode of the CTX would make a server that shares it send CertificateRequest. */ +void us_internal_ssl_client_verify_defaults(SSL *ssl, SSL_CTX *ctx) { + if (SSL_CTX_get_verify_mode(ctx) != SSL_VERIFY_NONE) return; + SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback); + /* A store that holds user CAs (ca / caFile / addCACert) stays in place. */ + if (us_ssl_ctx_has_user_ca(ctx)) return; + X509_STORE *roots = us_get_shared_default_ca_store(); + if (roots) SSL_set0_verify_cert_store(ssl, roots); +} + void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, int is_client, const char *sni, struct us_listen_socket_t *listener) { @@ -1715,27 +1717,7 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, * handshake when a resumed session's id differs from its own, so clients * keep none: a `session` stays usable under any client options. */ SSL_set_session_id_context(ssl, NULL, 0); - /* The CTX is mode-neutral and may have verify_mode == NONE (no - * ca/requestCert in options). Clients must always run verification so - * verify_error is populated for the JS rejectUnauthorized check — but - * setting VERIFY_PEER on the CTX would make a server using the same - * SecureContext send CertificateRequest. SSL_set_verify scopes the mode to - * this socket; SSL_set0_verify_cert_store gives it the process-shared root - * bundle without touching the CTX (servers using the same CTX never pay - * the ~150-root build). us_verify_callback returns 1 so the handshake - * never aborts here — JS reads verify_error and decides. */ - if (SSL_CTX_get_verify_mode(ctx) == SSL_VERIFY_NONE) { - SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback); - us_ex_idx_ensure(); - if (!SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx)) { - /* Default context: give this socket the process-shared root bundle. - * A context whose store holds user-provided CAs (ca/caFile options or - * addCACert) keeps using its own store - overriding it here would - * hide those CAs from chain verification. */ - X509_STORE *roots = us_get_shared_default_ca_store(); - if (roots) SSL_set0_verify_cert_store(ssl, roots); - } - } + us_internal_ssl_client_verify_defaults(ssl, ctx); } else { SSL_set_accept_state(ssl); SSL_set_renegotiate_mode(ssl, ssl_renegotiate_never); @@ -2995,9 +2977,7 @@ struct us_socket_t *us_socket_adopt_tls(struct us_socket_t *s, * listener for an adopted socket, so SNI resolves from the single ssl_ctx. */ us_internal_ssl_attach(new_s, ssl_ctx, is_client, sni, NULL); if (!is_client && new_s->ssl) { - /* Node's TLSWrap::SetVerifyMode runs unconditionally on server sockets: - * !requestCert must force SSL_VERIFY_NONE, or a shared SSL_CTX built with - * `ca` leaks its FAIL_IF_NO_PEER_CERT mode and rejects cert-less clients. */ + /* As in Node's TLSWrap::SetVerifyMode, the policy is this TLSSocket's, not the one the shared SSL_CTX was built with. */ SSL_set_verify(new_s->ssl, request_cert ? SSL_VERIFY_PEER | (reject_unauthorized diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 01fc1c5fe653..95b125671751 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -553,7 +553,7 @@ enum create_bun_socket_error_t { * * Mode-neutral: the same SSL_CTX may back client connects and server accepts * (Node's createSecureContext semantics). CTX-level verify mode is derived - * purely from options.{ca,request_cert,reject_unauthorized}; the per-socket + * purely from options.{request_cert,reject_unauthorized}; the per-socket * client override happens in us_internal_ssl_attach so a server using this * SSL_CTX never sends CertificateRequest unless options asked it to. Reneg * limits attach as SSL_CTX ex_data. */ @@ -573,6 +573,8 @@ long us_ssl_ctx_live_count(void); /* Appends the certificates in the PEM `content` to `ctx`'s trust store; * returns 0 when nothing could be added. */ int us_ssl_ctx_add_ca_cert(struct ssl_ctx_st *ctx, const char *content); +/* The client half of us_internal_ssl_attach, for an SSL that no us_socket_t drives. */ +void us_internal_ssl_client_verify_defaults(struct ssl_st *ssl, struct ssl_ctx_st *ctx); /* 1 when the verify step of this handshake asked the owner for the server's name. */ int us_ssl_identity_checked(struct ssl_st *ssl); /* For an SSL that no us_socket_t drives: its callbacks go to `wrapper`, which must outlive `ssl`. */ diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index a9bd0d61d72c..1be83418aa6c 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -267,16 +267,10 @@ function emitListenErrorNextTick(self, err) { self.emit("error", err); } -// Node.js only requests a client certificate when `requestCert: true`. -// The uSockets SSL context treats `ca` alone as "verify peer", so without -// these two flags an `https.Server({ ca })` would reject every client that -// doesn't present a cert. Mirror tls.Server (net.ts): default `requestCert` -// to false and, when not requesting, force `rejectUnauthorized` to false so -// the CA is loaded into the trust store without requiring a client cert. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1367-L1368 function normalizeServerTls(tls) { - const requestCert = !!tls.requestCert; - tls.requestCert = requestCert; - tls.rejectUnauthorized = requestCert ? tls.rejectUnauthorized !== false : false; + tls.requestCert = tls.requestCert === true; + tls.rejectUnauthorized = tls.rejectUnauthorized !== false; return tls; } @@ -630,7 +624,7 @@ Server.prototype.listen = function () { const otherTLS = arg0.tls; if (otherTLS && $isObject(otherTLS)) { - tls = normalizeServerTls({ ...otherTLS }); + tls = normalizeServerTls({ __proto__: null, ...otherTLS }); } } else if (typeof arg0 === "string" && !(Number(arg0) >= 0)) { // (path[...][, cb]) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index d38d26ba5f5b..87e1f5f6ad32 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1348,8 +1348,7 @@ function Server(options, secureConnectionListener): void { ecdhCurve: this.ecdhCurve ?? DEFAULT_ECDH_CURVE, passphrase: this.passphrase, secureOptions: this.secureOptions, - // A server that requests no client certificate has none to reject. - rejectUnauthorized: requestCert ? this._rejectUnauthorized : false, + rejectUnauthorized: this._rejectUnauthorized, requestCert, ALPNProtocols: this.ALPNProtocols, clientRenegotiationLimit: CLIENT_RENEG_LIMIT, diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 1edee5fede7d..c0077f0e0635 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -4232,7 +4232,7 @@ fn upgrade_reject_policy( /// A bare server-side `secureContext` carries no parsed config, so the policy /// comes from the ctx itself: `us_ssl_ctx_from_options` sets -/// `FAIL_IF_NO_PEER_CERT` iff the context was created with `rejectUnauthorized`. +/// `FAIL_IF_NO_PEER_CERT` iff the context was created with `requestCert` and `rejectUnauthorized`. fn server_ctx_rejects_unauthorized(ctx: Option<*mut SSL_CTX>) -> bool { let Some(ctx) = ctx else { return false }; const MODE: c_int = diff --git a/src/uws/lib.rs b/src/uws/lib.rs index e29796eb0040..b1552520cfbf 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -162,16 +162,15 @@ pub mod ssl_wrapper { mod boring_sys { pub(super) use bun_boringssl::c::{ BIO_ctrl_pending, BIO_free, BIO_new, BIO_read, BIO_reset, BIO_s_mem, - BIO_set_mem_eof_return, BIO_write, ERR_clear_error, OwnedSslCtx, SSL, - SSL_CTX_get_verify_mode, SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, - SSL_ERROR_WANT_RENEGOTIATE, SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, - SSL_RECEIVED_SHUTDOWN, SSL_SESSION, SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, - SSL_VERIFY_NONE, SSL_VERIFY_PEER, SSL_do_handshake, SSL_free, SSL_get_error, - SSL_get_rbio, SSL_get_shutdown, SSL_get_verify_result, SSL_get_wbio, - SSL_is_init_finished, SSL_new, SSL_pending, SSL_read, SSL_renegotiate, - SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, SSL_set_renegotiate_mode, - SSL_set_session_id_context, SSL_set_verify, SSL_set0_verify_cert_store, SSL_shutdown, - SSL_write, X509_STORE, X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never, + BIO_set_mem_eof_return, BIO_write, ERR_clear_error, OwnedSslCtx, SSL, SSL_CTX, + SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, + SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_SESSION, + SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, SSL_VERIFY_NONE, SSL_VERIFY_PEER, + SSL_do_handshake, SSL_free, SSL_get_error, SSL_get_rbio, SSL_get_shutdown, + SSL_get_verify_result, SSL_get_wbio, SSL_is_init_finished, SSL_new, SSL_pending, + SSL_read, SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, + SSL_set_renegotiate_mode, SSL_set_session_id_context, SSL_set_verify, SSL_shutdown, + SSL_write, X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never, }; } @@ -476,32 +475,7 @@ pub mod ssl_wrapper { boring_sys::SSL_set_connect_state(ssl.as_ptr()); // A client keeps no session id context (see `us_internal_ssl_attach`). boring_sys::SSL_set_session_id_context(ssl.as_ptr(), core::ptr::null(), 0); - // Mirror `us_internal_ssl_attach`: a SecureContext is - // mode-neutral, so a `tls.connect()` without - // `ca`/`requestCert` hands us a CTX with VERIFY_NONE and - // no trust store. Clients must always run verification so - // `verify_error` is real for the JS-side - // `rejectUnauthorized` decision; load the shared system - // roots per-SSL so a server using the same CTX never sees - // CertificateRequest. (Pre-redesign this happened by - // accident: net.ts forced `requestCert: true` after - // `[buntls]` and `SSLConfig.fromJS` rebuilt the CTX with - // roots from that.) - if boring_sys::SSL_CTX_get_verify_mode(ctx.as_ptr()) - == boring_sys::SSL_VERIFY_NONE - { - boring_sys::SSL_set_verify( - ssl.as_ptr(), - boring_sys::SSL_VERIFY_PEER, - Some(always_continue_verify), - ); - if let Some(roots) = NonNull::new(us_get_shared_default_ca_store()) { - let _ = boring_sys::SSL_set0_verify_cert_store( - ssl.as_ptr(), - roots.as_ptr(), - ); - } - } + us_internal_ssl_client_verify_defaults(ssl.as_ptr(), ctx.as_ptr()); } else { // Set the renegotiation mode to never so that we can't // renegotiate on the server side (security reasons). @@ -597,20 +571,7 @@ pub mod ssl_wrapper { Self::init_with_ctx(ssl_ctx, is_client, handlers) } - /// Mirror `us_socket_adopt_tls`'s server-side `SSL_set_verify` override. - /// - /// `us_ssl_ctx_from_options` turns on `SSL_VERIFY_PEER | - /// SSL_VERIFY_FAIL_IF_NO_PEER_CERT` whenever the options carry a `ca`, - /// because for a server that flag is what decides whether a - /// CertificateRequest is sent. Node instead keys that off `requestCert` - /// alone, so a server given `ca` but no `requestCert` must not ask for - /// a client certificate. The real-fd upgrade path already corrects - /// this per-SSL; without the same correction a duplex-wrapped server - /// rejects every cert-less client with UNABLE_TO_GET_ISSUER_CERT. - /// - /// No-op for clients: their verify mode is set in `init_with_ctx` so - /// `verify_error` is populated for the JS `rejectUnauthorized` - /// decision. + /// As in `us_socket_adopt_tls`, a server's own `requestCert` / `rejectUnauthorized` replace the mode of the shared `SSL_CTX`. No-op for clients. pub fn set_server_verify(&self, request_cert: bool, reject_unauthorized: bool) { if self.flags.is_client() { return; @@ -1507,11 +1468,11 @@ pub mod ssl_wrapper { } unsafe extern "C" { - /// Process-wide bundled root store from `root_certs.cpp` — built once and - /// up_ref'd per consumer so the ~150-cert load happens once total, not per - /// CTX. Returns null if root loading fails (treated as "no roots"). - // safe: no args; idempotent lazy init reading a process global — no preconditions. - safe fn us_get_shared_default_ca_store() -> *mut boring_sys::X509_STORE; + /// openssl.c: the client half of `us_internal_ssl_attach`. `ssl` was made from `ctx`. + fn us_internal_ssl_client_verify_defaults( + ssl: *mut boring_sys::SSL, + ctx: *mut boring_sys::SSL_CTX, + ); /// Implemented in uSockets C; reads /// `SSL_get_verify_result` and maps it onto the C `us_bun_verify_error_t`. fn us_ssl_socket_verify_error_from_ssl(ssl: *mut boring_sys::SSL) -> us_bun_verify_error_t; diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index 18f5223a9266..9028bac995d5 100644 --- a/src/uws_sys/SocketContext.rs +++ b/src/uws_sys/SocketContext.rs @@ -161,14 +161,8 @@ impl Default for BunSocketContextOptions { impl BunSocketContextOptions { /// Build a BoringSSL `SSL_CTX` from these options. The passphrase is freed - /// inside this call once private-key load completes. - /// - /// Mode-neutral: the same `SSL_CTX*` may back client connects and server - /// accepts. CTX-level verify mode comes from `request_cert`/`ca`/ - /// `reject_unauthorized` here; the per-socket client override (always run - /// chain validation, populate verify_error) is applied in - /// `us_internal_ssl_attach`, so a server reusing this ctx never sends - /// CertificateRequest unless these options asked it to. + /// inside this call once private-key load completes. The verify mode follows + /// `request_cert`/`reject_unauthorized` only; see `us_ssl_ctx_from_options`. pub fn create_ssl_context(self, err: &mut create_bun_socket_error_t) -> Option { // SAFETY: FFI call; `self` is `#[repr(C)]` and passed by value, `err` is a valid out-param. // A non-null return carries the +1 from `SSL_CTX_new`. diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 286bea043aed..73ffdb169ade 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1894,6 +1894,43 @@ describe("Bun.serve HTTP/3 request validation", () => { expect({ selfSigned, chained }).toEqual({ selfSigned: "closed", chained: "200 1" }); }); + + test("a `ca` without requestCert serves cert-less QUIC clients, and a per-serverName rejectUnauthorized: false tolerates an unverifiable certificate", async () => { + const keysDir = join(import.meta.dir, "..", "..", "node", "test", "fixtures", "keys"); + const pem = (name: string) => readFileSync(join(keysDir, name), "utf8"); + const credentials = { key: pem("agent1-key.pem"), cert: pem("agent1-cert.pem"), ca: pem("ca1-cert.pem") }; + await using server = Bun.serve({ + port: 0, + tls: [ + // No requestCert: the `ca` must not make the server ask for a certificate. + credentials, + // Asks, but accepts whatever comes back. agent2 is self-signed, so ca1 cannot verify it. + { ...credentials, serverName: "lenient.example.com", requestCert: true, rejectUnauthorized: false }, + ], + http3: true, + fetch: () => new Response("served"), + }); + + const agent2 = { + keys: [createPrivateKey(pem("agent2-key.pem"))], + certs: [readFileSync(join(keysDir, "agent2-cert.pem"))], + }; + const lenient = { servername: "lenient.example.com" }; + const results = { + defaultNoCert: await h3Exchange(server.port, requestHeaders("/")), + lenientNoCert: await h3Exchange(server.port, requestHeaders("/", { ":authority": lenient.servername }), lenient), + lenientUnverifiable: await h3Exchange(server.port, requestHeaders("/", { ":authority": lenient.servername }), { + ...lenient, + ...agent2, + }), + }; + + expect(results).toEqual({ + defaultNoCert: "200 served", + lenientNoCert: "200 served", + lenientUnverifiable: "200 served", + }); + }); }); // RFC 9110 section 10.1.1: a client that sent Expect: 100-continue can hold the diff --git a/test/js/node/tls/fetch-tls-cert.test.ts b/test/js/node/tls/fetch-tls-cert.test.ts index dba9c82914ea..3de128d6294f 100644 --- a/test/js/node/tls/fetch-tls-cert.test.ts +++ b/test/js/node/tls/fetch-tls-cert.test.ts @@ -98,10 +98,7 @@ it("rejects a client cert the server's CA cannot verify, every time", async () = expect(served).toBe(0); }); -// Known gap: `ca` on a Bun.serve TLS server sets SSL_VERIFY_PEER|FAIL_IF_NO_PEER_CERT even -// without `requestCert`, so the server demands a client cert and aborts (ECONNRESET). -// Node only requests one when `requestCert: true`. -it.todo("complete cert chains sent to peer, but without requesting client's cert.", async () => { +it("complete cert chains sent to peer, but without requesting client's cert.", async () => { await connect({ client: { ca: server.ca, diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 7a5c9abbe3fd..aa61eede5074 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -2362,6 +2362,53 @@ describe("a TLS socket over a Duplex transport reports that transport's error", }); }); +it("verifies against the CAs of its context when the TLS socket rides on a Duplex", async () => { + const fixtures = join(import.meta.dir, "fixtures"); + const ca1 = readFileSync(join(fixtures, "ca1-cert.pem"), "utf8"); + const server = tls.createServer( + { + key: readFileSync(join(fixtures, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "agent1-cert.pem"), "utf8"), + }, + socket => socket.end("hi"), + ); + await once(server.listen(0, "127.0.0.1"), "listening"); + + async function outcome(connectOptions: tls.ConnectionOptions): Promise { + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + await once(raw, "connect"); + const client = tls.connect({ socket: new SocketProxy(raw), servername: "agent1", ...connectOptions }); + const { promise, resolve } = Promise.withResolvers(); + client.on("secureConnect", () => resolve(`authorized=${client.authorized}`)); + client.on("error", err => resolve((err as NodeJS.ErrnoException).code!)); + client.on("close", () => resolve("closed")); + try { + return await promise; + } finally { + client.destroy(); + raw.destroy(); + } + } + + try { + const extended = tls.createSecureContext(); + extended.context.addCACert(ca1); + expect({ + ca: await outcome({ ca: ca1 }), + secureContext: await outcome({ secureContext: tls.createSecureContext({ ca: ca1 }) }), + addCACert: await outcome({ secureContext: extended }), + none: await outcome({}), + }).toEqual({ + ca: "authorized=true", + secureContext: "authorized=true", + addCACert: "authorized=true", + none: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + } finally { + server.close(); + } +}); + it("delivers 'session' even when the data handler destroys the socket immediately", async () => { // The TLS1.3 NewSessionTickets ride in the same read pass as the response // bytes. If the parked session were only flushed after the data dispatch, diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 134ef654ab79..678d74590afb 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1597,8 +1597,6 @@ describe("setSecureContext() on a listening server", () => { }); it("keeps accepting certificate-less clients when the server never set requestCert", async () => { - // listen() clamps rejectUnauthorized off for such a server. A rebuild that - // skipped the clamp would demand a client certificate because `ca` is set. const server: Server = createServer({ ...agent1, ca: ca1 }); const clientErrors: unknown[] = []; server.on("tlsClientError", err => clientErrors.push(err)); @@ -2408,9 +2406,9 @@ describe("tls.Server secure-context options", () => { }); it("accepts a cert-less client on a STARTTLS-wrapped connection when the server has `ca` but no requestCert", async () => { - // A shared SecureContext built with `ca` carries FAIL_IF_NO_PEER_CERT on - // its SSL_CTX; Node's TLSWrap::SetVerifyMode overrides it per socket to - // SSL_VERIFY_NONE for !requestCert, so an ordinary client still connects: + // The verify mode of a wrapped server socket comes from its own + // requestCert, as in Node's TLSWrap::SetVerifyMode, and a server `ca` + // never asks for a certificate by itself, so an ordinary client connects: // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1225-L1234 const tlsServer = createServer({ key: agent6Key, cert: agent6CertChain, ca: [ca3Cert, ca1Cert] }); const judged = Promise.withResolvers<{ secure: boolean }>(); @@ -3003,6 +3001,34 @@ describe("node v26.3.0 tls.Server parity follow-ups", () => { server.close(); } }); + + it("https.Server treats a truthy-but-not-true requestCert like false as well", async () => { + const server = https.createServer({ ...COMMON_CERT, requestCert: 1 as unknown as boolean }, (req, res) => { + res.end("served"); + }); + let client: TLSSocket | undefined; + try { + const port = await listen(server as unknown as Server); + const outcome = Promise.withResolvers(); + client = connect({ + port, + host: "127.0.0.1", + rejectUnauthorized: false, + key: COMMON_CERT.key, + cert: COMMON_CERT.cert, + }); + client.on("secureConnect", () => client!.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")); + let response = ""; + client.on("data", chunk => (response += chunk)); + client.on("error", err => outcome.resolve((err as Error & { code?: string }).code ?? err.message)); + client.on("close", () => outcome.resolve(response.split("\r\n").at(-1) || "closed without a response")); + // The server does not trust this self-signed certificate: it would refuse it if it had asked. + expect(await outcome.promise).toBe("served"); + } finally { + client?.destroy(); + server.close(); + } + }); }); describe("throwing 'secureConnection' listener", () => { From 71fbff5a2a23045d1e4ee1184ff6ccaf0120f488 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:27:09 +0000 Subject: [PATCH 046/174] tls: NODE_TLS_REJECT_UNAUTHORIZED=0 does not weaken a server (#35245) An unset rejectUnauthorized took its default from the variable whatever the role, so a process that set it for its outbound requests also stopped its Bun.serve / Bun.listen / upgradeTLS({ isServer }) servers from enforcing requestCert. The default is resolved where "unset" is still visible, so SSLConfig::from_js takes the role: true for a server, the variable for a client. A SecureContext takes the server default, since only a server reads the value back from a context. The test runs every kind of server x TLS 1.2/1.3 x {no, untrusted, trusted client certificate} with the variable unset and 0. --- docs/runtime/http/server.mdx | 6 + docs/runtime/http/tls.mdx | 17 ++ packages/bun-types/bun.d.ts | 11 +- src/runtime/api/bun/SecureContext.rs | 9 +- src/runtime/hw_exports.rs | 2 +- src/runtime/server/ServerConfig.rs | 6 +- src/runtime/socket/Handlers.rs | 6 +- src/runtime/socket/Listener.rs | 4 +- src/runtime/socket/SSLConfig.rs | 15 +- src/runtime/socket/socket_body.rs | 8 +- src/runtime/valkey_jsc/js_valkey.rs | 2 +- src/runtime/webcore/fetch/FetchSession.rs | 2 +- test/js/bun/http/bun-serve-ssl.test.ts | 68 ++++++- .../http/tls-client-cert-policy-fixture.mjs | 183 ++++++++++++++++++ 14 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 test/js/bun/http/tls-client-cert-policy-fixture.mjs diff --git a/docs/runtime/http/server.mdx b/docs/runtime/http/server.mdx index 2c375f63faeb..9be7d8385804 100644 --- a/docs/runtime/http/server.mdx +++ b/docs/runtime/http/server.mdx @@ -753,6 +753,12 @@ interface TLSOptions { /** Private key passphrase */ passphrase?: string; + /** Refuse a peer whose certificate does not verify (default: true) */ + rejectUnauthorized?: boolean; + + /** Ask clients for a certificate (default: false) */ + requestCert?: boolean; + /** OpenSSL options flags */ secureOptions?: number; diff --git a/docs/runtime/http/tls.mdx b/docs/runtime/http/tls.mdx index 7ff46040c4c6..220ed998d791 100644 --- a/docs/runtime/http/tls.mdx +++ b/docs/runtime/http/tls.mdx @@ -55,6 +55,23 @@ Bun.serve({ }); ``` +### Client certificates (mTLS) + +A server asks clients for a certificate only when `requestCert` is `true`. `ca` on its own does not: it names the CAs that client certificates are verified against. + +```ts +Bun.serve({ + tls: { + key: Bun.file("./key.pem"), + cert: Bun.file("./cert.pem"), + ca: Bun.file("./client-ca.pem"), // CA that issues the client certificates + requestCert: true, // [!code ++] + }, +}); +``` + +With `requestCert: true`, the server refuses a client that sends no certificate or one that `ca` does not verify. Set `rejectUnauthorized: false` to accept those clients too. The `NODE_TLS_REJECT_UNAUTHORIZED` environment variable does not change this for a server. + ### Diffie-Hellman To override Diffie-Hellman parameters: diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 074e9bca5565..fba2660c596a 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4781,12 +4781,16 @@ declare module "bun" { /** * If set to `false`, any certificate is accepted. - * Default is `$NODE_TLS_REJECT_UNAUTHORIZED` environment variable, or `true` if it is not set. + * + * For a server, the default is `true`. `$NODE_TLS_REJECT_UNAUTHORIZED` does not change it. + * For a client, the default is the `$NODE_TLS_REJECT_UNAUTHORIZED` environment variable, or `true` if it is not set. */ rejectUnauthorized?: boolean; /** - * If set to `true`, the server requests a client certificate. + * If set to `true`, the server requests a client certificate, and refuses + * a client without a valid one unless `rejectUnauthorized` is `false`. + * This option alone decides it: `ca` does not request a certificate. * * Default is `false`. */ @@ -4796,6 +4800,9 @@ declare module "bun" { * Optionally override the trusted CA certificates. Default is to trust * the well-known CAs curated by Mozilla. Mozilla's CAs are completely * replaced when CAs are explicitly specified using this option. + * + * On a server these verify client certificates, which it requests only + * with `requestCert: true`. */ ca?: string | BufferSource | BunFile | Array | undefined; /** diff --git a/src/runtime/api/bun/SecureContext.rs b/src/runtime/api/bun/SecureContext.rs index e7e1ce6c2e71..7a5011a64578 100644 --- a/src/runtime/api/bun/SecureContext.rs +++ b/src/runtime/api/bun/SecureContext.rs @@ -75,7 +75,8 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); - let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + // Only a server reads `rejectUnauthorized` back from a context (`server_ctx_rejects_unauthorized`). + let config = SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); // `defer config.deinit()` — handled by Drop. SecureContext::create(global, &config) @@ -233,7 +234,8 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); - let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + // Only a server reads `rejectUnauthorized` back from a context (`server_ctx_rejects_unauthorized`). + let config = SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); // `defer config.deinit()` — handled by Drop. let ctx_opts = config.as_usockets(); @@ -273,7 +275,8 @@ impl SecureContext { // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. let vm = global.bun_vm().as_mut(); - let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + // Only a server reads `rejectUnauthorized` back from a context (`server_ctx_rejects_unauthorized`). + let config = SSLConfig::from_js(vm, global, opts, true)?.unwrap_or_else(SSLConfig::zero); // `defer config.deinit()` — handled by Drop. let ctx_opts = config.as_usockets(); diff --git a/src/runtime/hw_exports.rs b/src/runtime/hw_exports.rs index 350996358d76..ed1404e8a2c0 100644 --- a/src/runtime/hw_exports.rs +++ b/src/runtime/hw_exports.rs @@ -239,7 +239,7 @@ mod sql_hooks { } unsafe fn ssl_config_from_js(global: &JSGlobalObject, value: JSValue) -> *mut c_void { use crate::socket::SSLConfigFromJs; - match crate::socket::SSLConfig::from_js(global.bun_vm_ref(), global, value) { + match crate::socket::SSLConfig::from_js(global.bun_vm_ref(), global, value, false) { Ok(Some(cfg)) => bun_core::heap::into_raw(Box::new(cfg)).cast::(), Ok(None) => core::ptr::null_mut(), Err(bun_jsc::JsError::OutOfMemory) => { diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 5eb758900f9e..0dacf2664f04 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -1236,7 +1236,7 @@ impl ServerConfig { // Empty TLS array means no TLS - this is valid } else { while let Some(item) = value_iter.next()? { - let Some(ssl_config) = SSLConfig::from_js(vm, global, item)? else { + let Some(ssl_config) = SSLConfig::from_js(vm, global, item, true)? else { // Backwards-compatibility; we ignored empty tls objects. continue; }; @@ -1259,7 +1259,7 @@ impl ServerConfig { } } } else { - if let Some(ssl_config) = SSLConfig::from_js(vm, global, tls)? { + if let Some(ssl_config) = SSLConfig::from_js(vm, global, tls, true)? { args.ssl_config = Some(ssl_config); } } @@ -1268,7 +1268,7 @@ impl ServerConfig { // @compatibility Bun v0.x - v0.2.1 // this used to be top-level, now it's "tls" object if args.ssl_config.is_none() { - if let Some(ssl_config) = SSLConfig::from_js(vm, global, arg)? { + if let Some(ssl_config) = SSLConfig::from_js(vm, global, arg, true)? { args.ssl_config = Some(ssl_config); } } diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 16498c50be56..1b737bb33f1a 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -503,12 +503,14 @@ impl SocketConfig { GeneratedTls::None => None, GeneratedTls::Boolean(b) => { if *b { - Some(super::tls_true_defaults(vm)) + Some(super::tls_true_defaults(vm, mode.is_server())) } else { None } } - GeneratedTls::Object(ssl) => SSLConfig::from_generated(vm, global, ssl)?, + GeneratedTls::Object(ssl) => { + SSLConfig::from_generated(vm, global, ssl, mode.is_server())? + } }; break 'blk SocketConfig { hostname_or_unix: Utf8Bytes::EMPTY, diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 3351c245748e..f305a5fd582c 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -760,7 +760,7 @@ impl Listener { } else if let Some(ssl_config) = { // SAFETY: per-thread VM; valid for program lifetime. let vm = VirtualMachine::get().as_mut(); - SSLConfig::from_js(vm, global, tls)? + SSLConfig::from_js(vm, global, tls, true)? } { // Note: `cfg` cleanup handled by Drop on SSLConfig let mut create_err = uws::create_bun_socket_error_t::none; @@ -808,7 +808,7 @@ impl Listener { } // SAFETY: per-thread VM; valid for program lifetime. let vm = VirtualMachine::get().as_mut(); - let Some(ssl_config) = SSLConfig::from_js(vm, global, tls)? else { + let Some(ssl_config) = SSLConfig::from_js(vm, global, tls, true)? else { return Ok(JSValue::UNDEFINED); }; let mut create_err = uws::create_bun_socket_error_t::none; diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 5f4634c4409f..8f6bfd20cf36 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -120,17 +120,20 @@ fn read_from_blob( /// JSC-dependent constructors for the canonical `bun_http::SSLConfig`. /// Import this trait to call `SSLConfig::from_js(..)` / `::from_generated(..)`. +/// An unset `rejectUnauthorized` is `true` for a server and `NODE_TLS_REJECT_UNAUTHORIZED` for a client, as in Node.js. pub(crate) trait SSLConfigFromJs: Sized { fn from_js( vm: &VirtualMachine, global: &JSGlobalObject, value: JSValue, + is_server: bool, ) -> JsResult>; fn from_generated( vm: &VirtualMachine, global: &JSGlobalObject, generated: &jsc::generated::SSLConfig, + is_server: bool, ) -> JsResult>; } @@ -139,16 +142,18 @@ impl SSLConfigFromJs for SSLConfig { vm: &VirtualMachine, global: &JSGlobalObject, value: JSValue, + is_server: bool, ) -> JsResult> { let generated = jsc::generated::SSLConfig::from_js(global, value)?; // `generated` dropped at scope exit - Self::from_generated(vm, global, &generated) + Self::from_generated(vm, global, &generated, is_server) } fn from_generated( vm: &VirtualMachine, global: &JSGlobalObject, generated: &jsc::generated::SSLConfig, + is_server: bool, ) -> JsResult> { let mut result = SSLConfig::zero(); // `result` cleanup handled by Drop on error-path `?` @@ -177,7 +182,7 @@ impl SSLConfigFromJs for SSLConfig { result.low_memory_mode = generated.low_memory_mode; result.reject_unauthorized = generated .reject_unauthorized - .unwrap_or_else(|| vm.get_tls_reject_unauthorized()) + .unwrap_or_else(|| is_server || vm.get_tls_reject_unauthorized()) as i32; result.request_cert = generated.request_cert as i32; result.secure_options = generated.secure_options; @@ -275,9 +280,9 @@ impl SSLConfigFromJs for SSLConfig { /// The `SSLConfig` for the `tls: true` shorthand: every option at its /// documented default, unlike `SSLConfig::zero()`. -pub(crate) fn tls_true_defaults(vm: &VirtualMachine) -> SSLConfig { +pub(crate) fn tls_true_defaults(vm: &VirtualMachine, is_server: bool) -> SSLConfig { let mut cfg = SSLConfig::zero(); - cfg.reject_unauthorized = vm.get_tls_reject_unauthorized() as i32; + cfg.reject_unauthorized = (is_server || vm.get_tls_reject_unauthorized()) as i32; cfg } @@ -462,7 +467,7 @@ extern "C" fn Bun__WebSocket__parseSSLConfig( // constructor only runs on the JS thread with an initialized VM. let vm = global_this.bun_vm(); // Use SSLConfig::from_js for clean and safe parsing - let config_opt = match SSLConfig::from_js(vm, global_this, tls_value) { + let config_opt = match SSLConfig::from_js(vm, global_this, tls_value, false) { Ok(c) => c, // Exception is already set on globalThis Err(_) => return None, diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index c0077f0e0635..40610435c4cc 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -3538,6 +3538,7 @@ impl NewSocket { VirtualMachine::get().as_mut(), global, t, + is_server, )?; } } @@ -3548,9 +3549,10 @@ impl NewSocket { VirtualMachine::get().as_mut(), global, tls_js, + is_server, )?; } else if tls_js.to_boolean() { - ssl_opts = Some(crate::socket::tls_true_defaults(handlers.vm)); + ssl_opts = Some(crate::socket::tls_true_defaults(handlers.vm, is_server)); } let cfg = ssl_opts .as_mut() @@ -4766,9 +4768,9 @@ pub(crate) fn js_upgrade_duplex_to_tls( // Drop frees ssl_opts on error. if let Some(tls) = opts.get_truthy(global, "tls")? { if !tls.is_boolean() { - ssl_opts = SSLConfig::from_js(handlers.vm, global, tls)?; + ssl_opts = SSLConfig::from_js(handlers.vm, global, tls, is_server)?; } else if tls.to_boolean() { - ssl_opts = Some(crate::socket::tls_true_defaults(handlers.vm)); + ssl_opts = Some(crate::socket::tls_true_defaults(handlers.vm, is_server)); } } if owned_ctx.is_none() && ssl_opts.is_none() { diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index 6e75314b8541..9f7432af94b7 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -2039,7 +2039,7 @@ impl Options { } else if tls.is_object() { // SAFETY: `bun_vm()` returns the live per-global VM pointer. if let Some(ssl_config) = - SSLConfig::from_js(global_object.bun_vm(), global_object, tls)? + SSLConfig::from_js(global_object.bun_vm(), global_object, tls, false)? { this.tls = valkey::TLS::Custom(Box::new(ssl_config)); } else { diff --git a/src/runtime/webcore/fetch/FetchSession.rs b/src/runtime/webcore/fetch/FetchSession.rs index d5319237838f..8e2b844326c1 100644 --- a/src/runtime/webcore/fetch/FetchSession.rs +++ b/src/runtime/webcore/fetch/FetchSession.rs @@ -67,7 +67,7 @@ pub(crate) fn parse_tls( )); } } - if let Some(config) = SSLConfig::from_js(vm, global, tls)? { + if let Some(config) = SSLConfig::from_js(vm, global, tls, false)? { parsed.ssl_config = Some(http::ssl_config::global_registry::intern(config)); } Ok(parsed) diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index a09227e1a176..9d2cfc83ab45 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "fs"; +import { bunEnv, bunExe, tempDir, tls as tlsCert } from "harness"; import { once } from "node:events"; import net from "node:net"; - -import { tempDir, tls as tlsCert } from "harness"; import tls from "node:tls"; import { join } from "path"; import privateKey from "../../third_party/jsonwebtoken/priv.pem" with { type: "text" }; @@ -572,3 +571,68 @@ test("keyFile/certFile/caFile/dhParamsFile reject a path with a NUL byte instead "Unable to access keyFile path", ); }); + +describe.concurrent("client certificate policy", () => { + async function run(NODE_TLS_REJECT_UNAUTHORIZED: string | undefined, policies: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "tls-client-cert-policy-fixture.mjs"), ...policies], + env: { ...bunEnv, NODE_TLS_REJECT_UNAUTHORIZED }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + return { table: JSON.parse(stdout), exitCode }; + } + // A client with no certificate | with one the server's `ca` did not issue | with one it issued. + const rows = { + "ca": ["anonymous", "anonymous", "anonymous"], + "caFile": ["anonymous", "anonymous", "anonymous"], + "ca, requestCert": [null, null, "agent10.example.com"], + "ca, requestCert, rejectUnauthorized: false": ["anonymous", "agent2", "agent10.example.com"], + }; + function everyServer(policies: (keyof typeof rows)[]) { + const table = (namesItsPeer: boolean) => + Object.fromEntries( + policies.flatMap(policy => { + const row = rows[policy].map(peer => (peer ? `served ${namesItsPeer ? peer : "someone"}` : "refused")); + return ["TLSv1.2", "TLSv1.3"].map(version => [`${policy} (${version})`, row.join(" | ")]); + }), + ); + return { + "tls.createServer": table(true), + "tls.createServer over a Duplex": table(true), + "https.createServer": table(false), + "Bun.serve": table(false), + "Bun.serve serverName entry": table(false), + "Bun.listen": table(true), + "upgradeTLS({ isServer: true, tls })": table(true), + "upgradeTLS({ isServer: true, secureContext })": table(true), + }; + } + + test("no server asks for a certificate without requestCert", async () => { + expect(await run(undefined, ["ca", "caFile"])).toEqual({ table: everyServer(["ca", "caFile"]), exitCode: 0 }); + }, 30_000); + + describe.each([undefined, "0"])("with NODE_TLS_REJECT_UNAUTHORIZED=%p", NODE_TLS_REJECT_UNAUTHORIZED => { + test("every server enforces requestCert", async () => { + const policies = ["ca, requestCert", "ca, requestCert, rejectUnauthorized: false"] as const; + expect(await run(NODE_TLS_REJECT_UNAUTHORIZED, [...policies])).toEqual({ + table: everyServer([...policies]), + exitCode: 0, + }); + }, 30_000); + + test("a client that leaves rejectUnauthorized unset follows the variable", async () => { + const off = NODE_TLS_REJECT_UNAUTHORIZED === "0"; + expect(await run(NODE_TLS_REJECT_UNAUTHORIZED, [])).toEqual({ + table: { + "tls.connect": off ? "connected" : "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + fetch: off ? "connected" : "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "Bun.connect": off ? "connected" : "closed", + }, + exitCode: 0, + }); + }); + }); +}); diff --git a/test/js/bun/http/tls-client-cert-policy-fixture.mjs b/test/js/bun/http/tls-client-cert-policy-fixture.mjs new file mode 100644 index 000000000000..f775c8a6a3df --- /dev/null +++ b/test/js/bun/http/tls-client-cert-policy-fixture.mjs @@ -0,0 +1,183 @@ +// Prints what three clients get from every kind of TLS server under the client-certificate policies named in argv, +// or without any what a client that leaves rejectUnauthorized unset does. The node:* kinds run on Node.js too. +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import https from "node:https"; +import net from "node:net"; +import { join } from "node:path"; +import { Duplex } from "node:stream"; +import tls from "node:tls"; + +const keys = join(import.meta.dirname, "..", "..", "node", "test", "fixtures", "keys"); +const pem = name => readFileSync(join(keys, name), "utf8"); +// ec10 (CN=agent10.example.com) chains to ca5. ec (CN=agent2) is self-signed. +const credentials = { key: pem("ec10-key.pem"), cert: pem("ec10-cert.pem") }; +const ca = pem("ca5-cert.pem"); +const clients = { + none: {}, + untrusted: { key: pem("ec-key.pem"), cert: pem("ec-cert.pem") }, + trusted: credentials, +}; +const policies = { + "ca": { ca }, + "caFile": { caFile: join(keys, "ca5-cert.pem") }, + "ca, requestCert": { ca, requestCert: true }, + "ca, requestCert, rejectUnauthorized: false": { ca, requestCert: true, rejectUnauthorized: false }, +}; +const servername = "policy.example"; +const reply = who => `HTTP/1.1 200 OK\r\nContent-Length: ${who.length}\r\nConnection: close\r\n\r\n${who}`; +const peerName = socket => socket.getPeerCertificate()?.subject?.CN ?? "anonymous"; + +class OverDuplex extends Duplex { + constructor(socket) { + super(); + this.socket = socket; + socket.on("data", chunk => this.push(chunk)); + socket.on("end", () => this.push(null)); + socket.on("close", () => this.destroy()); + socket.on("error", () => {}); + } + _read() {} + _write(chunk, encoding, callback) { + this.socket.write(chunk, encoding, callback); + } + _final(callback) { + this.socket.end(callback); + } + _destroy(error, callback) { + this.socket.destroy(); + callback(error); + } +} + +function nodeTlsServer(policy) { + const server = tls.createServer({ ...credentials, ...policy }, socket => { + socket.on("error", () => {}); + socket.once("data", () => socket.end(reply(peerName(socket)))); + }); + server.on("tlsClientError", () => {}); + return server; +} +async function listening(server) { + await once(server.listen(0, "127.0.0.1"), "listening"); + return { port: server.address().port, close: () => server.close() }; +} +const bunSocketHandlers = { + data(socket) { + socket.end(reply(peerName(socket))); + }, + error() {}, +}; + +const servers = { + "tls.createServer": policy => listening(nodeTlsServer(policy)), + "tls.createServer over a Duplex": policy => { + const server = nodeTlsServer(policy); + return listening(net.createServer(raw => server.emit("connection", new OverDuplex(raw)))); + }, + "https.createServer": policy => { + const server = https.createServer({ ...credentials, ...policy }, (req, res) => res.end("someone")); + server.on("tlsClientError", () => {}); + return listening(server); + }, +}; +if (typeof Bun !== "undefined") { + const serve = tls => { + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", tls, fetch: () => new Response("someone") }); + return { port: server.port, close: () => server.stop(true) }; + }; + const listen = options => { + const listener = Bun.listen({ port: 0, hostname: "127.0.0.1", ...options }); + return { port: listener.port, close: () => listener.stop(true) }; + }; + servers["Bun.serve"] = policy => serve({ ...credentials, ...policy }); + servers["Bun.serve serverName entry"] = policy => + serve([credentials, { ...credentials, ...policy, serverName: servername }]); + servers["Bun.listen"] = policy => listen({ tls: { ...credentials, ...policy }, socket: bunSocketHandlers }); + const upgrade = options => + listen({ + socket: { + data(raw, initialData) { + raw.upgradeTLS({ isServer: true, initialData, socket: bunSocketHandlers, ...options }); + }, + error() {}, + }, + }); + servers["upgradeTLS({ isServer: true, tls })"] = policy => upgrade({ tls: { ...credentials, ...policy } }); + servers["upgradeTLS({ isServer: true, secureContext })"] = policy => + upgrade({ secureContext: tls.createSecureContext({ ...credentials, ...policy }).context }); +} + +const request = `GET / HTTP/1.1\r\nHost: ${servername}\r\nConnection: close\r\n\r\n`; +function outcome(port, client, version) { + const { promise, resolve } = Promise.withResolvers(); + let received = ""; + const done = () => resolve(received ? `served ${received.split("\r\n\r\n")[1]}` : "refused"); + // rejectUnauthorized: false, so that only the server can refuse. + if (typeof Bun !== "undefined") { + const protocol = { "TLSv1.2": 0x0303, "TLSv1.3": 0x0304 }[version]; + Bun.connect({ + hostname: "127.0.0.1", + port, + tls: { servername, rejectUnauthorized: false, minVersion: protocol, maxVersion: protocol, ...client }, + socket: { + handshake: socket => void socket.write(request), + data: (socket, chunk) => void (received += chunk), + close: done, + error() {}, + }, + }); + return promise; + } + const options = { servername, rejectUnauthorized: false, minVersion: version, maxVersion: version, ...client }; + const socket = tls.connect({ host: "127.0.0.1", port, ...options }, () => socket.write(request)); + socket.on("data", chunk => (received += chunk)); + socket.on("error", () => {}); + socket.on("close", done); + return promise; +} + +const table = {}; +await Promise.all( + Object.entries(servers).flatMap(([kind, start]) => + process.argv.slice(2).map(async name => { + const server = await start(policies[name]); + for (const version of ["TLSv1.2", "TLSv1.3"]) { + const row = await Promise.all(Object.values(clients).map(client => outcome(server.port, client, version))); + (table[kind] ??= {})[`${name} (${version})`] = row.join(" | "); + } + server.close(); + }), + ), +); + +if (process.argv.length === 2) { + const server = await servers["https.createServer"]({}); + table["tls.connect"] = await new Promise(resolve => { + const socket = tls.connect({ host: "127.0.0.1", port: server.port, servername }); + socket.on("secureConnect", () => resolve("connected")); + socket.on("error", error => resolve(error.code)); + }); + if (typeof Bun !== "undefined") { + table.fetch = await fetch(`https://127.0.0.1:${server.port}/`).then( + () => "connected", + error => error.code, + ); + table["Bun.connect"] = await new Promise(resolve => + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: true, + socket: { + handshake: socket => void socket.write(request), + data: () => resolve("connected"), + close: () => resolve("closed"), + error() {}, + }, + }), + ); + } +} + +console.log(JSON.stringify(table)); +process.exit(0); From 04847e203f23e5db3d9ba87c33fa25f36c2f6e93 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:29:58 +0000 Subject: [PATCH 047/174] tls: tls.DEFAULT_CIPHERS applies to every context built without ciphers (#41810) tls.connect() honoured an assignment: tls/https servers, Bun.serve, Bun.listen and Bun.connect kept BoringSSL's list. SSLConfig applies it again wherever no `ciphers` option is given. A list of TLS 1.3 suites only is stored as an empty TLS 1.2 list, which BoringSSL reads as "keep the built-in one", so it raises the minimum version to TLS 1.3, as in Node. Nothing changes until tls.DEFAULT_CIPHERS is assigned. --- src/jsc/VirtualMachine.rs | 7 +++ src/runtime/socket/SSLConfig.rs | 17 ++++++ test/js/node/tls/node-tls-server.test.ts | 72 ++++++++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index ea0de289575e..b4873eb211ef 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4189,6 +4189,13 @@ impl VirtualMachine { self.transpiler.env_mut().get_tls_reject_unauthorized() } + /// The TLS 1.2 list assigned through `tls.DEFAULT_CIPHERS`, `None` when never assigned. + pub fn tls_default_ciphers(&self) -> Option<&[u8]> { + self.rare_data + .as_deref() + .and_then(RareData::tls_default_ciphers) + } + /// Registers a spawned subprocess with the auto-killer. pub fn on_subprocess_spawn(&mut self, process: core::ptr::NonNull) { self.auto_killer.on_subprocess_spawn(process); diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 8f6bfd20cf36..dcbd0ce6fb9d 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -264,6 +264,8 @@ impl SSLConfigFromJs for SSLConfig { result.ssl_ciphers = zbox_into_raw(&ciphers.to_owned_slice_z()); result.is_using_default_ciphers = false; result.requires_custom_request_ctx = true; + } else { + apply_default_ciphers(vm, &mut result); } result.client_renegotiation_limit = generated.client_renegotiation_limit; @@ -283,9 +285,24 @@ impl SSLConfigFromJs for SSLConfig { pub(crate) fn tls_true_defaults(vm: &VirtualMachine, is_server: bool) -> SSLConfig { let mut cfg = SSLConfig::zero(); cfg.reject_unauthorized = (is_server || vm.get_tls_reject_unauthorized()) as i32; + apply_default_ciphers(vm, &mut cfg); cfg } +/// No `ciphers` option means `tls.DEFAULT_CIPHERS`, as in Node's `configSecureContext`. +fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { + let Some(ciphers) = vm.tls_default_ciphers() else { + return; + }; + cfg.ssl_ciphers = dupe_z(ciphers); + // An empty TLS 1.2 list would leave BoringSSL's built-in one in effect. + if ciphers.is_empty() { + cfg.ssl_min_version = cfg + .ssl_min_version + .max(i32::from(bun_boringssl_sys::TLS1_3_VERSION)); + } +} + /// Whether a new TLS socket must enforce `rejectUnauthorized`: close the /// connection when the peer certificate fails verification. pub(crate) fn resolve_reject_unauthorized( diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 678d74590afb..b605eedf7011 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3872,3 +3872,75 @@ it("addContext() with a NUL in the name registers nothing under the part before server.close(); } }); + +it("tls.DEFAULT_CIPHERS applies to every context built without a ciphers option", async () => { + // Without the Bun rows this script prints the same on Node.js v26.3.0. + const script = ` + import tls from "node:tls"; + import https from "node:https"; + import net from "node:net"; + import { once } from "node:events"; + const cert = ${JSON.stringify({ key: cert1.key, cert: cert1.cert })}; + const AES128 = "ECDHE-RSA-AES128-GCM-SHA256", AES256 = "ECDHE-RSA-AES256-GCM-SHA384"; + + function probe(port, ciphers) { + return new Promise(resolve => { + const c = tls.connect({ port, host: "127.0.0.1", ciphers, maxVersion: "TLSv1.2", rejectUnauthorized: false }); + c.on("secureConnect", () => (resolve(c.getCipher().name), c.destroy())); + c.on("error", e => resolve(e.code.replace(/^ERR_SSL_(SSLV3|SSL\\/TLS)_/, ""))); + }); + } + const listen = async server => (await once(server.listen(0, "127.0.0.1"), "listening"), server.address().port); + const end = socket => socket.end("x"); + + const ports = { before: await listen(tls.createServer(cert, end)) }; + tls.DEFAULT_CIPHERS = AES128; + ports.tls = await listen(tls.createServer(cert, end)); + ports.https = await listen(https.createServer(cert, (req, res) => res.end("x"))); + const injected = tls.createServer(cert, end); + ports.injected = await listen(net.createServer(raw => injected.emit("connection", raw))); + ports.explicit = await listen(tls.createServer({ ...cert, ciphers: AES256 }, end)); + const results = {}; + if (typeof Bun !== "undefined") { + ports.serve = Bun.serve({ port: 0, hostname: "127.0.0.1", tls: cert, fetch: () => new Response("x") }).port; + ports.listen = Bun.listen({ port: 0, hostname: "127.0.0.1", tls: cert, socket: { data() {} } }).port; + results.connect = await new Promise(resolve => + Bun.connect({ + hostname: "127.0.0.1", + port: ports.explicit, + tls: { rejectUnauthorized: false, maxVersion: 0x0303 }, + socket: { + handshake: (socket, success, error) => resolve(socket.getCipher().name ?? error.code), + close: () => resolve("closed"), + data() {}, + error() {}, + }, + }), + ); + } + for (const [name, port] of Object.entries(ports)) results[name] = [await probe(port, AES256), await probe(port, AES128)]; + + tls.DEFAULT_CIPHERS = "TLS_AES_128_GCM_SHA256"; + const tls13Only = await listen(tls.createServer(cert, end)); + results.tls13Only = [await probe(tls13Only, AES256), await probe(tls13Only, AES128)]; + console.log(JSON.stringify(results)); + process.exit(0); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const AES128 = "ECDHE-RSA-AES128-GCM-SHA256"; + const AES256 = "ECDHE-RSA-AES256-GCM-SHA384"; + expect(JSON.parse(stdout)).toEqual({ + before: [AES256, AES128], + tls: ["ALERT_HANDSHAKE_FAILURE", AES128], + https: ["ALERT_HANDSHAKE_FAILURE", AES128], + injected: ["ALERT_HANDSHAKE_FAILURE", AES128], + explicit: [AES256, "ALERT_HANDSHAKE_FAILURE"], + serve: ["ALERT_HANDSHAKE_FAILURE", AES128], + listen: ["ALERT_HANDSHAKE_FAILURE", AES128], + connect: "EPROTO", + tls13Only: ["ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION", "ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION"], + }); + expect(exitCode).toBe(0); +}); From 4d029a56e7c0f0895c6c221ccb126164e71af4ec Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:30:10 +0000 Subject: [PATCH 048/174] tls: accept Ed25519 and ECDSA P-521 signatures from a peer (#32235) BoringSSL's default list of signature algorithms to verify with has neither, so Bun never offered them and a server (or an mTLS client) with such a certificate failed the handshake. Every context now sets that default list plus ed25519 and ecdsa_secp521r1_sha512, before rsa_pkcs1_sha1, which stays last. Nothing is removed, and the `sigalgs` option still replaces the list. The HTTP/3 client and node:quic build their own SSL_CTX and call the same helper. A P-521 certificate still fails on TLS 1.2: there its curve must also be a supported group, and adding secp521r1 would change every ClientHello. --- packages/bun-usockets/src/crypto/openssl.c | 20 ++- packages/bun-usockets/src/quic.c | 2 + src/runtime/node/quic/tls.rs | 5 + test/js/node/quic/quic-sni.test.ts | 17 +++ test/js/node/tls/fixtures/ed25519-cert.pem | 10 ++ test/js/node/tls/fixtures/ed25519-key.pem | 3 + test/js/node/tls/fixtures/p521-cert.pem | 14 ++ test/js/node/tls/fixtures/p521-key.pem | 8 ++ test/js/node/tls/node-tls-connect.test.ts | 130 +++++++++++++++++++ test/js/web/fetch/fetch-http3-client.test.ts | 19 +++ 10 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 test/js/node/tls/fixtures/ed25519-cert.pem create mode 100644 test/js/node/tls/fixtures/ed25519-key.pem create mode 100644 test/js/node/tls/fixtures/p521-cert.pem create mode 100644 test/js/node/tls/fixtures/p521-key.pem diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 52881044a3bd..0fc5bbf5cd0b 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1216,6 +1216,17 @@ static void ssl_ctx_build_fail(SSL_CTX *ctx) { SSL_CTX_free(ctx); } +/* BoringSSL's kVerifySignatureAlgorithms (ssl/extensions.cc) plus the two it leaves out and Node.js accepts. */ +int us_ssl_ctx_set_verify_signature_algorithms(SSL_CTX *ctx) { + static const uint16_t algorithms[] = { + SSL_SIGN_ECDSA_SECP256R1_SHA256, SSL_SIGN_RSA_PSS_RSAE_SHA256, SSL_SIGN_RSA_PKCS1_SHA256, + SSL_SIGN_ECDSA_SECP384R1_SHA384, SSL_SIGN_RSA_PSS_RSAE_SHA384, SSL_SIGN_RSA_PKCS1_SHA384, + SSL_SIGN_RSA_PSS_RSAE_SHA512, SSL_SIGN_RSA_PKCS1_SHA512, SSL_SIGN_ED25519, + SSL_SIGN_ECDSA_SECP521R1_SHA512, SSL_SIGN_RSA_PKCS1_SHA1, + }; + return SSL_CTX_set_verify_algorithm_prefs(ctx, algorithms, sizeof(algorithms) / sizeof(algorithms[0])); +} + /* Exported for quic.c (lsquic configures ALPN/transport-params on the SSL_CTX * directly) and as the body of us_ssl_ctx_from_options. */ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, @@ -1448,11 +1459,10 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, X509_STORE_set_flags(partial_store, X509_V_FLAG_PARTIAL_CHAIN); } - if (options.sigalgs) { - if (!SSL_CTX_set1_sigalgs_list(ssl_context, options.sigalgs)) { - ssl_ctx_build_fail(ssl_context); - return NULL; - } + if (options.sigalgs ? !SSL_CTX_set1_sigalgs_list(ssl_context, options.sigalgs) + : !us_ssl_ctx_set_verify_signature_algorithms(ssl_context)) { + ssl_ctx_build_fail(ssl_context); + return NULL; } if (options.ecdh_curve) { diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index d4aced6ef711..2fc4816885ad 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -28,6 +28,7 @@ extern SSL_CTX *us_ssl_ctx_build_raw( struct us_bun_socket_context_options_t options, enum create_bun_socket_error_t *err); extern X509_STORE *us_get_default_ca_store(void); +extern int us_ssl_ctx_set_verify_signature_algorithms(SSL_CTX *ctx); extern struct us_bun_verify_error_t us_ssl_socket_verify_error_from_ssl(SSL *ssl); #define US_QUIC_READ_BUF (16 * 1024) @@ -1336,6 +1337,7 @@ us_quic_socket_context_t *us_create_quic_client_context( * the system store on macOS/Windows. */ SSL_CTX_set_cert_store(ssl, us_get_default_ca_store()); SSL_CTX_set_custom_verify(ssl, SSL_VERIFY_PEER, us_quic_client_verify); + if (!us_ssl_ctx_set_verify_signature_algorithms(ssl)) { SSL_CTX_free(ssl); return NULL; } us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) us_calloc(1, sizeof(us_quic_socket_context_t) + ext_size); diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index 8396870f9ebf..59434f26ba0d 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -438,6 +438,9 @@ impl TlsContext { ssl::SSL_CTX_get0_param(ctx), X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS, ); + if us_ssl_ctx_set_verify_signature_algorithms(ctx) != 1 { + return Err("failed to set the signature algorithms"); + } if let Some(groups) = &config.groups { if ssl::SSL_CTX_set1_groups_list(ctx, groups.as_ptr().cast()) != 1 { return Err("invalid TLS groups list"); @@ -565,6 +568,8 @@ pub(super) fn negotiated_alpn(ssl: *mut ssl::SSL) -> Option> { unsafe extern "C" { /// `X509_V_ERR_*` -> node's code name; see ncrypto.cpp. fn Bun__X509__validationErrorCode(err: i32) -> *const core::ffi::c_char; + /// openssl.c: the signature algorithms every other TLS context of Bun accepts from a peer. + fn us_ssl_ctx_set_verify_signature_algorithms(ctx: *mut ssl::SSL_CTX) -> c_int; } /// node's `X509_V_ERR_UNSPECIFIED`, reported when a peer sent no certificate diff --git a/test/js/node/quic/quic-sni.test.ts b/test/js/node/quic/quic-sni.test.ts index 130a39b17560..04e4bdc23442 100644 --- a/test/js/node/quic/quic-sni.test.ts +++ b/test/js/node/quic/quic-sni.test.ts @@ -221,3 +221,20 @@ test("connect() with the default verifyPeer refuses an unverifiable certificate exitCode: 0, }); }); + +// https://github.com/oven-sh/bun/issues/32234 +test("a client verifies an Ed25519 server certificate", async () => { + const fixtures = join(import.meta.dir, "..", "tls", "fixtures"); + const cert = readFileSync(join(fixtures, "ed25519-cert.pem")); + const key = createPrivateKey(readFileSync(join(fixtures, "ed25519-key.pem"))); + await using server = await listen(ignoreErrors, { + sni: { "*": { keys: [key], certs: [cert] } }, + alpn: ["quic-test"], + }); + const session = await connect(server.address, { alpn: "quic-test", servername: "localhost", ca: [cert] }); + try { + expect(await session.opened).toMatchObject({ validationErrorCode: undefined, validationErrorReason: undefined }); + } finally { + await session.close(); + } +}); diff --git a/test/js/node/tls/fixtures/ed25519-cert.pem b/test/js/node/tls/fixtures/ed25519-cert.pem new file mode 100644 index 000000000000..a7c20f73a77b --- /dev/null +++ b/test/js/node/tls/fixtures/ed25519-cert.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBazCCAR2gAwIBAgIUIRnW7d+/xEjATOwpaLufjVG3L50wBQYDK2VwMBwxGjAY +BgNVBAMMEWVkMjU1MTktbG9jYWxob3N0MCAXDTI2MDYxMzE2NDA0MVoYDzIxMjYw +NTIwMTY0MDQxWjAcMRowGAYDVQQDDBFlZDI1NTE5LWxvY2FsaG9zdDAqMAUGAytl +cAMhAJctkaOzUhRwNmww05zPThcS3kxYat1D1u23etBE5afto28wbTAdBgNVHQ4E +FgQUsKxmTSgWWCGVfig+r1ZpDskV/P0wHwYDVR0jBBgwFoAUsKxmTSgWWCGVfig+ +r1ZpDskV/P0wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwBQYDK2VwA0EAwTZ5YJdRLVaHhonefLnfj3JZRMCkLy7sJh9PzaMMLmzy +Ykda9Ma72YYRhxA17xJDrVHqbch3Xj0av7HdiBA1Dw== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/ed25519-key.pem b/test/js/node/tls/fixtures/ed25519-key.pem new file mode 100644 index 000000000000..522bc761909c --- /dev/null +++ b/test/js/node/tls/fixtures/ed25519-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIPHkA+g5xFMMCoC3mdG4/P1fZklg3FWNUW79wAEkMQur +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/p521-cert.pem b/test/js/node/tls/fixtures/p521-cert.pem new file mode 100644 index 000000000000..f2a7d2e780c4 --- /dev/null +++ b/test/js/node/tls/fixtures/p521-cert.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICLDCCAY6gAwIBAgIULMKSgCPMb6GMkhK/ZpfRas9lTPkwCgYIKoZIzj0EAwIw +GTEXMBUGA1UEAwwOcDUyMS1sb2NhbGhvc3QwIBcNMjYwNjEzMTcwNDQyWhgPMjEy +NjA1MjAxNzA0NDJaMBkxFzAVBgNVBAMMDnA1MjEtbG9jYWxob3N0MIGbMBAGByqG +SM49AgEGBSuBBAAjA4GGAAQA/PIq4LR4n0p8/94YQowNcGwk6GAs625n0ufFQ/ib +G7uEPD/KUhJTMg/SJRwrq6lOSebbdVjIindagm4mFJtOFpwBk46G1TDMOA8goAGZ +qTp15B5Cte0AnedrSjJ8L24CQeh3P8jrzSpkf/FTKlWbvtl5Mlj4+PVMh+Q0hZkC +UpJoPVWjbzBtMB0GA1UdDgQWBBT18yyESC3nxaPyz7oA8dyBGEHJ/TAfBgNVHSME +GDAWgBT18yyESC3nxaPyz7oA8dyBGEHJ/TAPBgNVHRMBAf8EBTADAQH/MBoGA1Ud +EQQTMBGCCWxvY2FsaG9zdIcEfwAAATAKBggqhkjOPQQDAgOBiwAwgYcCQSyY3lFD +f0N7631iLceyvBQ62U1+cQyDTUNEt9B/YW1TPiUONCfbdHB0IOzBwUBhVPYUcwYR +s+yBvABruLk1OzrQAkIB+twMGvX6ZD8llMA5Ac/lYrIvfL2RiAInaN8Oin194cKP +A148UijZM1s3nxvhjqQZtX/NnS4VrIkmY4PtCY89Rr0= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/p521-key.pem b/test/js/node/tls/fixtures/p521-key.pem new file mode 100644 index 000000000000..842324c81baa --- /dev/null +++ b/test/js/node/tls/fixtures/p521-key.pem @@ -0,0 +1,8 @@ +-----BEGIN PRIVATE KEY----- +MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIASR7GbUoVvpY4c+bL +oPGX1Cd+K49MA3BD+pl/eukQuImXGa9PLDrJXyrVrKfjdoKF0EupRxoLOGqFfWO3 +A/lGId+hgYkDgYYABAD88irgtHifSnz/3hhCjA1wbCToYCzrbmfS58VD+Jsbu4Q8 +P8pSElMyD9IlHCurqU5J5tt1WMiKd1qCbiYUm04WnAGTjobVMMw4DyCgAZmpOnXk +HkK17QCd52tKMnwvbgJB6Hc/yOvNKmR/8VMqVZu+2XkyWPj49UyH5DSFmQJSkmg9 +VQ== +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index aa61eede5074..2fb506b02a5f 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -4439,3 +4439,133 @@ describe("negotiated parameters", () => { }); }); }); + +// https://github.com/oven-sh/bun/issues/32234 +describe("signature algorithms a peer may sign with", () => { + const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); + const ed25519 = { key: fixture("ed25519-key.pem"), cert: fixture("ed25519-cert.pem") }; + const p521 = { key: fixture("p521-key.pem"), cert: fixture("p521-cert.pem") }; + const rsa = { key: COMMON_CERT_.key, cert: COMMON_CERT_.cert }; + + /** The extensions of the ClientHello that `connect` sends, as lists of 16-bit values. */ + async function clientHello(connect: (port: number) => unknown) { + const { promise, resolve } = Promise.withResolvers>(); + const server = net.createServer(socket => { + let hello = Buffer.alloc(0); + socket.on("error", () => {}); + socket.on("data", chunk => { + hello = Buffer.concat([hello, chunk]); + if (hello.length < 5 || hello.length < 5 + hello.readUInt16BE(3)) return; + // record header, handshake header, version, random; then session id, cipher suites, compression methods + let at = 5 + 4 + 2 + 32; + at += 1 + hello[at]; + at += 2 + hello.readUInt16BE(at); + at += 1 + hello[at]; + const end = at + 2 + hello.readUInt16BE(at); + const lists: Record = {}; + for (at += 2; at < end; at += 4 + hello.readUInt16BE(at + 2)) { + const list = (lists[hello.readUInt16BE(at)] = [] as number[]); + for (let i = at + 6; i + 1 < at + 4 + hello.readUInt16BE(at + 2); i += 2) list.push(hello.readUInt16BE(i)); + } + socket.destroy(); + resolve({ signatureAlgorithms: lists[13], supportedGroups: lists[10] }); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + await connect((server.address() as AddressInfo).port); + return await promise; + } finally { + server.close(); + } + } + + it.each<[string, (port: number) => unknown]>([ + ["tls.connect", port => tls.connect({ port, host: "127.0.0.1" }).on("error", () => {})], + [ + "tls.connect, TLS 1.2", + port => tls.connect({ port, host: "127.0.0.1", maxVersion: "TLSv1.2" }).on("error", () => {}), + ], + [ + "tls.connect over a Duplex", + port => { + const raw = net.connect(port, "127.0.0.1").on("error", () => {}); + const socket = new Duplex({ + read() {}, + write: (chunk, _encoding, callback) => void raw.write(chunk, callback), + }); + tls.connect({ socket }).on("error", () => {}); + }, + ], + ["Bun.connect", port => Bun.connect({ port, hostname: "127.0.0.1", tls: true, socket: { data() {}, error() {} } })], + ["fetch", port => void fetch(`https://127.0.0.1:${port}/`).catch(() => {})], + ["WebSocket", port => void new WebSocket(`wss://127.0.0.1:${port}/`)], + ])("%s offers what it offered before, ed25519 and ecdsa_secp521r1_sha512", async (name, connect) => { + const added = [0x0807, 0x0603]; + const { signatureAlgorithms, supportedGroups } = await clientHello(connect); + expect({ + before: signatureAlgorithms.filter(algorithm => !added.includes(algorithm)), + added: signatureAlgorithms.filter(algorithm => added.includes(algorithm)), + supportedGroups, + }).toEqual({ + // BoringSSL's kVerifySignatureAlgorithms, in its order. rsa_pkcs1_sha1 is last. + before: [0x0403, 0x0804, 0x0401, 0x0503, 0x0805, 0x0501, 0x0806, 0x0601, 0x0201], + added, + // X25519MLKEM768 (TLS 1.3 only), x25519, secp256r1, secp384r1 + supportedGroups: name.endsWith("TLS 1.2") ? [0x001d, 0x0017, 0x0018] : [0x11ec, 0x001d, 0x0017, 0x0018], + }); + }); + + async function handshake(serverOptions: tls.TlsOptions, clientOptions: tls.ConnectionOptions) { + const accepted = Promise.withResolvers(); + const server = tls.createServer(serverOptions, socket => { + accepted.resolve(socket.authorized); + socket.end(); + }); + server.on("tlsClientError", error => accepted.resolve((error as NodeJS.ErrnoException).code!)); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + const client = tls.connect({ port, host: "127.0.0.1", servername: "localhost", ...clientOptions }); + try { + const connected = Promise.withResolvers(); + client.on("secureConnect", () => connected.resolve(client.authorized)); + client.on("error", error => connected.resolve((error as NodeJS.ErrnoException).code!)); + return { client: await connected.promise, server: await accepted.promise, protocol: client.getProtocol() }; + } finally { + client.destroy(); + server.close(); + } + } + + it.each([ + ["Ed25519", "TLSv1.2", ed25519], + ["Ed25519", "TLSv1.3", ed25519], + ["P-521", "TLSv1.3", p521], + ] as const)("a client verifies a server certificate: %s, %s", async (_name, maxVersion, { key, cert }) => { + expect(await handshake({ key, cert }, { ca: cert, maxVersion })).toEqual({ + client: true, + server: false, + protocol: maxVersion, + }); + }); + + // In TLS 1.2 the curve of the certificate must also be a supported group, and secp521r1 is not one by default. + it.todo("a client verifies a server certificate: P-521, TLSv1.2"); + + it.each(["TLSv1.2", "TLSv1.3"] as const)("a server verifies an Ed25519 client certificate (%s)", async maxVersion => { + expect( + await handshake({ ...rsa, requestCert: true, ca: ed25519.cert }, { ...ed25519, ca: rsa.cert, maxVersion }), + ).toEqual({ client: true, server: true, protocol: maxVersion }); + }); + + // Node.js v26.3.0 does not offer rsa_pkcs1_sha1. + it("a client still connects to a TLS 1.2 server that signs with rsa_pkcs1_sha1 only", async () => { + // An ECDHE suite, so that the server has a ServerKeyExchange to sign. + const server = { ...rsa, maxVersion: "TLSv1.2", sigalgs: "rsa_pkcs1_sha1", ciphers: "ECDHE-RSA-AES128-GCM-SHA256" }; + expect(await handshake(server as tls.TlsOptions, { ca: rsa.cert })).toEqual({ + client: true, + server: false, + protocol: "TLSv1.2", + }); + }); +}); diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index 91ffde726cf9..6b4051a0e0f2 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -1259,3 +1259,22 @@ test("custom TLS trust options are rejected on protocol: http3 and excluded from expect(stdout).toMatch(/second status=200 sessions=0\n$/); expect(exitCode).toBe(0); }); + +// https://github.com/oven-sh/bun/issues/32234 +test("connects to a server with an Ed25519 certificate", async () => { + const fixtures = join(import.meta.dir, "..", "..", "node", "tls", "fixtures"); + const server = Bun.serve({ + port: 0, + tls: { key: Bun.file(join(fixtures, "ed25519-key.pem")), cert: Bun.file(join(fixtures, "ed25519-cert.pem")) }, + http3: true, + http1: false, + fetch: () => new Response("ed25519 over h3"), + }); + try { + const res = await fetch(`https://127.0.0.1:${server.port}/`, h3); + expect(await res.text()).toBe("ed25519 over h3"); + } finally { + // As in afterAll: the pooled session drains on lsquic's idle timeout, so stop(true) is not awaited. + server.stop(true); + } +}); From b73dfc99da2f09c6a158b7f1a8e41d617e992910 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:30:19 +0000 Subject: [PATCH 049/174] tls: trust a pinned self-issued certificate whose keyUsage lacks keyCertSign (#44441) A `dotnet dev-certs` certificate names itself as its issuer but may not sign certificates, so BoringSSL does not take it for self-signed, finds no issuer and reports UNABLE_TO_VERIFY_LEAF_SIGNATURE even when `ca` or NODE_EXTRA_CA_CERTS holds it. Node trusts it. us_x509_verify_cert sets X509_V_FLAG_PARTIAL_CHAIN on the parameters of that one verification, before it runs, only when the leaf is self-issued, is not flagged self-signed, has the purpose of the peer's role, and the store holds a byte-identical copy. Validity and CRLs are still checked. Any other certificate takes the path it took, with the error it had. The HTTP/3 client shares it. --- packages/bun-usockets/src/crypto/openssl.c | 19 ++++- packages/bun-usockets/src/quic.c | 3 +- .../fixtures/pinned-leaf/client-eku-cert.pem | 11 +++ .../fixtures/pinned-leaf/client-eku-key.pem | 5 ++ .../tls/fixtures/pinned-leaf/dev-cert.pem | 11 +++ .../node/tls/fixtures/pinned-leaf/dev-key.pem | 5 ++ .../tls/fixtures/pinned-leaf/expired-cert.pem | 11 +++ .../tls/fixtures/pinned-leaf/expired-key.pem | 5 ++ test/js/node/tls/fixtures/pinned-leaf/gen.sh | 30 +++++++ .../pinned-leaf/intermediate-cert.pem | 11 +++ .../pinned-leaf/issued-by-dev-cert.pem | 12 +++ .../pinned-leaf/issued-by-dev-key.pem | 5 ++ .../issued-by-intermediate-cert.pem | 12 +++ .../issued-by-intermediate-key.pem | 5 ++ .../fixtures/pinned-leaf/other-key-cert.pem | 11 +++ .../fixtures/pinned-leaf/other-key-key.pem | 5 ++ .../tls/fixtures/pinned-leaf/root-cert.pem | 10 +++ test/js/node/tls/node-tls-connect.test.ts | 84 +++++++++++++++++++ .../node/tls/test-node-extra-ca-certs.test.ts | 47 +++++++++++ test/js/web/fetch/fetch.tls.test.ts | 18 ++++ 20 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 test/js/node/tls/fixtures/pinned-leaf/client-eku-cert.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/client-eku-key.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/dev-cert.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/dev-key.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/expired-cert.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/expired-key.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/gen.sh create mode 100644 test/js/node/tls/fixtures/pinned-leaf/intermediate-cert.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-cert.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-key.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-cert.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-key.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/other-key-cert.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/other-key-key.pem create mode 100644 test/js/node/tls/fixtures/pinned-leaf/root-cert.pem diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 0fc5bbf5cd0b..07c12832bf49 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1157,11 +1157,28 @@ enum { US_IDENTITY_UNCHECKED = 2, }; +/* A self-issued leaf that may not sign certificates is its own anchor when the store holds that very certificate (#44365). */ +int us_x509_verify_cert(X509_STORE_CTX *ctx, int purpose) { + X509 *leaf = X509_STORE_CTX_get0_cert(ctx); + uint32_t flags = X509_get_extension_flags(leaf); + if ((flags & EXFLAG_SI) && !(flags & EXFLAG_SS) && X509_check_purpose(leaf, purpose, 0) == 1) { + STACK_OF(X509) *same_subject = X509_STORE_CTX_get1_certs(ctx, X509_get_subject_name(leaf)); + for (size_t i = 0; i < sk_X509_num(same_subject); i++) { + if (X509_cmp(sk_X509_value(same_subject, i), leaf) == 0) { + X509_VERIFY_PARAM_set_flags(X509_STORE_CTX_get0_param(ctx), X509_V_FLAG_PARTIAL_CHAIN); + break; + } + } + sk_X509_pop_free(same_subject, X509_free); + } + return X509_verify_cert(ctx); +} + /* Verifies the chain, then asks the owner of a client for the name: a wrong name is a verify error like any other. */ static int us_cert_verify_cb(X509_STORE_CTX *ctx, void *arg) { (void)arg; - int ok = X509_verify_cert(ctx); SSL *ssl = X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx()); + int ok = us_x509_verify_cert(ctx, ssl && SSL_is_server(ssl) ? X509_PURPOSE_SSL_CLIENT : X509_PURPOSE_SSL_SERVER); if (!ssl) return ok; void *wrapper = us_ssl_wrapper(ssl); struct us_socket_t *s = wrapper ? NULL : us_ssl_socket(ssl); diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 2fc4816885ad..c6cb52a21617 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -29,6 +29,7 @@ extern SSL_CTX *us_ssl_ctx_build_raw( enum create_bun_socket_error_t *err); extern X509_STORE *us_get_default_ca_store(void); extern int us_ssl_ctx_set_verify_signature_algorithms(SSL_CTX *ctx); +extern int us_x509_verify_cert(X509_STORE_CTX *ctx, int purpose); extern struct us_bun_verify_error_t us_ssl_socket_verify_error_from_ssl(SSL *ssl); #define US_QUIC_READ_BUF (16 * 1024) @@ -1307,7 +1308,7 @@ static enum ssl_verify_result_t us_quic_client_verify(SSL *ssl, uint8_t *out_ale int ok = 0; if (X509_STORE_CTX_init(vctx, store, leaf, chain) == 1) { X509_STORE_CTX_set_default(vctx, "ssl_server"); - ok = X509_verify_cert(vctx) == 1; + ok = us_x509_verify_cert(vctx, X509_PURPOSE_SSL_SERVER) == 1; } X509_STORE_CTX_free(vctx); if (!ok) return ssl_verify_invalid; diff --git a/test/js/node/tls/fixtures/pinned-leaf/client-eku-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/client-eku-cert.pem new file mode 100644 index 000000000000..5c583a943f3a --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/client-eku-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBnDCCAUKgAwIBAgIUQujf9hICZkqNlLke73XVr68cNCwwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAwNjAwNDgxOVoYDzIxMjYwOTEy +MDA0ODE5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAQUdaQaq2ijRQnakroKGeixQs2erdQLPDVJ4j8Jst337x0Aopoxa39p +3yCSkiYgy6C7vuVcHWzlB9TGhS0+Mw4vo3AwbjAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDAjAaBgNVHREEEzARgglsb2Nh +bGhvc3SHBH8AAAEwHQYDVR0OBBYEFGy5aF0rUgZ3zHk+L+O92KPiIz+xMAoGCCqG +SM49BAMCA0gAMEUCIQCtFQd6nNjrD9Qei70JkvByeIAPaQGtnIYxVIBYGJEIBwIg +KZmex7JTOOw427y9bPthJ4GfbIG4FlmYvsPKkQcalrU= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/client-eku-key.pem b/test/js/node/tls/fixtures/pinned-leaf/client-eku-key.pem new file mode 100644 index 000000000000..07279c2c0749 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/client-eku-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgaTx/ngzwIg+nOZf4 +BQdqlQ1KVfRKaqPtwJblyvD9cmWhRANCAAQUdaQaq2ijRQnakroKGeixQs2erdQL +PDVJ4j8Jst337x0Aopoxa39p3yCSkiYgy6C7vuVcHWzlB9TGhS0+Mw4v +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/dev-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/dev-cert.pem new file mode 100644 index 000000000000..0fdcdb419194 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/dev-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBpzCCAUygAwIBAgIUf0OTEOeh5kLhXQTwxTmJm2HOHeUwCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAwNjAwNDgxOVoYDzIxMjYwOTEy +MDA0ODE5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAS8uiSqL7ElKijKkZGAAikcFwRVl4cwfQeX7OuNcjGNWwTiTED/EfaX +COEbPC9zbK6YJ2x48zXQr7bsH2TMCSKNo3oweDAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGgYDVR0R +BBMwEYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBS4cX1ogUqRusz69mdtcBv4 +i+jWBjAKBggqhkjOPQQDAgNJADBGAiEAic6fPLvyXl5xVJ5m1ulEOVh/Hxaar1ZI +49gI75/1DRQCIQC+9kfl0YPSNwv+BYqKeIJVKXZkKlc/8JV2++0fkSQVtw== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/dev-key.pem b/test/js/node/tls/fixtures/pinned-leaf/dev-key.pem new file mode 100644 index 000000000000..e79cc20e1ae7 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/dev-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgHce1XDmfimUW2EMk +n3vGbJvm+yo0+ZQ+K/+LjAFfYaKhRANCAAS8uiSqL7ElKijKkZGAAikcFwRVl4cw +fQeX7OuNcjGNWwTiTED/EfaXCOEbPC9zbK6YJ2x48zXQr7bsH2TMCSKN +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/expired-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/expired-cert.pem new file mode 100644 index 000000000000..8c4e006561ce --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/expired-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBkjCCATegAwIBAgIBATAKBggqhkjOPQQDAjAUMRIwEAYDVQQDDAlsb2NhbGhv +c3QwHhcNMjAwMTAxMDAwMDAwWhcNMjAwMTAyMDAwMDAwWjAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR/sv8OGcw5yVhqhbx0 +ZTc6qwZzj5Qau1d/Kez3t2Bt6vAL2Jiv4npMyW/Z+mvqgpwSNs6ihr/JexN3UYXP +YqSMo3oweDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAU +BggrBgEFBQcDAQYIKwYBBQUHAwIwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAAB +MB0GA1UdDgQWBBTbGt2xbiydjcRN7nC+uQmDvNNRLjAKBggqhkjOPQQDAgNJADBG +AiEA5NypyNHoHoMccTwsU+hU3inIRc+2cqDYDOaf8Ju37gACIQCJCV859J9SYrWa +ID+cMOEIgNiqaKDGv6uh4uEEAIKhOw== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/expired-key.pem b/test/js/node/tls/fixtures/pinned-leaf/expired-key.pem new file mode 100644 index 000000000000..1822b8762a71 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/expired-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwWGtE9k6Grt/aDEM +HVOHVnTbJk2dUpA/nu+pOdMNnFyhRANCAAR/sv8OGcw5yVhqhbx0ZTc6qwZzj5Qa +u1d/Kez3t2Bt6vAL2Jiv4npMyW/Z+mvqgpwSNs6ihr/JexN3UYXPYqSM +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/gen.sh b/test/js/node/tls/fixtures/pinned-leaf/gen.sh new file mode 100644 index 000000000000..ca90ce52fcdb --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/gen.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Regenerates the certificates of this directory, in the current directory. Needs OpenSSL 3. +set -e +tmp=$(mktemp -d) +key() { openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out "$1-key.pem"; } +leaf() { printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=%s\nsubjectAltName=DNS:localhost,IP:127.0.0.1\n' "$1"; } +ca() { printf 'basicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\n'; } +# sign <"self" or the name of the issuer> +sign() { + openssl req -new -key "$1-key.pem" -subj "$2" -out "$tmp/csr" + echo "$4" > "$tmp/ext" + if [ "$3" = self ]; then signer=(-signkey "$1-key.pem"); else signer=(-CA "$3-cert.pem" -CAkey "$3-key.pem" -set_serial "0x$(openssl rand -hex 8)"); fi + openssl x509 -req -in "$tmp/csr" "${signer[@]}" -extfile "$tmp/ext" -days 36500 -sha256 -out "$1-cert.pem" 2> /dev/null +} +for name in dev other-key client-eku expired root intermediate issued-by-intermediate issued-by-dev; do key $name; done +sign dev /CN=localhost self "$(leaf serverAuth,clientAuth)" +sign other-key /CN=localhost self "$(leaf serverAuth,clientAuth)" +sign client-eku /CN=localhost self "$(leaf clientAuth)" +sign root /CN=pinned-leaf-root self "$(ca)" +sign intermediate /CN=localhost root "$(ca)" +sign issued-by-intermediate /CN=localhost intermediate "$(leaf serverAuth)" +sign issued-by-dev /CN=localhost dev "$(leaf serverAuth)" +# `openssl x509 -req` cannot backdate before OpenSSL 3.4. `openssl ca` can. +: > "$tmp/index.txt" +echo 01 > "$tmp/serial" +leaf serverAuth,clientAuth > "$tmp/ext" +printf '[ca]\ndefault_ca=d\n[d]\ndatabase=%s/index.txt\nserial=%s/serial\nnew_certs_dir=%s\ndefault_md=sha256\npolicy=p\n[p]\ncommonName=supplied\n' "$tmp" "$tmp" "$tmp" > "$tmp/cnf" +openssl req -new -key expired-key.pem -subj /CN=localhost -out "$tmp/csr" +openssl ca -batch -config "$tmp/cnf" -selfsign -keyfile expired-key.pem -in "$tmp/csr" -extfile "$tmp/ext" \ + -startdate 20200101000000Z -enddate 20200102000000Z -notext -out expired-cert.pem 2> /dev/null diff --git a/test/js/node/tls/fixtures/pinned-leaf/intermediate-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/intermediate-cert.pem new file mode 100644 index 000000000000..c3a1972bac2b --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/intermediate-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBijCCATCgAwIBAgIIGj4OnD082cowCgYIKoZIzj0EAwIwGzEZMBcGA1UEAwwQ +cGlubmVkLWxlYWYtcm9vdDAgFw0yNjEwMDYwMDQ4MTlaGA8yMTI2MDkxMjAwNDgx +OVowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD +QgAEda6+iId1s80h37fuOgE69BU6/LfVRymO1rB9EfSpnAJh8tzUxAzzj6msjCSz +vzZV0f5S2G9w3nWgjVQDg284F6NjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B +Af8EBAMCAQYwHQYDVR0OBBYEFBd7pvlCwZ/priMQWBV1fAdcIu8uMB8GA1UdIwQY +MBaAFLNOEIJVG139b28MSzn2HXPGuIHDMAoGCCqGSM49BAMCA0gAMEUCIDYBcUCl +JJMSbXShClPjPDiEucXhxVNK9ScuA3piPIc2AiEA7535mLR5XIImYFQdDYGNZ78J +BdciKaTrP9evTxneG90= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-cert.pem new file mode 100644 index 000000000000..b4bbb330d022 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBszCCAVqgAwIBAgIJAPJOx8wEBeuNMAoGCCqGSM49BAMCMBQxEjAQBgNVBAMM +CWxvY2FsaG9zdDAgFw0yNjEwMDYwMDQ4MTlaGA8yMTI2MDkxMjAwNDgxOVowFDES +MBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAutI +m+gKhwaSHVP2KPsEZglnXtIkKwJvvtfYTtVrQF/8P+pkTZLhyFfgDu5qAiVRK286 +duRjMQI/nOniL3z+gqOBkjCBjzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIF +oDATBgNVHSUEDDAKBggrBgEFBQcDATAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A +AAEwHQYDVR0OBBYEFOdksxgmkRidKaox1TRm9kh27aLMMB8GA1UdIwQYMBaAFLhx +fWiBSpG6zPr2Z21wG/iL6NYGMAoGCCqGSM49BAMCA0cAMEQCIFh+voz+GhulsD4w +nDDzHzh1JVFLdXgo7Mhu1jaDLwRtAiBRcJQPUBLDZrgo1a9OhRd80TbGRF5am8GL +RGkq2EGCrQ== +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-key.pem b/test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-key.pem new file mode 100644 index 000000000000..09b2b068dd12 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/issued-by-dev-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgCzC13IzgADDZCW2O +pB/9n12WFqGvjGX59vQYap66c32hRANCAAQC60ib6AqHBpIdU/Yo+wRmCWde0iQr +Am++19hO1WtAX/w/6mRNkuHIV+AO7moCJVErbzp25GMxAj+c6eIvfP6C +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-cert.pem new file mode 100644 index 000000000000..8f27895ddd0e --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBtTCCAVqgAwIBAgIJALo1oBqpOxQSMAoGCCqGSM49BAMCMBQxEjAQBgNVBAMM +CWxvY2FsaG9zdDAgFw0yNjEwMDYwMDQ4MTlaGA8yMTI2MDkxMjAwNDgxOVowFDES +MBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEH4yO +ZC2d629fB6ra5rm9LAItlPkRwdww7GS3oqptgQdKrno0OZQVZJf//42cYfROXfgb +wQPiySbb2/dP4xDYBaOBkjCBjzAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIF +oDATBgNVHSUEDDAKBggrBgEFBQcDATAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8A +AAEwHQYDVR0OBBYEFMAz2EABTwl1IDei5v+NyslV8k3IMB8GA1UdIwQYMBaAFBd7 +pvlCwZ/priMQWBV1fAdcIu8uMAoGCCqGSM49BAMCA0kAMEYCIQCi9z1nMr9KdBYR +/DxtiavJSosy/vn6On40ecGnonbhawIhANA+RMQgKusPrk8L/05EvUh0B6wuroH0 +Dnp3BBUUEFyS +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-key.pem b/test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-key.pem new file mode 100644 index 000000000000..1bbb55656981 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/issued-by-intermediate-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgq41Ymi7ON1BOrOvA +V4BaLsoV9ywZhf4zPwsu3Xi8kfKhRANCAAQfjI5kLZ3rb18Hqtrmub0sAi2U+RHB +3DDsZLeiqm2BB0quejQ5lBVkl///jZxh9E5d+BvBA+LJJtvb90/jENgF +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/other-key-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/other-key-cert.pem new file mode 100644 index 000000000000..ed00004682b6 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/other-key-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBpjCCAUygAwIBAgIUFKslwe0A40wco9xGQrbcYlnnK4swCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAwNjAwNDgxOVoYDzIxMjYwOTEy +MDA0ODE5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAARJc3eo2XTMI4ZlZ3ZtsNMa6goyvMAOj0vEo9g84WPEi4+ouujsY7l3 +Th0qaN2OjIzIgiPFa2dqQDBfA9e3UD9Uo3oweDAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwGgYDVR0R +BBMwEYIJbG9jYWxob3N0hwR/AAABMB0GA1UdDgQWBBRsLCHrHa3uz/eeXUuF5QHH +QkrKiTAKBggqhkjOPQQDAgNIADBFAiBrCw9o7K2BT4TF+k1hhpDsV9bgkhOv+65D +BlpAHqxUAAIhAM6uxpQFeK8T2/4pb/GcfyIw4ix1hEDovA8LscBloaxB +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/other-key-key.pem b/test/js/node/tls/fixtures/pinned-leaf/other-key-key.pem new file mode 100644 index 000000000000..dc4d347e3d1a --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/other-key-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg79F5QC91YLlbDGSj +kLnLEjpGXKI1zMJ/VkDWbgvZSuOhRANCAARJc3eo2XTMI4ZlZ3ZtsNMa6goyvMAO +j0vEo9g84WPEi4+ouujsY7l3Th0qaN2OjIzIgiPFa2dqQDBfA9e3UD9U +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/pinned-leaf/root-cert.pem b/test/js/node/tls/fixtures/pinned-leaf/root-cert.pem new file mode 100644 index 000000000000..ca9eb7d271a8 --- /dev/null +++ b/test/js/node/tls/fixtures/pinned-leaf/root-cert.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBfDCCASKgAwIBAgIUU4o4/7EaYTKFvBqMPXk+qk/vwQwwCgYIKoZIzj0EAwIw +GzEZMBcGA1UEAwwQcGlubmVkLWxlYWYtcm9vdDAgFw0yNjEwMDYwMDQ4MTlaGA8y +MTI2MDkxMjAwNDgxOVowGzEZMBcGA1UEAwwQcGlubmVkLWxlYWYtcm9vdDBZMBMG +ByqGSM49AgEGCCqGSM49AwEHA0IABHXcLisX98+PbtZEfszyk7R+JstykBa1VVbv +v+XFBuNJXyah4k8ge0J9YYEOmthqD5V4ImlMDKhaSD2Z4EeUyB2jQjBAMA8GA1Ud +EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSzThCCVRtd/W9v +DEs59h1zxriBwzAKBggqhkjOPQQDAgNIADBFAiA3/z1cctN3sfhA+CRE2sbIXcNN +HOl6TmYlnqqpBNa9AgIhAMuu1XKekTt0oGT2tCv/7pZp1BO0F8miOByhYAi3g/DS +-----END CERTIFICATE----- diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 2fb506b02a5f..24cabec56ee5 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -4569,3 +4569,87 @@ describe("signature algorithms a peer may sign with", () => { }); }); }); + +// https://github.com/oven-sh/bun/issues/44365 +describe("a self-issued certificate that may not sign certificates, like the one of `dotnet dev-certs`", () => { + const pem = (name: string) => readFileSync(join(import.meta.dir, "fixtures", "pinned-leaf", `${name}.pem`), "utf8"); + const identity = (name: string, ...chain: string[]) => ({ + key: pem(`${name}-key`), + cert: [name, ...chain].map(name => pem(`${name}-cert`)).join(""), + }); + + /** What each side says of the other's certificate: `true`, or the verification error. */ + async function verdicts(serverOptions: tls.TlsOptions, clientOptions: tls.ConnectionOptions, overDuplex = false) { + const accepted = Promise.withResolvers(); + const server = tls.createServer({ rejectUnauthorized: false, ...serverOptions }, socket => { + accepted.resolve(socket.authorized || String(socket.authorizationError)); + socket.end(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + const transport = overDuplex + ? { socket: new SocketProxy(net.connect(port, "127.0.0.1")) } + : { port, host: "127.0.0.1" }; + const client = tls.connect({ ...transport, servername: "localhost", rejectUnauthorized: false, ...clientOptions }); + try { + await once(client, "secureConnect"); + return { ofServer: client.authorized || String(client.authorizationError), ofClient: await accepted.promise }; + } finally { + client.destroy(); + server.close(); + } + } + + describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s", maxVersion => { + it.each([false, true])("is trusted when `ca` holds that very certificate (over a Duplex: %p)", async overDuplex => { + const { ofServer } = await verdicts(identity("dev"), { ca: pem("dev-cert"), maxVersion }, overDuplex); + expect(ofServer).toBe(true); + }); + + // Node.js v26.3.0 refuses "after": it looks at a single certificate of the subject. + it.each([ + ["before", ["dev-cert", "other-key-cert"]], + ["after", ["other-key-cert", "dev-cert"]], + ])("is trusted when it comes %s another certificate of the same subject in `ca`", async (_name, names) => { + expect((await verdicts(identity("dev"), { ca: names.map(pem), maxVersion })).ofServer).toBe(true); + }); + + it("is trusted as the certificate of a client", async () => { + const server = { ...identity("dev"), requestCert: true, ca: pem("dev-cert") }; + expect((await verdicts(server, { ...identity("dev"), maxVersion })).ofClient).toBe(true); + expect((await verdicts(server, { ...identity("other-key"), maxVersion })).ofClient).not.toBe(true); + }); + + it("is refused once it has expired", async () => { + const { ofServer } = await verdicts(identity("expired"), { ca: pem("expired-cert"), maxVersion }); + expect(ofServer).toBe("CERT_HAS_EXPIRED"); + }); + + it.each([ + ["no `ca` holds it", identity("dev"), undefined], + ["`ca` holds other certificates", identity("dev"), [pem("root-cert"), pem("client-eku-cert")]], + ["`ca` holds one of the same subject and another key", identity("other-key"), [pem("dev-cert")]], + ["it is not for a server", identity("client-eku"), [pem("client-eku-cert")]], + ])("is refused when %s", async (_name, server, ca) => { + expect((await verdicts(server, { ca, maxVersion })).ofServer).not.toBe(true); + }); + + it.each([ + ["the trusted one signed", identity("issued-by-dev"), [pem("dev-cert")]], + ["the trusted one signed and comes with", identity("issued-by-dev", "dev"), [pem("dev-cert")]], + ["a trusted intermediate signed", identity("issued-by-intermediate"), [pem("intermediate-cert")]], + [ + "a trusted intermediate signed, next to the trusted one", + identity("issued-by-intermediate", "intermediate"), + [pem("intermediate-cert"), pem("dev-cert")], + ], + ])("does not make an anchor for a certificate of the same subject that %s", async (_name, server, ca) => { + expect((await verdicts(server, { ca, maxVersion })).ofServer).not.toBe(true); + }); + + it("leaves the chain of that intermediate valid under its root", async () => { + const server = identity("issued-by-intermediate", "intermediate"); + expect((await verdicts(server, { ca: pem("root-cert"), maxVersion })).ofServer).toBe(true); + }); + }); +}); diff --git a/test/js/node/tls/test-node-extra-ca-certs.test.ts b/test/js/node/tls/test-node-extra-ca-certs.test.ts index 1a77b5eb1060..83a4a499fa2c 100644 --- a/test/js/node/tls/test-node-extra-ca-certs.test.ts +++ b/test/js/node/tls/test-node-extra-ca-certs.test.ts @@ -276,3 +276,50 @@ test("explicit ca option replaces the default trust store instead of appending t expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + +// https://github.com/oven-sh/bun/issues/44365 +test("NODE_EXTRA_CA_CERTS trusts a self-issued certificate whose keyUsage lacks keyCertSign", async () => { + const fixtures = join(import.meta.dir, "fixtures", "pinned-leaf"); + const script = ` + import tls from "node:tls"; + import { once } from "node:events"; + const serve = name => + Bun.serve({ + port: 0, + tls: { key: Bun.file(name + "-key.pem"), cert: Bun.file(name + "-cert.pem") }, + http3: true, + fetch: () => new Response("ok"), + }); + const out = {}; + for (const name of ["dev", "other-key"]) { + const server = serve(name); + const url = "https://localhost:" + server.port + "/"; + const outcome = promise => promise.then(res => res.text(), error => error.code); + const socket = tls.connect({ host: "127.0.0.1", port: server.port, servername: "localhost", rejectUnauthorized: false }); + await once(socket, "secureConnect"); + out[name] = { + fetch: await outcome(fetch(url)), + http3: await outcome(fetch(url, { protocol: "http3" })), + tls: socket.authorized, + }; + socket.destroy(); + server.stop(true); + } + console.log(JSON.stringify(out)); + process.exit(0); + `; + await using proc = spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(fixtures, "dev-cert.pem") }, + cwd: fixtures, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "dev": { fetch: "ok", http3: "ok", tls: true }, + "other-key": { fetch: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", http3: "HTTP3HandshakeFailed", tls: false }, + }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 7eb209d740d2..f41b4b63f102 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -729,6 +729,24 @@ describe.concurrent("fetch-tls", () => { }); }); + // https://github.com/oven-sh/bun/issues/44365 + it("trusts a self-issued certificate without keyCertSign only when `ca` holds that very certificate", async () => { + const pem = (name: string) => + readFileSync(join(import.meta.dir, "../../node/tls/fixtures/pinned-leaf", name), "utf8"); + const attempt = (port: number, ca: string) => + fetch(`https://localhost:${port}`, { keepalive: false, tls: { ca } }).then( + res => res.text(), + error => error.code, + ); + await createServer({ key: pem("dev-key.pem"), cert: pem("dev-cert.pem") }, async port => { + expect(await attempt(port, pem("dev-cert.pem"))).toBe("Hello World"); + expect(await attempt(port, pem("other-key-cert.pem"))).toBe("UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + }); + await createServer({ key: pem("expired-key.pem"), cert: pem("expired-cert.pem") }, async port => { + expect(await attempt(port, pem("expired-cert.pem"))).toBe("CERT_HAS_EXPIRED"); + }); + }); + it("fetch with invalid tls should throw", async () => { await createServer(CERT_EXPIRED, async port => { await Promise.all( From 661fa7cd2147fe1f87acd6ddf92d4d21914201fb Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:30:43 +0000 Subject: [PATCH 050/174] sql, quic: refuse a NUL in the hostname of Bun.SQL and the servername of node:quic (#37013) Both become C strings: the connection, SNI and the certificate check saw the name up to the NUL. In a debug build the SQL hostname panicked in ZStr::as_cstr. --- src/runtime/node/quic/tls.rs | 5 +++++ src/sql_jsc/shared/ConnectionCtorArgs.rs | 5 +++++ test/js/node/quic/quic-sni.test.ts | 8 ++++++++ test/js/sql/sql.test.ts | 12 ++++++++++++ 4 files changed, 30 insertions(+) diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index 59434f26ba0d..910e8ed94a60 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -115,6 +115,11 @@ impl TlsConfig { } if let Some(v) = tls.get(global, "servername")?.filter(|v| v.is_string()) { let mut bytes = bun_core::String::from_js(v, global)?.to_owned_slice(); + if bun_core::strings::contains_char(&bytes, 0) { + return Err(global.throw_invalid_arguments(format_args!( + "servername must not contain null bytes" + ))); + } bytes.push(0); config.servername = Some(bytes); } diff --git a/src/sql_jsc/shared/ConnectionCtorArgs.rs b/src/sql_jsc/shared/ConnectionCtorArgs.rs index d41985923f73..92a7676bd1ba 100644 --- a/src/sql_jsc/shared/ConnectionCtorArgs.rs +++ b/src/sql_jsc/shared/ConnectionCtorArgs.rs @@ -59,6 +59,11 @@ impl ConnectionCtorArgs { let username_str = arguments[2].to_bun_string(global_object)?; let password_str = arguments[3].to_bun_string(global_object)?; let database_str = arguments[4].to_bun_string(global_object)?; + // A C string from here on: the resolver, SNI and the certificate check would see the name up to the NUL. + if bun_core::strings::contains_char(hostname_str.to_utf8().slice(), 0) { + return Err(global_object + .throw_invalid_arguments(format_args!("hostname must not contain null bytes"))); + } let modes = M::MODES; let ssl_mode = usize::try_from(arguments[5].to_int32()) .ok() diff --git a/test/js/node/quic/quic-sni.test.ts b/test/js/node/quic/quic-sni.test.ts index 04e4bdc23442..a67c46a71be8 100644 --- a/test/js/node/quic/quic-sni.test.ts +++ b/test/js/node/quic/quic-sni.test.ts @@ -238,3 +238,11 @@ test("a client verifies an Ed25519 server certificate", async () => { await session.close(); } }); + +test("connect() refuses a servername with a NUL byte", async () => { + await using server = await listen(ignoreErrors, { sni: { "*": identity1 }, alpn: ["quic-test"] }); + expect( + (async () => + connect(server.address, { alpn: "quic-test", servername: "agent2.example\0evil", verifyPeer: "manual" }))(), + ).rejects.toThrow("servername must not contain null bytes"); +}); diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index 3f10f169a5a9..26dd363a3e0f 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -12711,6 +12711,18 @@ describe("shared createInstance validation (no server)", () => { }, ); + test.concurrent.each(["postgres", "mysql"] as const)("%s rejects a hostname containing null bytes", async adapter => { + for (const hostname of ["127.0.0.1\0evil.example.invalid", "127.0.0.1\0"]) { + await using sql = new SQL({ ...base, adapter, hostname, username: "u" }); + const err: any = await sql`select 1`.then( + () => null, + e => e, + ); + expect(err?.message).toBe("hostname must not contain null bytes"); + expect(err?.code).toBe("ERR_INVALID_ARG_TYPE"); + } + }); + test.concurrent("SSL_CTX creation failure throws the structured BoringSSL error", async () => { // An unparseable CA makes `SSL_CTX` creation fail synchronously inside // createInstance, before any socket exists. The failure carries From 2c6464182a01e2d6dc4e962cf04ca24a465941a8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:30:59 +0000 Subject: [PATCH 051/174] process.env: a write to an object that inherits from the shared env map is that object's #42692 gave JSEnvironmentVariableMap::put a receiver check. JSSharedEnvMap, which replaces it once a worker starts with env: SHARE_ENV, had none, so Object.create(process.env).NODE_TLS_REJECT_UNAUTHORIZED = "0" wrote to the process and turned certificate verification off. --- src/jsc/bindings/JSEnvironmentVariableMap.cpp | 4 ++ .../worker_threads/worker_threads.test.ts | 37 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/JSEnvironmentVariableMap.cpp b/src/jsc/bindings/JSEnvironmentVariableMap.cpp index e3d6192daed8..0ec535e51b8d 100644 --- a/src/jsc/bindings/JSEnvironmentVariableMap.cpp +++ b/src/jsc/bindings/JSEnvironmentVariableMap.cpp @@ -743,6 +743,10 @@ bool JSSharedEnvMap::put(JSCell* cell, JSGlobalObject* globalObject, PropertyNam VM& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); + // As in JSEnvironmentVariableMap::put, a write to an object that inherits from process.env is that object's. + if (slot.thisValue() != cell) [[unlikely]] + RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); + auto* uid = propertyName.uid(); if (propertyName.isSymbol() || !uid) { RELEASE_AND_RETURN(scope, Base::put(cell, globalObject, propertyName, value, slot)); diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index a1a4d4239df3..c0c00f66098a 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isDebug, tempDir, tls, tmpdirSync } from "harness"; import { once } from "node:events"; import fs from "node:fs"; import { join, relative, resolve } from "node:path"; @@ -1886,6 +1886,41 @@ describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide on expect(exitCode).toBe(0); }); + it("a write to an object that inherits from the shared process.env is that object's", async () => { + // Without the fetch this script prints the same on Node.js v26.3.0. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker, SHARE_ENV } = require("worker_threads"); + const worker = new Worker('require("worker_threads").parentPort.once("message", () => {})', { eval: true, env: SHARE_ENV }); + worker.once("online", async () => { + const child = Object.create(process.env); + child.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + child.OTHER = "child"; + const out = { + child: [Object.hasOwn(child, "NODE_TLS_REJECT_UNAUTHORIZED"), Object.hasOwn(child, "OTHER")], + env: [process.env.NODE_TLS_REJECT_UNAUTHORIZED ?? null, process.env.OTHER ?? null], + }; + if (typeof Bun !== "undefined") { + using server = Bun.serve({ port: 0, tls: ${JSON.stringify(tls)}, fetch: () => new Response("ok") }); + out.fetch = await fetch(server.url).then(() => "not verified", error => error.code); + } + console.log(JSON.stringify(out)); + worker.postMessage("done"); + });`, + ], + env: { ...bunEnv, NODE_TLS_REJECT_UNAUTHORIZED: undefined }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ parsed: stdout ? JSON.parse(stdout) : stdout, stderr, exitCode }).toEqual({ + parsed: { child: [true, true], env: [null, null], fetch: "DEPTH_ZERO_SELF_SIGNED_CERT" }, + stderr: "", + exitCode: 0, + }); + }); + // Integer-like keys reach JSC through the indexed hooks; without ByIndex overrides // they land in JSObject's indexed storage and never touch the shared store. it("routes integer-like env keys through the shared store", async () => { From 8cfefe26a7b96eef9f0c40fe3ae8c007df3addd8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:33:52 +0000 Subject: [PATCH 052/174] types: declare crl on Bun.TLSOptions (#38092) The option works (SSLConfig.bindv2.ts, openssl.c) but TypeScript refused it with TS2353, and no page mentioned it. --- docs/runtime/http/server.mdx | 3 +++ docs/runtime/http/tls.mdx | 16 ++++++++++++++++ packages/bun-types/bun.d.ts | 6 ++++++ test/integration/bun-types/fixture/fetch.ts | 3 +++ test/integration/bun-types/fixture/serve.ts | 8 ++++++++ test/integration/bun-types/fixture/tcp.ts | 1 + 6 files changed, 37 insertions(+) diff --git a/docs/runtime/http/server.mdx b/docs/runtime/http/server.mdx index 9be7d8385804..433e91424c11 100644 --- a/docs/runtime/http/server.mdx +++ b/docs/runtime/http/server.mdx @@ -741,6 +741,9 @@ interface TLSOptions { /** Server certificate */ cert?: string | Buffer | BunFile | Array; + /** Certificate revocation lists */ + crl?: string | Buffer | BunFile | Array; + /** Path to DH parameters file */ dhParamsFile?: string; diff --git a/docs/runtime/http/tls.mdx b/docs/runtime/http/tls.mdx index 220ed998d791..9e7c0b425d87 100644 --- a/docs/runtime/http/tls.mdx +++ b/docs/runtime/http/tls.mdx @@ -72,6 +72,22 @@ Bun.serve({ With `requestCert: true`, the server refuses a client that sends no certificate or one that `ca` does not verify. Set `rejectUnauthorized: false` to accept those clients too. The `NODE_TLS_REJECT_UNAUTHORIZED` environment variable does not change this for a server. +### Certificate revocation lists + +Pass `crl` to refuse peer certificates that a CA has revoked. It takes the PEM contents of one or more CRLs, in the same value types as `ca`. On a server it applies to client certificates, so use it with `requestCert`. + +```ts +Bun.serve({ + tls: { + key: Bun.file("./key.pem"), + cert: Bun.file("./cert.pem"), + ca: Bun.file("./client-ca.pem"), + requestCert: true, + crl: Bun.file("./client-ca.crl.pem"), // [!code ++] + }, +}); +``` + ### Diffie-Hellman To override Diffie-Hellman parameters: diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index fba2660c596a..1f5401b7fef8 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4805,6 +4805,12 @@ declare module "bun" { * with `requestCert: true`. */ ca?: string | BufferSource | BunFile | Array | undefined; + /** + * PEM formatted CRLs (Certificate Revocation Lists). A peer certificate + * listed in one of them fails verification: a client certificate when a + * server sets `requestCert`, or the server certificate when connecting. + */ + crl?: string | BufferSource | BunFile | Array | undefined; /** * Cert chains in PEM format. One cert chain should be provided per * private key. Each cert chain should consist of the PEM formatted diff --git a/test/integration/bun-types/fixture/fetch.ts b/test/integration/bun-types/fixture/fetch.ts index a9e3db4e28f2..86a8abbf7444 100644 --- a/test/integration/bun-types/fixture/fetch.ts +++ b/test/integration/bun-types/fixture/fetch.ts @@ -383,4 +383,7 @@ if (typeof process !== "undefined") { fetch("https://example.com", { session: {} }); // @ts-expect-error `true` does not name a proxy fetch("https://example.com", { proxy: true }); + fetch("https://example.com", { tls: { ca: "ca", crl: Buffer.from("crl") } }); + // @ts-expect-error crl takes PEM contents + fetch("https://example.com", { tls: { crl: 1 } }); } diff --git a/test/integration/bun-types/fixture/serve.ts b/test/integration/bun-types/fixture/serve.ts index 6443fcd55c13..7eaee97aab5e 100644 --- a/test/integration/bun-types/fixture/serve.ts +++ b/test/integration/bun-types/fixture/serve.ts @@ -84,3 +84,11 @@ const s4 = Bun.serve({ }, }, }); + +Bun.serve({ + tls: [ + { key: "key", cert: "cert", ca: "ca", requestCert: true, crl: Bun.file("crl.pem") }, + { serverName: "a.example.com", key: "key", cert: "cert", crl: "crl" }, + ], + fetch: () => new Response(), +}); diff --git a/test/integration/bun-types/fixture/tcp.ts b/test/integration/bun-types/fixture/tcp.ts index e8f42689a1b2..f3d0094c43aa 100644 --- a/test/integration/bun-types/fixture/tcp.ts +++ b/test/integration/bun-types/fixture/tcp.ts @@ -102,6 +102,7 @@ Bun.listen({ cert: "asdf", key: Bun.file("adsf"), ca: Buffer.from("asdf"), + crl: ["asdf", Buffer.from("asdf"), new Uint8Array(1), Bun.file("asdf")], }, }); From 5d4f209ddfc74ea25655225faadaf6ee1afc0011 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:50:42 +0000 Subject: [PATCH 053/174] test(tls): a standalone server-side TLSSocket asks for a client certificate with requestCert only (#33963) Regression cover for #33954, which #32630 fixed. --- test/js/node/tls/node-tls-server.test.ts | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index b605eedf7011..c79e806cbe07 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3944,3 +3944,42 @@ it("tls.DEFAULT_CIPHERS applies to every context built without a ciphers option" }); expect(exitCode).toBe(0); }); + +// https://github.com/oven-sh/bun/issues/33954 +it.each([ + [{ requestCert: true }, "agent1"], + [{}, undefined], +])( + "new TLSSocket(socket, { isServer: true, ...%j }) asks for a client certificate with requestCert only", + async (options, peer) => { + const fixtures = join(import.meta.dir, "fixtures"); + const identity = { + key: readFileSync(join(fixtures, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "agent1-cert.pem"), "utf8"), + }; + const secure = Promise.withResolvers(); + const rawServer = net.createServer(raw => { + const socket = new TLSSocket(raw, { isServer: true, ...identity, ...options }); + socket.on("secure", () => secure.resolve(socket.getPeerCertificate()?.subject?.CN)); + socket.on("data", data => socket.write(data)); + socket.on("error", secure.reject); + }); + await once(rawServer.listen(0, "127.0.0.1"), "listening"); + const client = connect({ + host: "127.0.0.1", + port: (rawServer.address() as AddressInfo).port, + ...identity, + rejectUnauthorized: false, + }); + try { + client.on("error", secure.reject); + expect(await secure.promise).toBe(peer); + // The wrap trusts no CA, and without rejectUnauthorized it keeps the connection. + client.write("ping"); + expect(String((await once(client, "data"))[0])).toBe("ping"); + } finally { + client.destroy(); + rawServer.close(); + } + }, +); From 778f46a25e75703fb91d785a47059cd848eae45b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:32:24 +0000 Subject: [PATCH 054/174] redis: an IPv6 address with a zone id is not sent as SNI (#42054, #43982) RedisClient's new SNI gate used the plain address check, so tls.serverName "::1%lo" went out as a host name. It now uses the zone-aware check of the other clients. --- src/runtime/valkey_jsc/js_valkey.rs | 2 +- test/js/valkey/valkey-tls-verify.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index 9f7432af94b7..f90e8acd8603 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -1759,7 +1759,7 @@ impl SocketHandler { // RFC 6066 section 3: an IP literal is not sent as SNI. let sni = Self::configured_hostname(this); if !sni.is_empty() - && !bun_core::ip_address::is_ip_address(sni) + && !bun_core::ip_address::is_ip_host(sni) && let Some(ssl) = socket.ssl_mut() { ssl.set_servername(bun_core::ZBox::from_bytes(sni).as_cstr()); diff --git a/test/js/valkey/valkey-tls-verify.test.ts b/test/js/valkey/valkey-tls-verify.test.ts index a84f846fb50a..0226d1bff200 100644 --- a/test/js/valkey/valkey-tls-verify.test.ts +++ b/test/js/valkey/valkey-tls-verify.test.ts @@ -279,6 +279,15 @@ describe("RedisClient tls.serverName", () => { }); }); + // The zone id names an interface of the client, so the certificate has the address alone. + test("an IPv6 address with a zone id is verified against the IP SAN of the address and is not sent as SNI", async () => { + await withServer(localhost, async (port, server) => { + const servernames = recordServernames(server); + expect(await ping(`rediss://localhost:${port}`, { ca: localhostTls.cert, serverName: "::1%lo" })).toBe("PONG"); + expect(servernames).toEqual([false]); + }); + }); + test("tls: true sends the URL host as SNI", async () => { const servernames: string[] = []; const SNICallback: tls.TlsOptions["SNICallback"] = (servername, callback) => { From eec87910d1320609d20d5e5f7b46c5cb8f196a84 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:34:01 +0000 Subject: [PATCH 055/174] tls: pin a relative keyFile/certFile/caFile/dhParamsFile without resolving `..` as text (#40984) Path::join normalises, so "linkdir/../key.pem" with linkdir a symlink named another file than the one the kernel opens, for absolute paths too, which needed no pinning at all. On POSIX an absolute path is now kept as typed and a relative one is appended to the cwd of the call. Windows keeps join: Win32 resolves `..` the same way, and join knows `C:foo` and `\foo`. append drops a trailing slash, with which no file opens, so such a path is refused as it was. Also covers `tls: true` in the tls.DEFAULT_CIPHERS test (#41810). --- src/runtime/socket/SSLConfig.rs | 16 +++++++-- test/js/bun/http/bun-serve-ssl.test.ts | 43 ++++++++++++++++++++++-- test/js/node/tls/node-tls-server.test.ts | 19 +++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index dcbd0ce6fb9d..f438bc6c70e8 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -336,9 +336,21 @@ fn handle_path( .throw()); } // Opened later (for fetch, on the HTTP thread), and the SSL_CTX caches key on this string. - let mut path = bun_paths::AutoAbsPathChecked::init_top_level_dir(); + let mut path = if cfg!(windows) || !bun_paths::is_absolute_posix(name) { + bun_paths::AutoAbsPathChecked::init_top_level_dir() + } else { + bun_paths::AutoAbsPathChecked::init() + }; + // The kernel resolves `..` through symlinks, so it is left in. Win32 resolves it as `join` does, which also knows `C:foo` and `\foo`. + let pinned = if cfg!(windows) { + path.join(&[name]) + } else { + path.append(name) + }; + // `append` drops a trailing slash, with which no file opens. if name.is_empty() - || path.join(&[name]).is_err() + || (cfg!(not(windows)) && name.ends_with(b"/")) + || pinned.is_err() || bun_sys::access(path.slice_z(), bun_sys::posix::F_OK).is_err() { return Err(global.throw_invalid_arguments(format_args!("Unable to access {field} path"))); diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 9d2cfc83ab45..269fed9b6172 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "fs"; -import { bunEnv, bunExe, tempDir, tls as tlsCert } from "harness"; +import { readFileSync, symlinkSync } from "fs"; +import { bunEnv, bunExe, expiredTls, isWindows, tempDir, tls as tlsCert } from "harness"; import { once } from "node:events"; import net from "node:net"; import tls from "node:tls"; @@ -572,6 +572,45 @@ test("keyFile/certFile/caFile/dhParamsFile reject a path with a NUL byte instead ); }); +// `linkdir/..` is `other`, where the key of the certificate is. Resolved as text it is the directory of another key. +test.skipIf(isWindows).each(["relative", "absolute"])( + "keyFile reaches `..` through a symlink (%s path)", + async kind => { + using dir = tempDir("bun-serve-ssl-symlink", { + "cert.pem": tlsCert.cert, + "key.pem": expiredTls.key, + "other/key.pem": tlsCert.key, + "other/sub/.keep": "", + }); + symlinkSync(join(String(dir), "other", "sub"), join(String(dir), "linkdir")); + const script = ` + using server = Bun.serve({ + port: 0, + tls: { keyFile: process.argv[1] + "linkdir/../key.pem", certFile: "./cert.pem" }, + fetch: () => new Response("served"), + }); + console.log(await fetch(server.url, { tls: { caFile: "cert.pem" } }).then(res => res.text())); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script, kind === "absolute" ? String(dir) + "/" : ""], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "served\n", stderr: "", exitCode: 0 }); + }, +); + +test.skipIf(isWindows)("a keyFile with a trailing slash is not the file without it", () => { + using dir = tempDir("bun-serve-ssl-slash", { "key.pem": tlsCert.key, "cert.pem": tlsCert.cert }); + const certFile = join(String(dir), "cert.pem"); + expect(() => + Bun.serve({ port: 0, tls: { keyFile: join(String(dir), "key.pem") + "/", certFile }, fetch: () => new Response() }), + ).toThrow("Unable to access keyFile path"); +}); + describe.concurrent("client certificate policy", () => { async function run(NODE_TLS_REJECT_UNAUTHORIZED: string | undefined, policies: string[]) { await using proc = Bun.spawn({ diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index c79e806cbe07..4869e88e781c 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3917,6 +3917,24 @@ it("tls.DEFAULT_CIPHERS applies to every context built without a ciphers option" }, }), ); + // The TLS 1.2 suites in the ClientHello of \`tls: true\`. + results.tlsTrue = await new Promise(resolve => { + const raw = net.createServer(socket => { + let hello = Buffer.alloc(0); + socket.on("data", chunk => { + hello = Buffer.concat([hello, chunk]); + if (hello.length < 5 || hello.length < 5 + hello.readUInt16BE(3)) return; + const at = 5 + 4 + 2 + 32 + 1 + hello[5 + 4 + 2 + 32]; + const suites = []; + for (let i = at + 2; i < at + 2 + hello.readUInt16BE(at); i += 2) suites.push(hello.readUInt16BE(i)); + socket.destroy(); + resolve(suites.filter(suite => suite >> 8 !== 0x13)); + }); + }); + raw.listen(0, "127.0.0.1", () => + Bun.connect({ hostname: "127.0.0.1", port: raw.address().port, tls: true, socket: { data() {}, error() {} } }), + ); + }); } for (const [name, port] of Object.entries(ports)) results[name] = [await probe(port, AES256), await probe(port, AES128)]; @@ -3940,6 +3958,7 @@ it("tls.DEFAULT_CIPHERS applies to every context built without a ciphers option" serve: ["ALERT_HANDSHAKE_FAILURE", AES128], listen: ["ALERT_HANDSHAKE_FAILURE", AES128], connect: "EPROTO", + tlsTrue: [0xc02f], tls13Only: ["ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION", "ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION"], }); expect(exitCode).toBe(0); From 213719e1c5d625d7fe066c29d6ed1fea31c6ef20 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:40:53 +0000 Subject: [PATCH 056/174] node:tls: stop the Server prototype from pinning the last tls.Server constructed The Server constructor assigned Server.prototype.getTicketKeys, setTicketKeys and [kNativeSecureContextCtor] on every call. The two functions were closures over that call's scope, which holds `this` and the addContext() map, so the prototype kept the most recently constructed tls.Server reachable for the life of the process, with every SSL_CTX it owns: its addContext() entries and its shared SecureContext. They are now assigned once, at module level. --- src/js/node/tls.ts | 38 ++++++++++++------------ test/js/node/tls/node-tls-server.test.ts | 26 ++++++++++++++++ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 87e1f5f6ad32..b9dd49034e8b 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1314,25 +1314,6 @@ function Server(options, secureConnectionListener): void { this._sharedCreds = sharedCreds; }; - // Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to - // an SNICallback (the `context.context || context` unwrap accepts both the - // wrapper and the unwrapped native context). - Server.prototype[kNativeSecureContextCtor] = NativeSecureContext; - - Server.prototype.getTicketKeys = function () { - throw Error("Not implented in Bun yet"); - }; - - Server.prototype.setTicketKeys = function (keys) { - if (!ArrayBuffer.isView(keys)) { - throw $ERR_INVALID_ARG_TYPE("buffer", ["Buffer", "TypedArray", "DataView"], keys); - } - if (keys.byteLength !== 48) { - throw $ERR_INVALID_ARG_VALUE("buffer", keys, "Session ticket keys must be a 48-byte buffer"); - } - throw Error("Not implented in Bun yet"); - }; - this[buntls] = function (port, host, isClient) { const requestCert = isClient ? true : this._requestCert; return [ @@ -1416,6 +1397,25 @@ function Server(options, secureConnectionListener): void { } $toClass(Server, "Server", NetServer); +// Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to +// an SNICallback (the `context.context || context` unwrap accepts both the +// wrapper and the unwrapped native context). +Server.prototype[kNativeSecureContextCtor] = NativeSecureContext; + +Server.prototype.getTicketKeys = function () { + throw Error("Not implented in Bun yet"); +}; + +Server.prototype.setTicketKeys = function (keys) { + if (!ArrayBuffer.isView(keys)) { + throw $ERR_INVALID_ARG_TYPE("buffer", ["Buffer", "TypedArray", "DataView"], keys); + } + if (keys.byteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("buffer", keys, "Session ticket keys must be a 48-byte buffer"); + } + throw Error("Not implented in Bun yet"); +}; + function createServer(options, connectionListener) { return new Server(options, connectionListener); } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 4869e88e781c..0329af938c03 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4002,3 +4002,29 @@ it.each([ } }, ); + +describe("a tls.Server nothing refers to any more is collected", () => { + it.each([ + ["never listened", async (_: Server) => {}], + [ + "listened and closed", + async (server: Server) => { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + server.close(); + await once(server, "close"); + }, + ], + ])("%s", async (_, use) => { + const ref = await (async () => { + const server: Server = createServer(COMMON_CERT); + server.addContext("a.example", COMMON_CERT); + await use(server); + return new WeakRef(server); + })(); + // A WeakRef keeps its target until the job that created it ends. + await new Promise(resolve => setImmediate(resolve)); + Bun.gc(true); + expect(ref.deref()).toBeUndefined(); + }); +}); From 870fb236fc5854985d3e6b9afcb30974d1cb7d58 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:40:54 +0000 Subject: [PATCH 057/174] test(tls): count the server names' SSL_CTXs in a process of its own (#42355) "the names last as long as the connections, and no longer" took its baseline in the test process, so contexts earlier tests had left for the GC died during the test and offset the ones under test: it passed in the file and failed alone. It now runs as a spawned script and asserts exact deltas: 7 while the connections are open, 0 once they are gone. --- test/js/node/tls/node-tls-server.test.ts | 83 ++++++++++++++++++------ 1 file changed, 64 insertions(+), 19 deletions(-) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 0329af938c03..c665cdad880a 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3736,28 +3736,73 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names after close() (%s)" ]); }); + // In a process of its own: a count taken here would include what earlier tests left for the GC. it("the names last as long as the connections, and no longer", async () => { - Bun.gc(true); - const before = sslCtxLiveCount(); - // Nothing here outlives the call but the raw client sockets. - const raws = await (async () => { - const server: Server = createServer({ ...agent(2), sessionTimeout: 4001 }); - for (let i = 0; i < 4; i++) { - server.addContext(`${i}.example`, tls.createSecureContext({ ...agent(1), sessionTimeout: 4002 + i })); + const script = ` + const { sslCtxLiveCount } = require("bun:internal-for-testing"); + const net = require("node:net"), tls = require("node:tls"), { once } = require("node:events"); + const agent = ${JSON.stringify([, agent(1), agent(2)])}; + const before = sslCtxLiveCount(); + const result = {}; + + // Nothing here outlives the call but the raw client sockets and the 'close' promise. + async function serve() { + const server = tls.createServer({ ...agent[2], sessionTimeout: 4001 }, s => s.on("error", () => {}).end()); + for (let i = 0; i < 4; i++) { + server.addContext(i + ".example", tls.createSecureContext({ ...agent[1], sessionTimeout: 4002 + i })); + } + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const accepted = Promise.withResolvers(); + let connections = 0; + server.on("connection", () => ++connections === 4 && accepted.resolve()); + const raws = [0, 1, 2, 3].map(() => net.connect(server.address().port, "127.0.0.1")); + await accepted.promise; + const closed = once(server, "close"); + server.close(); + return { raws, closed }; } - const raws = await acceptRaw(server, 4); - server.close(); - return raws; - })(); - Bun.gc(true); - expect(sslCtxLiveCount() - before).toBeGreaterThanOrEqual(5); - expect(await Promise.all(raws.map((raw, i) => servedCN(raw, `${i}.example`)))).toEqual(Array(4).fill("agent1")); - // Finalizers run on GC, so wait for the condition. - for (let i = 0; i < 100 && sslCtxLiveCount() > before; i++) { + + const { raws, closed } = await serve(); Bun.gc(true); - await new Promise(resolve => setImmediate(resolve)); - } - expect(sslCtxLiveCount()).toBeLessThanOrEqual(before); + result.open = sslCtxLiveCount() - before; + result.served = await Promise.all( + raws.map(async (socket, i) => { + const client = tls.connect({ + socket, + servername: i + ".example", + rejectUnauthorized: false, + minVersion: "${version}", + maxVersion: "${version}", + }); + await once(client, "secureConnect"); + const { CN } = client.getPeerCertificate().subject; + client.destroy(); + return CN; + }), + ); + await closed; + // Finalizers run on GC, so wait for the condition. + for (let i = 0; i < 10 && sslCtxLiveCount() !== before; i++) { + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); + } + result.left = sslCtxLiveCount() - before; + console.log(JSON.stringify(result)); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, result: JSON.parse(stdout || "null") }).toEqual({ + stderr: "", + result: { + // The 4 entries, the listener's default (each accepted SSL holds it), and what the Server the + // connections keep alive owns: _sharedCreds and the context the accepted TLSSockets share. + open: 7, + served: ["agent1", "agent1", "agent1", "agent1"], + left: 0, + }, + }); + expect(exitCode).toBe(0); }); }); From cfd715bff4880bc12018a1b0158a416b5c86c6a4 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:44:29 +0000 Subject: [PATCH 058/174] test(tls): make "frees the context it replaces" exact and independent of earlier tests (#42050) Since setSecureContext() builds the Server's shared SecureContext, a swap leaves the replaced one, which is interned and GC-owned, alive until the next GC. The test counted before any GC, so alone it saw one context too many; in the file it passed only because of what earlier tests had left behind. It now runs as a spawned script, a second server keeps the other interned context referenced so that no call leaves a context to the GC, and the deltas are exact without a GC: 0 after 20 swaps, -1 after close(). --- test/js/node/tls/node-tls-server.test.ts | 53 ++++++++++++++---------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index c665cdad880a..95e34ff2b7c4 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1,5 +1,3 @@ -// debug-only export -import { sslCtxLiveCount } from "bun:internal-for-testing"; import cluster from "cluster"; import crypto from "crypto"; import { readFileSync, realpathSync } from "fs"; @@ -1740,34 +1738,43 @@ describe("setSecureContext() on a listening server", () => { } }); + // In a process of its own: a count taken here would include what earlier tests left for the GC. it("frees the context it replaces", async () => { - const server: Server = createServer({ ...agent1 }); - try { - await listen(server); - Bun.gc(true); + const script = ` + const { sslCtxLiveCount } = require("bun:internal-for-testing"); + const tls = require("node:tls"), { once } = require("node:events"); + const [agent1, agent3] = ${JSON.stringify([agent1, agent3])}; + const server = tls.createServer(agent1); + // The shared context of a Server is interned and dies on GC. This one keeps agent3's + // referenced, so no call below leaves a context to the GC and every count is exact. + const holder = tls.createServer(agent3); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); const listening = sslCtxLiveCount(); - for (let i = 0; i < 20; i++) server.setSecureContext(i % 2 ? { ...agent1 } : { ...agent3 }); - expect(() => - server.setSecureContext({ - key: agent3.key, - cert: "-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----", - }), - ).toThrow(); + for (let i = 0; i < 20; i++) server.setSecureContext(i % 2 ? agent1 : agent3); + let threw = false; + try { + server.setSecureContext({ key: agent3.key, cert: "-----BEGIN CERTIFICATE-----\\nnope\\n-----END CERTIFICATE-----" }); + } catch { + threw = true; + } // A leak adds one live SSL_CTX per call. - expect(sslCtxLiveCount() - listening).toBeLessThanOrEqual(0); + const swapped = sslCtxLiveCount() - listening; server.close(); await once(server, "close"); - // The listener lets go of the last one. Finalizers run on GC, so wait for the condition. - for (let i = 0; i < 50 && sslCtxLiveCount() >= listening; i++) { - Bun.gc(true); - await new Promise(resolve => setImmediate(resolve)); - } - expect(sslCtxLiveCount()).toBeLessThan(listening); - } finally { - server.close(); - } + // The listener lets go of the last one. + console.log(JSON.stringify({ threw, swapped, closed: sslCtxLiveCount() - listening })); + holder.close(); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, result: JSON.parse(stdout || "null") }).toEqual({ + stderr: "", + result: { threw: true, swapped: 0, closed: -1 }, + }); + expect(exitCode).toBe(0); }); it("a rejected call changes neither the listener nor the context injected sockets get", async () => { From b52eda87c5f12e8713e8be4e1437182d13ad57c7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:08:37 +0000 Subject: [PATCH 059/174] node:tls: attach the onConnectEnd 'end' listener once per connect() (#38007) connect() prepended onConnectEnd, then internalConnect() prepended it again and internalConnectMultiple() once more per attempt, while the handshake removes one copy. The two per-attempt blocks only re-assigned state connect() had just set, so they are gone; what has to be rebuilt per attempt (the native tls options, so a late setServername()/setSession() reaches the dial) is one helper. connect() drops a copy a failed dial left behind, so a retry loop on one socket no longer warns about a listener leak. --- src/js/node/net.ts | 68 ++++------- test/js/node/tls/node-tls-connect.test.ts | 133 ++++++++++++++++++++++ 2 files changed, 153 insertions(+), 48 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index b4cf11dd51ac..8e370d1a281f 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -2203,6 +2203,7 @@ Socket.prototype.connect = function connect(...args) { this._securePending = true; this[kConnectOptions] = options; // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1811 + this.removeListener("end", onConnectEnd); if (!this[kStandaloneWrap]) this.prependListener("end", onConnectEnd); } // start using existing connection @@ -3293,6 +3294,23 @@ function lookupAndConnectMultiple(self, lookup, host, options, dnsopts, port, lo }); } +// Per attempt, not per connect(): setServername()/setSession() calls made after tls.connect() must reach the dial. +function clientTLSOptionsForAttempt(self, options, port): TLSConnectOptions | undefined { + const bunTLS = self[bunTlsSymbol]; + if (typeof bunTLS !== "function") return undefined; + const tls = bunTLS.$call(self, port, self._host, true); + if (tls) { + const { rejectUnauthorized, session, checkServerIdentity } = options; + applyRejectUnauthorized(self, tls, rejectUnauthorized); + tls.requestCert = true; + tls.session = session || tls.session; + self.servername = tls.servername; + tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; + self[bunTLSConnectOptions] = tls; + } + return tls; +} + function internalConnect(self, options, path); function internalConnect(self, options, address, port, addressType, localAddress, localPort, _flags?); function internalConnect(self, options, address, port?, addressType?, localAddress?, localPort?, _flags?) { @@ -3326,30 +3344,7 @@ function internalConnect(self, options, address, port?, addressType?, localAddre } } - //TLS - let tls: TLSConnectOptions | undefined = undefined; - const bunTLS = self[bunTlsSymbol]; - if (typeof bunTLS === "function") { - tls = bunTLS.$call(self, port, self._host, true); - self._requestCert = true; // Client always request Cert - if (tls) { - const { rejectUnauthorized, session, checkServerIdentity } = options; - applyRejectUnauthorized(self, tls, rejectUnauthorized); - tls.requestCert = true; - tls.session = session || tls.session; - self.servername = tls.servername; - tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; - self[bunTLSConnectOptions] = tls; - } - self.authorized = false; - self.secureConnecting = true; - self[kPreHandshakeWrite] = false; - self._secureEstablished = false; - self._securePending = true; - self[kConnectOptions] = options; - self.prependListener("end", onConnectEnd); - } - //TLS + const tls = clientTLSOptionsForAttempt(self, options, port); $debug("connect: attempting to connect to %s:%d (addressType: %d)", address, port, addressType); self.emit("connectionAttempt", address, port, addressType); @@ -3468,30 +3463,7 @@ function internalConnectMultiple(context, canceled?) { return; } - //TLS - let tls: TLSConnectOptions | undefined = undefined; - const bunTLS = self[bunTlsSymbol]; - if (typeof bunTLS === "function") { - tls = bunTLS.$call(self, port, self._host, true); - self._requestCert = true; // Client always request Cert - if (tls) { - const { rejectUnauthorized, session, checkServerIdentity } = context.options; - applyRejectUnauthorized(self, tls, rejectUnauthorized); - tls.requestCert = true; - tls.session = session || tls.session; - self.servername = tls.servername; - tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; - self[bunTLSConnectOptions] = tls; - } - self.authorized = false; - self.secureConnecting = true; - self[kPreHandshakeWrite] = false; - self._secureEstablished = false; - self._securePending = true; - self[kConnectOptions] = context.options; - self.prependListener("end", onConnectEnd); - } - //TLS + const tls = clientTLSOptionsForAttempt(self, context.options, port); $debug("connect/multiple: attempting to connect to %s:%d (addressType: %d)", address, port, addressType); self.emit("connectionAttempt", address, port, addressType); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 24cabec56ee5..7e3644cc4aff 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -4653,3 +4653,136 @@ describe("a self-issued certificate that may not sign certificates, like the one }); }); }); + +async function withTLSServer(listenOn: net.ListenOptions, fn: (server: tls.Server) => Promise): Promise { + const server = tls.createServer(COMMON_CERT_, socket => socket.on("error", () => {})); + await once(server.listen(listenOn), "listening"); + try { + return await fn(server); + } finally { + server.close(); + } +} + +// IPv4 only: with lookupIPv6LoopbackFirst the first autoSelectFamily attempt (::1) is refused. +function withIPv4TLSServer(fn: (server: tls.Server) => Promise): Promise { + return withTLSServer({ port: 0, host: "127.0.0.1" }, fn); +} + +function lookupIPv6LoopbackFirst(_host: string, _options: unknown, callback: Function) { + callback(null, [ + { address: "::1", family: 6 }, + { address: "127.0.0.1", family: 4 }, + ]); +} + +function portOf(server: net.Server) { + return (server.address() as AddressInfo).port; +} + +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1706 (removeListener), #L1811 (prependListener) +describe("tls.connect() attaches onConnectEnd to 'end' exactly once", () => { + function onConnectEndCount(socket: tls.TLSSocket) { + return socket.listeners("end").filter(listener => listener.name === "onConnectEnd").length; + } + + async function countsThroughHandshake(socket: tls.TLSSocket) { + const secureConnect = Promise.withResolvers(); + socket.on("error", secureConnect.reject); + socket.on("secureConnect", () => secureConnect.resolve(onConnectEndCount(socket))); + try { + const duringSecureConnect = await secureConnect.promise; + return { duringSecureConnect, afterHandshake: onConnectEndCount(socket) }; + } finally { + socket.destroy(); + } + } + + it("tls.connect({ host, port })", async () => { + await withIPv4TLSServer(async server => { + const socket = tls.connect({ host: "127.0.0.1", port: portOf(server), rejectUnauthorized: false }); + expect(await countsThroughHandshake(socket)).toEqual({ duringSecureConnect: 1, afterHandshake: 0 }); + }); + }); + + it("tls.connect({ host, port, autoSelectFamily }) with a refused first address", async () => { + await withIPv4TLSServer(async server => { + const port = portOf(server); + const socket = tls.connect({ + host: "localhost", + port, + rejectUnauthorized: false, + autoSelectFamily: true, + lookup: lookupIPv6LoopbackFirst, + }); + const counts = await countsThroughHandshake(socket); + expect({ ...counts, attempted: socket.autoSelectFamilyAttemptedAddresses }).toEqual({ + duringSecureConnect: 1, + afterHandshake: 0, + attempted: [`::1:${port}`, `127.0.0.1:${port}`], + }); + }); + }); + + it.skipIf(process.platform === "win32")("tls.connect({ path })", async () => { + using dir = tempDir("tls-connect-path", {}); + const path = join(String(dir), "tls.sock"); + await withTLSServer({ path }, async () => { + const socket = tls.connect({ path, rejectUnauthorized: false }); + expect(await countsThroughHandshake(socket)).toEqual({ duringSecureConnect: 1, afterHandshake: 0 }); + }); + }); + + it("a handshake the peer drops keeps the one copy that reported ECONNRESET", async () => { + await using server = net.createServer(c => { + c.resume(); + c.end(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { promise, resolve } = Promise.withResolvers(); + const socket = tls.connect({ host: "127.0.0.1", port: portOf(server) }); + socket.on("error", resolve); + const error = await promise; + expect({ code: error.code, onConnectEnd: onConnectEndCount(socket) }).toEqual({ + code: "ECONNRESET", + onConnectEnd: 1, + }); + }); + + it("re-dialing one socket after each dropped handshake does not pile copies up", async () => { + await using server = net.createServer(c => c.resume().end()); + await once(server.listen(0, "127.0.0.1"), "listening"); + const options = { host: "127.0.0.1", port: portOf(server) }; + const socket = tls.connect(options); + socket.on("error", () => {}); + socket.resume(); + for (let i = 0; i < 3; i++) { + await new Promise(resolve => socket.once("close", resolve)); + socket.connect(options); + } + await new Promise(resolve => socket.once("close", resolve)); + expect(onConnectEndCount(socket)).toBe(1); + }); + + it("a setServername() made between autoSelectFamily attempts reaches the attempt that connects", async () => { + await withIPv4TLSServer(async server => { + const socket = tls.connect({ + host: "localhost", + port: portOf(server), + rejectUnauthorized: false, + autoSelectFamily: true, + lookup: lookupIPv6LoopbackFirst, + }); + try { + const received = Promise.withResolvers(); + server.once("secureConnection", serverSide => received.resolve(serverSide.servername)); + socket.on("error", received.reject); + socket.once("connectionAttemptFailed", () => socket.setServername("retry.example")); + expect(await received.promise).toBe("retry.example"); + expect(onConnectEndCount(socket)).toBe(0); + } finally { + socket.destroy(); + } + }); + }); +}); From 699cf5679ee95716faf8fab81132f8fe151e87a8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:16:34 +0000 Subject: [PATCH 060/174] node:net: emit 'close' when the handle was detached before the deferred close ran (#38058) A failed server-side handshake destroys the socket with _closeAfterHandlingError, which closes the handle from a microtask. That microtask re-read self._handle. When the failure is reported under a JS call (the stream-level engine fed from a 'data' listener, resumeSNI() from an asynchronous SNICallback) the native close runs first and detaches the handle, so nothing was closed and 'close' was never emitted. _destroy already holds the handle: pass it. closeAdoptedTLSRawNowNT was the same function, so it is gone. --- src/js/node/net.ts | 47 ++++----- test/js/node/tls/node-tls-server.test.ts | 117 +++++++++++++++++++++++ 2 files changed, 137 insertions(+), 27 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 8e370d1a281f..b571fed18636 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -400,12 +400,8 @@ function emitCloseNT(self, hasError) { // Shared-fd TLS pair teardown: mirrors node's close ordering, where the // close-callbacks phase runs after the check phase (lib/net.js close path in // node v26.3.0), so destroy()-time setImmediates still see the pair alive. -function closeAdoptedTLSRawNT(handle, self, isException) { - setImmediate(closeAdoptedTLSRawNowNT, handle, self, isException); -} -function closeAdoptedTLSRawNowNT(handle, self, isException) { - handle.close(onSocketHandleClosed); - setImmediate(emitCloseNT, self, isException); +function closeAdoptedTLSRawNT(self, handle, isException) { + setImmediate(closeSocketHandle, self, handle, isException); } function detachSocket(self) { if (!self) self = this; @@ -2442,14 +2438,15 @@ Socket.prototype._destroy = function _destroy(err, callback) { } else if (this._closeAfterHandlingError) { // Enqueue closing the socket as a microtask, so that the socket can be // accessible when an `error` event is handled in the `next tick queue`. - queueMicrotask(() => closeSocketHandle(this, isException, true)); + // The native close that follows a failure reported under a JS call detaches _handle before this runs. + queueMicrotask(() => closeSocketHandle(this, currentHandle, isException, true)); } else if (currentHandle[kAdoptedTLSRaw]) { // Shared-fd TLS pair: defer the close two check-phase turns // (test-tls-socket-close); see closeAdoptedTLSRawNT. currentHandle.pause?.(); - setImmediate(closeAdoptedTLSRawNT, currentHandle, this, isException); + setImmediate(closeAdoptedTLSRawNT, this, currentHandle, isException); } else { - closeSocketHandle(this, isException); + closeSocketHandle(this, currentHandle, isException); } if (!this._closeAfterHandlingError) { @@ -4485,25 +4482,21 @@ function initSocketHandle(self) { // intercepts close on `socket._handle` and invokes it, so always pass one. function onSocketHandleClosed() {} -function closeSocketHandle(self, isException, isCleanupPending = false) { - const handle = self._handle; - $debug("closeSocketHandle", isException, isCleanupPending, !!handle); - if (handle) { - handle.close(onSocketHandleClosed); - setImmediate(() => { - $debug("emit close", isCleanupPending); - self.emit("close", isException); - if (isCleanupPending) { - // A second destroy() before this runs clears self._handle, and a - // re-attach replaces it - only tear down the handle captured here. - handle.onread = noop; - if (self._handle === handle) { - self._handle = null; - self._sockname = null; - } +function closeSocketHandle(self, handle, isException, isCleanupPending = false) { + $debug("closeSocketHandle", isException, isCleanupPending); + handle.close(onSocketHandleClosed); + setImmediate(() => { + $debug("emit close", isCleanupPending); + self.emit("close", isException); + if (isCleanupPending) { + // self._handle may have been detached or replaced since. + handle.onread = noop; + if (self._handle === handle) { + self._handle = null; + self._sockname = null; } - }); - } + } + }); } // Reformat a native listen error to Node's "listen : " diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 95e34ff2b7c4..7660eba4c54b 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4080,3 +4080,120 @@ describe("a tls.Server nothing refers to any more is collected", () => { expect(ref.deref()).toBeUndefined(); }); }); + +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L480-L488 +describe("a server-side handshake failure reported under a JS call emits 'close'", () => { + // `wrap` writes a plain banner, so `peer` runs once the wrap is in place. + async function failHandshake( + wrap: (conn: net.Socket) => TLSSocket, + peer: (client: net.Socket) => void, + ): Promise { + const events: string[] = []; + const closed = Promise.withResolvers(); + let conn: net.Socket | undefined; + const rawServer = net.createServer(socket => { + conn = socket; + conn.on("error", () => {}); + const wrapped = wrap(conn); + wrapped.on("error", () => events.push("error")); + wrapped.on("close", hadError => { + events.push(`close:${hadError}`); + closed.resolve(events); + }); + }); + let client: net.Socket | undefined; + try { + await once(rawServer.listen(0, "127.0.0.1"), "listening"); + client = net.connect((rawServer.address() as AddressInfo).port, "127.0.0.1"); + client.on("error", () => {}); + client.once("data", () => peer(client!)); + return await closed.promise; + } finally { + client?.destroy(); + conn?.destroy(); + rawServer.close(); + } + } + + const serverOptions = (extra: tls.TlsOptions = {}) => ({ isServer: true, ...COMMON_CERT, ...extra }); + + const sendPlaintext = (client: net.Socket) => { + client.write("this is not a ClientHello\r\n"); + }; + + const rejectingSNI: tls.TlsOptions = { + SNICallback: (_servername, cb) => { + setImmediate(cb, new Error("unknown servername")); + }, + }; + + it("over a connection with unflushed writes", async () => { + const events = await failHandshake(conn => { + conn.cork(); + conn.write("220 banner\r\n"); + const wrapped = new TLSSocket(conn, serverOptions()); + conn.uncork(); + return wrapped; + }, sendPlaintext); + expect(events).toEqual(["error", "close:true"]); + }); + + it("over a generic Duplex", async () => { + const events = await failHandshake(conn => { + conn.write("220 banner\r\n"); + const transport = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + conn.write(chunk, encoding, callback); + }, + final(callback) { + conn.end(callback); + }, + }); + conn.on("data", chunk => transport.push(chunk)); + conn.on("end", () => transport.push(null)); + return new TLSSocket(transport, serverOptions()); + }, sendPlaintext); + expect(events).toEqual(["error", "close:true"]); + }); + + it("when an asynchronous SNICallback rejects a wrap that adopted the fd", async () => { + const events = await failHandshake( + conn => { + conn.write("220 banner\r\n"); + return new TLSSocket(conn, serverOptions(rejectingSNI)); + }, + client => { + connect({ socket: client, servername: "rejected.example.com", rejectUnauthorized: false }).on( + "error", + () => {}, + ); + }, + ); + expect(events).toEqual(["error", "close:true"]); + }); + + it("when an asynchronous SNICallback of a tls.Server rejects", async () => { + const server = createServer({ ...COMMON_CERT, ...rejectingSNI }); + const closed = Promise.withResolvers<{ message: string; hadError: boolean }>(); + server.on("tlsClientError", (err, socket) => { + socket.on("close", hadError => closed.resolve({ message: err.message, hadError })); + }); + server.on("secureConnection", () => closed.reject(new Error("secureConnection must not fire"))); + let client: TLSSocket | undefined; + try { + await once(server.listen(0, "127.0.0.1"), "listening"); + client = connect({ + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + servername: "rejected.example.com", + rejectUnauthorized: false, + }); + client.on("error", () => {}); + expect(await closed.promise).toEqual({ message: "unknown servername", hadError: true }); + } finally { + client?.destroy(); + server.close(); + } + }); +}); From ca91b2f9617bf2667a5387153af679edabd5c0ec Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:16:36 +0000 Subject: [PATCH 061/174] node:tls: call checkServerIdentity with the connect options as `this` (#44085) Node calls it as options.checkServerIdentity(...). Bun called it bare, so a sloppy-mode callback guarding on `this.pin` read globalThis and accepted every certificate. The receiver is the connect options only when they own the callback: after TLSSocket#connect() it can be the constructor's. --- src/js/node/net.ts | 7 +- ...-tls-connect-hostname-verification.test.ts | 168 +++++++++++++++++- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index b571fed18636..dd5fc0441ae9 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -529,7 +529,12 @@ function onClientHandshake(self, socket, success, verifyError) { const hostname = self.servername || options?.host || options?.socket?._host || self._host || "localhost"; const cert = self.getPeerCertificate(true); if (cert) { - verifyError = checkServerIdentity(hostname, cert); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1671. After TLSSocket#connect() the callback can be the constructor's, and then `options` is not its owner. + const receiver = + options !== undefined && $getByIdDirect(options, "checkServerIdentity") === checkServerIdentity + ? options + : undefined; + verifyError = checkServerIdentity.$call(receiver, hostname, cert); } } let rejectUnauthorized; diff --git a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts index 054f0526eb4c..2f0219de2e3f 100644 --- a/test/js/node/tls/node-tls-connect-hostname-verification.test.ts +++ b/test/js/node/tls/node-tls-connect-hostname-verification.test.ts @@ -1,7 +1,9 @@ import { tls as ipSanCert } from "harness"; import assert from "node:assert"; -import { once } from "node:events"; +import { once, type EventEmitter } from "node:events"; import fs from "node:fs"; +import http2 from "node:http2"; +import https from "node:https"; import net, { type AddressInfo } from "node:net"; import path from "node:path"; import { describe, test } from "node:test"; @@ -270,6 +272,170 @@ describe("tls.connect over an existing socket verifies the certificate against o }); }); +// Adds a pin and a callback that records its `this` to the caller's options. +function pinned(callerOptions: T) { + let receiver: any; + let calls = 0; + const options = Object.assign(callerOptions, { + pin: "agent1", + checkServerIdentity(this: unknown) { + calls++; + receiver = this; + return undefined; + }, + }); + const seen = () => ({ + calls, + pin: receiver?.pin, + host: receiver?.host, + ownsCallback: receiver?.checkServerIdentity === options.checkServerIdentity, + isCallerObject: receiver === options, + }); + return { options, seen, receiver: () => receiver }; +} + +// The first of `event`, 'error' and 'close', with its arguments. It removes its listeners then. +function firstOf(emitter: EventEmitter, event: string) { + const { promise, resolve } = Promise.withResolvers<{ name: string; args: any[] }>(); + const names = [event, "error", "close"]; + const listeners = names.map(name => (...args: any[]) => { + names.forEach((name, i) => emitter.off(name, listeners[i])); + resolve({ name, args }); + }); + names.forEach((name, i) => emitter.on(name, listeners[i])); + return promise; +} + +// Resolves with the arguments of `event`. Rejects when 'error' or 'close' comes first. +async function eventOf(emitter: EventEmitter, event: string) { + const { name, args } = await firstOf(emitter, event); + if (name === event) return args; + throw name === "error" ? args[0] : new Error(`'close' came before '${event}'`); +} + +async function secureConnect(socket: tls.TLSSocket) { + try { + await eventOf(socket, "secureConnect"); + } finally { + socket.destroy(); + } +} + +async function outcomeOf(socket: tls.TLSSocket) { + try { + const { name, args } = await firstOf(socket, "secureConnect"); + if (name === "error") return `error: ${args[0].message}`; + return name === "close" ? "close" : `secureConnect, authorized=${socket.authorized}`; + } finally { + socket.destroy(); + } +} + +// What `seen()` gives when `this` is the copy of the caller's options that tls.connect() builds. +const connectOptions = (host: string) => ({ + calls: 1, + pin: "agent1", + host, + ownsCallback: true, + isCallerObject: false, +}); + +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1671 +describe("checkServerIdentity is called with the connect options as `this`", () => { + test("tls.connect(options) to an IP address", async () => { + await withServer(async port => { + const { options, seen } = pinned({ ca, host: "127.0.0.1", port }); + await secureConnect(tls.connect(options)); + assert.deepStrictEqual(seen(), connectOptions("127.0.0.1")); + }); + }); + + test("tls.connect({ socket })", async () => { + await withRawSocketTo({ key: serverKey, cert: serverCert }, async raw => { + const { options, seen } = pinned({ ca, socket: raw, host: "agent1" }); + await secureConnect(tls.connect(options)); + assert.deepStrictEqual(seen(), connectOptions("agent1")); + }); + }); + + test("https.request(options)", async () => { + const server = https.createServer({ key: serverKey, cert: serverCert }, (_req, res) => res.end("ok")); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const { options, seen } = pinned({ ca, host: "127.0.0.1", port: (server.address() as AddressInfo).port }); + const [res] = await eventOf(https.request(options).end(), "response"); + res.resume(); + await eventOf(res, "end"); + assert.deepStrictEqual(seen(), connectOptions("127.0.0.1")); + } finally { + server.close(); + server.closeAllConnections(); + } + }); + + test("http2.connect(authority, options)", async () => { + const server = http2.createSecureServer({ key: serverKey, cert: serverCert }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const { options, seen } = pinned({ ca }); + const session = http2.connect(`https://127.0.0.1:${(server.address() as AddressInfo).port}`, options); + try { + await eventOf(session, "connect"); + } finally { + session.destroy(); + } + assert.deepStrictEqual(seen(), connectOptions("127.0.0.1")); + } finally { + server.close(); + } + }); + + // Sloppy mode turns a missing `this` into globalThis, and the guard then accepts every certificate. + test("a sloppy mode callback that guards on `this.pin` refuses a wrong pin", async () => { + // The Function constructor makes a sloppy mode function in this strict mode module. + const checkServerIdentity = new Function( + "hostname", + "cert", + `if (this.pin && cert.subject.CN !== this.pin) return new Error("pin mismatch");`, + ) as typeof tls.checkServerIdentity; + await withServer(async port => { + const connect = (pin: string) => + tls.connect({ host: "127.0.0.1", port, ca, pin, checkServerIdentity } as tls.ConnectionOptions); + assert.strictEqual(await outcomeOf(connect("agent1")), "secureConnect, authorized=true"); + assert.strictEqual(await outcomeOf(connect("agent2")), "error: pin mismatch"); + }); + }); +}); + +// Bun only: node runs the identity check for a socket from tls.connect() alone. +describe("checkServerIdentity after TLSSocket#connect()", { skip: typeof Bun === "undefined" }, () => { + test("`this` is undefined when the constructor options own the callback", async () => { + await withServer(async port => { + for (const ownKeys of [{}, { checkServerIdentity: undefined }]) { + const { options, seen, receiver } = pinned({ ca }); + // @ts-expect-error @types/node requires a socket + const socket = new tls.TLSSocket(undefined, options); + socket.connect({ host: "127.0.0.1", port, ...ownKeys }); + await secureConnect(socket); + assert.deepStrictEqual({ calls: seen().calls, receiver: receiver() }, { calls: 1, receiver: undefined }); + } + }); + }); + + test("`this` is the options of connect() when they own the callback", async () => { + await withServer(async port => { + const { options, seen } = pinned({ host: "127.0.0.1", port }); + // @ts-expect-error @types/node requires a socket + const socket = new tls.TLSSocket(undefined, { ca }); + socket.connect(options); + await secureConnect(socket); + assert.deepStrictEqual(seen(), { ...connectOptions("127.0.0.1"), isCallerObject: true }); + }); + }); +}); + const escapingDir = path.join(import.meta.dirname, "..", "test", "fixtures", "x509-escaping"); const escapingKey = fs.readFileSync(path.join(escapingDir, "server-key.pem")); From 54d8417da08fd7e76ac0b900627458eef05ce24a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:16:37 +0000 Subject: [PATCH 062/174] node:tls: ignore onread when the TLS socket wraps an existing socket (#42425) Node passes `onread: !socket ? tlsOptions.onread : null` to net.Socket, so a wrap always emits 'data'. Bun honored onread there and 'data' never fired. --- src/js/node/tls.ts | 6 +-- test/js/node/tls/node-tls-upgrade.test.ts | 58 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index b9dd49034e8b..cc9d86e6fac1 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -641,14 +641,14 @@ function TLSSocket(socket?, options?) { throw $ERR_INVALID_ARG_TYPE("socket", "Duplex", socket); } - // The wrapped socket's allowHalfOpen wins: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L592 + // A wrapped socket keeps its allowHalfOpen and drops onread: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L592-L596 if (isNetSocketOrDuplex) { - options = { ...options, allowHalfOpen: socket.allowHalfOpen }; + options = { ...options, allowHalfOpen: socket.allowHalfOpen, onread: null }; } else { options = options || socket || {}; const wrapped = options.socket; if (wrapped instanceof Duplex) { - options = { ...options, allowHalfOpen: wrapped.allowHalfOpen }; + options = { ...options, allowHalfOpen: wrapped.allowHalfOpen, onread: null }; } } diff --git a/test/js/node/tls/node-tls-upgrade.test.ts b/test/js/node/tls/node-tls-upgrade.test.ts index 409028ddbea5..2e62f79d53a7 100644 --- a/test/js/node/tls/node-tls-upgrade.test.ts +++ b/test/js/node/tls/node-tls-upgrade.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { once } from "events"; import { tls as certs } from "harness"; import net from "net"; +import { Duplex } from "stream"; import tls from "tls"; test("should be able to upgrade a paused socket and also have backpressure on it #15438", async () => { @@ -148,3 +149,60 @@ test("a STARTTLS exchange hands no TLS bytes to the 'data' listeners of the wrap server.close(); } }); + +function duplexOver(raw: net.Socket) { + const duplex = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + raw.write(chunk, encoding, callback); + }, + final(callback) { + raw.end(callback); + }, + }); + raw.on("data", chunk => duplex.push(chunk)); + raw.on("end", () => duplex.push(null)); + return duplex; +} + +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L596 +test.each([ + ["tls.connect({ socket: connected })", true, (socket: net.Socket, o: object) => tls.connect({ socket, ...o })], + ["tls.connect({ socket: connecting })", false, (socket: net.Socket, o: object) => tls.connect({ socket, ...o })], + [ + "tls.connect({ socket: Duplex })", + true, + (raw: net.Socket, o: object) => tls.connect({ socket: duplexOver(raw), ...o }), + ], + [ + "new tls.TLSSocket(socket)", + true, + (raw: net.Socket, o: object) => { + const socket = new tls.TLSSocket(raw, o); + // @ts-expect-error node starts the handshake of a constructor wrap here + socket._start(); + return socket; + }, + ], +] as const)("%s ignores onread and emits 'data' (#42419)", async (_name, waitForConnect, wrap) => { + const server = tls.createServer(certs, socket => { + socket.on("error", () => {}); + socket.end("hello"); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const raw = net.connect({ port: (server.address() as net.AddressInfo).port, host: "127.0.0.1" }); + try { + if (waitForConnect) await once(raw, "connect"); + const log: string[] = []; + const tlsSocket = wrap(raw, { + rejectUnauthorized: false, + onread: { buffer: Buffer.alloc(16), callback: (n: number) => log.push(`onread ${n}`) }, + }); + tlsSocket.on("data", data => log.push(`data ${data.length}`)); + await once(tlsSocket, "end"); + expect(log).toEqual(["data 5"]); + } finally { + raw.destroy(); + server.close(); + } +}); From 9e213fbfd17180c364207e7faf698289cd814106 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:45:49 +0000 Subject: [PATCH 063/174] node:tls: link a TLSSocket to the socket it wraps when it is made, not when an engine is attached (#38028, #38122, #38076) Node ties the two sockets together in the TLSSocket constructor, whatever state the wrapped one is in: its 'close' destroys the wrap, its 'error' is re-emitted, and closing the TLS handle destroys it. Bun made those links at the moment a TLS engine was attached, in six copies, and the last one only for a generic Duplex or an adopted fd. So a wrap with no engine yet (a client waiting for 'connect', the server's one-tick deferral, a server over a connecting socket) and every stream-level wrap over a net.Socket had none: the fd leaked and server.close() never called back, a refused connect was uncaught or reported nowhere, a socket dialed again was upgraded by its dead wrap, and a server wrap adopted a half-connected socket, which kept the process alive. linkUpgraded() makes the links once, before any branching. The client and server attach code is one function each over two shared helpers. _destroy takes down any transport that is not the raw twin of an adopted fd, where node does it, next to the handle close, so it is still intact inside 'error'. A server wrap over a connecting socket waits for 'connect', and named pipes take the stream-level engine there as they do on the client. The constructor refuses a secureContext that is not one (ERR_TLS_INVALID_CONTEXT) instead of throwing out of nextTick. --- src/js/node/net.ts | 299 ++++++------------ src/js/node/tls.ts | 6 +- src/jsc/bindings/ErrorCode.ts | 1 + test/js/node/tls/node-tls-connect.test.ts | 2 - test/js/node/tls/node-tls-namedpipes.test.ts | 54 +++- test/js/node/tls/node-tls-server.test.ts | 68 ++++ .../tls/node-tls-wrapped-socket-close.test.ts | 240 ++++++++++++++ 7 files changed, 469 insertions(+), 201 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index dd5fc0441ae9..38f26b08e42c 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -423,11 +423,82 @@ function onUpgradedClose(self, connection) { function destroyWhenUpgradedCloses(self, connection) { connection.once("close", (self[kOnUpgradedClose] = onUpgradedClose.bind(null, self, connection))); } -// Node's wrap 'error' -> _emitTLSError. Not for a net.Socket: a listener there makes its close synthesize ECONNRESET. +// Node's wrap 'error' -> _emitTLSError. Not for a connected net.Socket: a listener there makes its close synthesize ECONNRESET. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L65 // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977 -function forwardUpgradedError(self, connection) { - if (!(connection instanceof Socket)) connection.on("error", err => self._emitTLSError(err)); +function forwardUpgradedError(self, connection: SocketInstance) { + const isSocket = connection instanceof Socket; + if (isSocket && connection._handle && !connection.connecting) return; + const onError = err => { + if (!self.destroyed) self._emitTLSError(err); + }; + connection.on("error", onError); + if (isSocket) connection.once("connect", connection.removeListener.bind(connection, "error", onError)); +} +// Whatever state the wrapped stream is in: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 +function linkUpgraded(self, connection: SocketInstance) { + const isSocket = connection instanceof Socket; + self[kupgraded] = connection; + self._parent = isSocket ? connection : null; + destroyWhenUpgradedCloses(self, connection); + forwardUpgradedError(self, connection); +} +function attachTLSEngine(self, connection, options) { + const [handle, events] = upgradeDuplexToTLS(connection, options); + connection.on("data", events[0]); + connection.on("end", events[1]); + connection.on("drain", events[2]); + connection.on("close", events[3]); + self._handle = handle; +} +function adoptTLSPair(self, connection, pair) { + if (!pair) throw new Error("Invalid socket"); + const raw = (connection._handle = pair[0]); + raw[kAdoptedTLSRaw] = self; + self.once("end", self[kCloseRawConnection]); + raw.connecting = false; + self._handle = pair[1]; +} +function attachClientTLS(self, connection, tls, upgradeDuplex) { + const handle = connection._handle; + if (self.destroyed || (!upgradeDuplex && !handle)) return; + const options = { data: { self, req: { oncomplete: afterConnect } }, tls, socket: self[khandlers], isServer: false }; + try { + if (upgradeDuplex || isNamedPipeSocket(handle) || hasUnflushedWrites(connection)) { + attachTLSEngine(self, connection, options); + } else { + adoptTLSPair(self, connection, upgradeTLSDeferred(handle, options)); + } + } catch (error) { + self.destroy(error); + } +} +function adoptServerTLS(self, connection, options) { + if (self.destroyed) return; + const handle = connection._handle; + if (connection.destroyed || connection[kclosed] || !handle) { + self.destroy(); + return; + } + try { + // A user 'connection' listener that ran after the wrap may have queued plain writes, which must flush first. + if (hasUnflushedWrites(connection)) { + attachTLSEngine(self, connection, options); + } else { + // What was read off the fd before the wrap sits in the connection's readable buffer. + options.initialData = connection.read() || undefined; + adoptTLSPair(self, connection, handle.upgradeTLS(options)); + } + } catch (error) { + self.destroy(error); + return; + } + self.emit(kUpgradeAttached); +} +// What TLSWrap.prototype.close destroys. The raw twin of an adopted fd closes with that fd instead (closeWithTLSSocket). +function unadoptedTransport(self): SocketInstance | undefined { + const upgraded = self[kupgraded]; + if (upgraded && !upgraded.destroyed && upgraded._handle?.[kAdoptedTLSRaw] !== self) return upgraded; } // The wrapped socket reports nothing and closes with the TLS socket: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L676-L688 function closeWithTLSSocket(self, raw) { @@ -2218,112 +2289,17 @@ Socket.prototype.connect = function connect(...args) { this.connecting = false; } if (connectListener != null) this.once("secureConnect", connectListener); - try { - // reset the underlying writable object when establishing a new connection - // this is a function on `Duplex`, originally defined on `Writable` - // https://github.com/nodejs/node/blob/c5cfdd48497fe9bd8dbd55fd1fca84b321f48ec1/lib/net.js#L311 - // https://github.com/nodejs/node/blob/c5cfdd48497fe9bd8dbd55fd1fca84b321f48ec1/lib/net.js#L1126 - this._undestroy(); - const socket = connection._handle; - if (!upgradeDuplex && socket) { - // if is named pipe socket we can upgrade it using the same wrapper than we use for duplex - upgradeDuplex = isNamedPipeSocket(socket) || hasUnflushedWrites(connection); - } - if (upgradeDuplex) { - this[kupgraded] = connection; - const [result, events] = upgradeDuplexToTLS(connection, { - data: { self: this, req: { oncomplete: afterConnect } }, - tls, - socket: this[khandlers], - }); - destroyWhenUpgradedCloses(this, connection); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); - forwardUpgradedError(this, connection); - this._handle = result; - } else { - // upgradeTLS requires an established socket; a socket that is still - // connecting (e.g. tls.connect({ socket: net.connect(port) })) must be - // upgraded once it emits 'connect'. - if (socket && !connection.connecting) { - this[kupgraded] = connection; - const result = upgradeTLSDeferred(socket, { - data: { self: this, req: { oncomplete: afterConnect } }, - tls, - socket: this[khandlers], - isServer: false, - }); - if (result) { - const [raw, tls] = result; - // replace socket - connection._handle = raw; - raw[kAdoptedTLSRaw] = this; - destroyWhenUpgradedCloses(this, connection); - this.once("end", this[kCloseRawConnection]); - raw.connecting = false; - this._handle = tls; - } else { - this._handle = null; - throw new Error("Invalid socket"); - } - } else { - // wait to be connected - connection.once("connect", () => { - // The TLS socket may have been destroyed before the underlying - // socket connected (e.g. tls.connect({ socket }).destroy()); don't - // start a handshake on a dead socket. - if (this.destroyed) { - connection.destroy(); - return; - } - const socket = connection._handle; - if (!upgradeDuplex && socket) { - // if is named pipe socket we can upgrade it using the same wrapper than we use for duplex - upgradeDuplex = isNamedPipeSocket(socket) || hasUnflushedWrites(connection); - } - if (upgradeDuplex) { - this[kupgraded] = connection; - const [result, events] = upgradeDuplexToTLS(connection, { - data: { self: this, req: { oncomplete: afterConnect } }, - tls, - socket: this[khandlers], - }); - destroyWhenUpgradedCloses(this, connection); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); - forwardUpgradedError(this, connection); - this._handle = result; - } else { - this[kupgraded] = connection; - const result = upgradeTLSDeferred(socket, { - data: { self: this, req: { oncomplete: afterConnect } }, - tls, - socket: this[khandlers], - isServer: false, - }); - if (result) { - const [raw, tls] = result; - // replace socket - connection._handle = raw; - raw[kAdoptedTLSRaw] = this; - destroyWhenUpgradedCloses(this, connection); - this.once("end", this[kCloseRawConnection]); - raw.connecting = false; - this._handle = tls; - } else { - this._handle = null; - throw new Error("Invalid socket"); - } - } - }); - } - } - } catch (error) { - process.nextTick(emitErrorAndCloseNextTick, this, error); + // reset the underlying writable object when establishing a new connection + // this is a function on `Duplex`, originally defined on `Writable` + // https://github.com/nodejs/node/blob/c5cfdd48497fe9bd8dbd55fd1fca84b321f48ec1/lib/net.js#L311 + // https://github.com/nodejs/node/blob/c5cfdd48497fe9bd8dbd55fd1fca84b321f48ec1/lib/net.js#L1126 + this._undestroy(); + linkUpgraded(this, connection); + // upgradeTLS() takes an established socket. + if (upgradeDuplex || (connection._handle && !connection.connecting)) { + attachClientTLS(this, connection, tls, upgradeDuplex); + } else { + connection.once("connect", attachClientTLS.bind(null, this, connection, tls, false)); } return this; } @@ -2387,14 +2363,7 @@ Socket.prototype._destroy = function _destroy(err, callback) { $debug("Socket.prototype._destroy"); this.connecting = false; - // Tear down a wrapped generic duplex with this socket: the native handle's - // close only flushes close_notify and lets the wrapper drain; without an - // explicit destroy here a late RST on the underlying transport can surface - // as an unhandled error after this socket is gone. const upgraded = this[kupgraded]; - if (upgraded && !(upgraded instanceof Socket) && !upgraded.destroyed) { - upgraded.destroy?.(); - } // Close an fd adopted for synchronous writes (node closes the wrapping // libuv handle here). Leave stdio fds 0-2 open: process.stdout/stderr and @@ -2434,6 +2403,7 @@ Socket.prototype._destroy = function _destroy(err, callback) { // ECONNRESET. `close()` does a fast shutdown (clean close) which only // happens to surface as RST on some platforms; `terminate()` arms // SO_LINGER{1,0} for a real reset on all platforms. + unadoptedTransport(this)?.destroy(); const err = this._handle.terminate(); setImmediate(() => { $debug("emit close"); @@ -2465,7 +2435,10 @@ Socket.prototype._destroy = function _destroy(err, callback) { } else { callback(err); closeOwedRaw(this, upgraded); - process.nextTick(emitCloseNT, this, err ? true : false); + const transport = unadoptedTransport(this); + transport?.destroy(); + if (transport instanceof Socket) transport.once("close", emitCloseNT.bind(null, this, err ? true : false)); + else process.nextTick(emitCloseNT, this, err ? true : false); } const server = this._server; @@ -2590,83 +2563,18 @@ Socket.prototype.pause = function pause() { // ServerHandlers — the shared accepted-socket handler table, with per-socket // state carried via `data` (mirrors tls.createServer's one-handler-for-all model). Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, tls) { + linkUpgraded(this, connection); + const options = { data: this, tls, socket: serverHandlersFor(this), isServer: true }; const socket = connection._handle; - if (!socket || connection.encrypted || hasUnflushedWrites(connection)) { - // No adoptable fd (generic Duplex / not yet connected), TLS over TLS (the - // fd belongs to the outer SSL layer), or pending plain writes that must - // flush first: run the TLS engine over the stream itself. - const [result, events] = upgradeDuplexToTLS(connection, { - data: this, - tls, - socket: serverHandlersFor(this), - isServer: true, - }); - destroyWhenUpgradedCloses(this, connection); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); - forwardUpgradedError(this, connection); - this[kupgraded] = connection; - this._handle = result; - return; + if (!socket || connection.encrypted || isNamedPipeSocket(socket) || hasUnflushedWrites(connection)) { + // No adoptable fd (generic Duplex, named pipe, never dialed), TLS over TLS (the fd belongs to the + // outer SSL layer), or pending plain writes that must flush first. + attachTLSEngine(this, connection, options); + } else if (connection.connecting) { + connection.once("connect", process.nextTick.bind(null, adoptServerTLS, this, connection, options)); + } else { + process.nextTick(adoptServerTLS, this, connection, options); } - this[kupgraded] = connection; - process.nextTick(() => { - if (this.destroyed || connection.destroyed) { - this.destroy(); - return; - } - const handle = connection._handle; - if (!handle) { - this.destroy(); - return; - } - // Writes may have been queued between the wrap and this tick (a user - // 'connection' listener runs after the server's): those bytes must flush - // before any TLS output, so fall back to the stream-level engine. - if (hasUnflushedWrites(connection)) { - const [result, events] = upgradeDuplexToTLS(connection, { - data: this, - tls, - socket: serverHandlersFor(this), - isServer: true, - }); - destroyWhenUpgradedCloses(this, connection); - connection.on("data", events[0]); - connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); - forwardUpgradedError(this, connection); - this._handle = result; - this.emit(kUpgradeAttached); - return; - } - // Bytes that already arrived before the wrap were pulled off the fd into - // the connection's readable buffer; hand them to the TLS engine so the - // handshake doesn't stall. - const pending = connection.read(); - const result = handle.upgradeTLS({ - data: this, - tls, - socket: serverHandlersFor(this), - isServer: true, - initialData: pending || undefined, - }); - if (!result) { - this._handle = null; - this.destroy(new Error("Invalid socket")); - return; - } - const [raw, tlsHandle] = result; - connection._handle = raw; - raw[kAdoptedTLSRaw] = this; - destroyWhenUpgradedCloses(this, connection); - this.once("end", this[kCloseRawConnection]); - raw.connecting = false; - this._handle = tlsHandle; - this.emit(kUpgradeAttached); - }); }; // Client-side `new tls.TLSSocket(socket)`: the tls.connect({ socket }) upgrade, without onConnectSecure and onConnectEnd. @@ -4206,11 +4114,6 @@ function emitErrorNextTick(self, error) { self.emit("error", error); } -function emitErrorAndCloseNextTick(self, error) { - self.emit("error", error); - self.emit("close", true); -} - function emitListeningNextTick(self) { // (Or the Bun.ModuleGraph whose script listened has been disposed: its listener was closed with it.) if (!self._handle || isStoppedModuleGraphRunning()) return; @@ -4489,6 +4392,8 @@ function onSocketHandleClosed() {} function closeSocketHandle(self, handle, isException, isCleanupPending = false) { $debug("closeSocketHandle", isException, isCleanupPending); + // Ahead of the setImmediate below: the transport's 'close' precedes this socket's. + unadoptedTransport(self)?.destroy(); handle.close(onSocketHandleClosed); setImmediate(() => { $debug("emit close", isCleanupPending); diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index cc9d86e6fac1..78e5f8b9a6db 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -708,7 +708,11 @@ function TLSSocket(socket?, options?) { } // Internal path: keep the per-digest cache (the user-facing constructors, // createSecureContext() and new tls.SecureContext(), own theirs exclusively). - this[ksecureContext] = options.secureContext || new InternalSecureContext(options, true); + const secureContext = options.secureContext; + if (secureContext && !(secureContext.context instanceof NativeSecureContext)) { + throw $ERR_TLS_INVALID_CONTEXT("context must be a SecureContext"); + } + this[ksecureContext] = secureContext || new InternalSecureContext(options, true); this.authorized = false; this.secureConnecting = true; this._secureEstablished = false; diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 1819ef40ab00..99495d66bad9 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -242,6 +242,7 @@ const errors: ErrorCodeMapping = [ ["ERR_TLS_CERT_ALTNAME_FORMAT", SyntaxError], ["ERR_TLS_CERT_ALTNAME_INVALID", Error], ["ERR_TLS_HANDSHAKE_TIMEOUT", Error], + ["ERR_TLS_INVALID_CONTEXT", TypeError], ["ERR_TLS_INVALID_PROTOCOL_METHOD", TypeError], ["ERR_TLS_INVALID_PROTOCOL_VERSION", TypeError], ["ERR_TLS_PROTOCOL_VERSION_CONFLICT", TypeError], diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 7e3644cc4aff..f19b2196a0b6 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -1884,8 +1884,6 @@ describe("a TLS socket over a Duplex transport follows that transport's teardown it.each(["in the same tick", "after the engine started"])( "a connected net.Socket is closed when the TLS socket is destroyed %s", async when => { - // The TLS socket does not destroy a net.Socket it runs over, so its own - // close has to end that socket. Node closes it in both cases. const peerSawFin = Promise.withResolvers(); await using server = net.createServer({ allowHalfOpen: true }, peer => { peer.on("error", () => {}); diff --git a/test/js/node/tls/node-tls-namedpipes.test.ts b/test/js/node/tls/node-tls-namedpipes.test.ts index cd3cb8d57c03..ca2bf212fdf5 100644 --- a/test/js/node/tls/node-tls-namedpipes.test.ts +++ b/test/js/node/tls/node-tls-namedpipes.test.ts @@ -5,7 +5,7 @@ import { once } from "node:events"; import { readFileSync } from "node:fs"; import net from "node:net"; import { join } from "node:path"; -import { connect, createServer } from "node:tls"; +import { connect, createServer, TLSSocket } from "node:tls"; it.if(isWindows)("should work with named pipes and tls", async () => { await expectMaxObjectTypeCount(expect, "TLSSocket", 0); @@ -206,3 +206,55 @@ it.if(isWindows)("should be able to upgrade a named pipe connection to TLS", asy await test(`\\\\.\\pipe\\test\\${randomUUID()}`); await expectMaxObjectTypeCount(expect, "TLSSocket", 3); }); + +// A pipe has no fd for the native upgrade to adopt. +type ServerWrap = (accepted: net.Socket, echo: (secure: TLSSocket) => void, fail: (err: Error) => void) => void; + +async function serverWrapRoundTrip(wrap: ServerWrap) { + const pipeName = `\\\\.\\pipe\\test\\${randomUUID()}`; + const echoed = Promise.withResolvers(); + const server = net.createServer(accepted => { + accepted.on("error", echoed.reject); + wrap( + accepted, + secure => { + secure.on("error", echoed.reject); + secure.on("data", chunk => secure.end(`echo:${chunk}`)); + }, + echoed.reject, + ); + }); + server.on("error", echoed.reject); + let client: TLSSocket | undefined; + try { + server.listen(pipeName); + await once(server, "listening"); + client = connect({ socket: net.connect(pipeName), rejectUnauthorized: false }, () => client!.write("ping")); + client.on("error", echoed.reject); + // A pipe write completes once the peer has read it: read through to the server's close_notify. + let received = ""; + client.on("data", chunk => (received += chunk)); + client.on("end", () => echoed.resolve(received)); + expect(await echoed.promise).toBe("echo:ping"); + } finally { + client?.destroy(); + server.close(); + } +} + +it.if(isWindows)("new TLSSocket(pipeSocket, { isServer: true }) completes a handshake over a named pipe", async () => { + await serverWrapRoundTrip((accepted, echo) => echo(new TLSSocket(accepted, { isServer: true, ...tls }))); +}); + +it.if(isWindows)("tls.Server wraps a named-pipe connection handed in via emit('connection')", async () => { + const tlsServer = createServer(tls); + try { + await serverWrapRoundTrip((accepted, echo, fail) => { + tlsServer.once("secureConnection", echo); + tlsServer.once("tlsClientError", fail); + tlsServer.emit("connection", accepted); + }); + } finally { + tlsServer.close(); + } +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 7660eba4c54b..12d37509d332 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4197,3 +4197,71 @@ describe("a server-side handshake failure reported under a JS call emits 'close' } }); }); + +// Bun only: node reports the reset on the wrapped socket, which has no 'error' listener here. +it("closes a server wrap of a connection whose native socket is already gone", async () => { + const accepted = Promise.withResolvers(); + const rawServer = net.createServer(socket => { + socket.write("x"); + accepted.resolve(socket); + }); + let outgoing: net.Socket | undefined; + let wrapped: TLSSocket | undefined; + try { + await once(rawServer.listen(0, "127.0.0.1"), "listening"); + outgoing = net.connect((rawServer.address() as AddressInfo).port, "127.0.0.1"); + // The byte stays unread, so the reset closes the native socket and the EOF queues behind the byte. + const byteBuffered = Promise.withResolvers(); + const eofBuffered = Promise.withResolvers(); + let readableEvents = 0; + outgoing.on("readable", () => (++readableEvents === 1 ? byteBuffered : eofBuffered).resolve()); + await byteBuffered.promise; + (await accepted.promise).resetAndDestroy(); + await eofBuffered.promise; + expect({ destroyed: outgoing.destroyed, pending: outgoing.pending }).toEqual({ destroyed: false, pending: false }); + + wrapped = new TLSSocket(outgoing, { isServer: true, ...COMMON_CERT }); + const events: string[] = []; + const closed = Promise.withResolvers(); + outgoing.on("close", () => events.push("raw close")); + wrapped.on("error", err => events.push(`error: ${err.message}`)); + wrapped.on("close", () => { + events.push("close"); + closed.resolve(); + }); + await closed.promise; + expect(events).toEqual(["raw close", "close"]); + } finally { + wrapped?.destroy(); + outgoing?.destroy(); + rawServer.close(); + } +}); + +// Bun only: node throws ERR_INVALID_HANDLE_TYPE. +it("resetAndDestroy() of a server wrap on the stream-level engine destroys the wrapped socket", async () => { + const events: string[] = []; + const closed = Promise.withResolvers(); + const rawServer = net.createServer(raw => { + raw.cork(); + raw.write("220 banner\r\n"); + const wrapped = new TLSSocket(raw, { isServer: true, ...COMMON_CERT }); + raw.uncork(); + raw.on("close", () => events.push("raw close")); + wrapped.on("close", () => { + events.push("close"); + closed.resolve(); + }); + wrapped.resetAndDestroy(); + }); + await once(rawServer.listen(0, "127.0.0.1"), "listening"); + const peer = net.connect({ port: (rawServer.address() as AddressInfo).port, host: "127.0.0.1", allowHalfOpen: true }); + peer.on("error", () => {}); + try { + await Promise.all([closed.promise, once(peer.resume(), "end")]); + expect(events).toEqual(["raw close", "close"]); + } finally { + peer.destroy(); + rawServer.close(); + } +}); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index 6bafd2286767..28b008193aaa 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -14,10 +14,12 @@ * node --test test/js/node/tls/node-tls-wrapped-socket-close.test.ts */ import assert from "node:assert"; +import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFileSync } from "node:fs"; import net from "node:net"; import { join } from "node:path"; +import { Duplex } from "node:stream"; import { describe, test } from "node:test"; import tls from "node:tls"; @@ -288,6 +290,244 @@ describe("the owner destroys the TLS socket with an error", () => { }); }); +// 'error' and 'close' only, and no 'close' argument for the wrapped socket: node passes none when it never had a handle. +function lifecycleLog() { + const events: string[] = []; + const closed: Promise[] = []; + function observe(name: string, socket: Duplex, onError = true) { + const { promise, resolve } = Promise.withResolvers(); + closed.push(promise); + if (onError) socket.on("error", (err: NodeJS.ErrnoException) => events.push(`${name} error ${err.code}`)); + socket.on("close", hadError => { + events.push(name === "tls" ? `tls close hadError=${hadError}` : `${name} close`); + resolve(); + }); + } + return { events, closed, observe }; +} + +// The local port of a live connection: nothing listens on it, and no concurrent listen(0) can be handed it. +async function refusedPort() { + const sink = net.createServer(); + const holder = net.connect(await listen(sink), "127.0.0.1"); + const [[accepted]] = await Promise.all([once(sink, "connection"), once(holder, "connect")]); + return { + port: (holder.address() as net.AddressInfo).port, + async [Symbol.asyncDispose]() { + holder.destroy(); + accepted.destroy(); + sink.close(); + await once(sink, "close"); + }, + }; +} + +const wraps: [string, (raw: Duplex) => tls.TLSSocket][] = [ + ["tls.connect({ socket })", raw => tls.connect({ socket: raw, rejectUnauthorized: false })], + ["new TLSSocket(socket)", raw => new tls.TLSSocket(raw as net.Socket, { rejectUnauthorized: false })], + ["new TLSSocket(socket, { isServer })", raw => new tls.TLSSocket(raw as net.Socket, { isServer: true, key, cert })], +]; + +describe("the TLS socket is destroyed in the tick it was made", () => { + const both = ["raw close", "tls close hadError=false"]; + + // No 'error' listener on the wrapped socket: a connect left running would fail as an uncaught exception. + async function destroyedAtOnce(raw: Duplex, wrap: (raw: Duplex) => tls.TLSSocket) { + const { events, closed, observe } = lifecycleLog(); + const tlsSocket = wrap(raw); + observe("raw", raw, false); + observe("tls", tlsSocket); + tlsSocket.destroy(); + events.push(`raw.destroyed=${raw.destroyed}`); + await Promise.all(closed); + return events; + } + + for (const [name, wrap] of wraps) { + test(`${name} over a socket whose connect will be refused`, async () => { + await using refused = await refusedPort(); + const events = await destroyedAtOnce(net.connect(refused.port, "127.0.0.1"), wrap); + assert.deepStrictEqual(events, ["raw.destroyed=true", ...both]); + }); + + test(`${name} over a socket that was never dialed`, async () => { + assert.deepStrictEqual(await destroyedAtOnce(new net.Socket(), wrap), ["raw.destroyed=true", ...both]); + }); + } + + test("new TLSSocket(socket, { isServer }) over an accepted socket releases the connection", async () => { + const accepted = Promise.withResolvers(); + const server = net.createServer(raw => accepted.resolve(destroyedAtOnce(raw, wraps[2][1]))); + const peer = net.connect(await listen(server), "127.0.0.1").resume(); + try { + await once(peer, "close"); + assert.deepStrictEqual(await accepted.promise, ["raw.destroyed=true", ...both]); + server.close(); + await once(server, "close"); + } finally { + peer.destroy(); + server.close(); + } + }); + + test("tls.connect({ socket }) over an established TLS socket", async () => { + const server = tls.createServer({ key, cert }, peer => peer.on("error", () => {}).resume()); + const inner = tls.connect({ port: await listen(server), host: "127.0.0.1", rejectUnauthorized: false }); + try { + await once(inner, "secureConnect"); + assert.deepStrictEqual(await destroyedAtOnce(inner, wraps[0][1]), ["raw.destroyed=true", ...both]); + } finally { + inner.destroy(); + server.close(); + } + }); +}); + +test("destroy() of a TLS socket made over a socket with a write still queued closes a connection the peer keeps open", async () => { + const { events, closed, observe } = lifecycleLog(); + const server = net.createServer({ allowHalfOpen: true }, peer => peer.on("error", () => {}).resume()); + const raw = net.connect(await listen(server), "127.0.0.1"); + try { + await once(raw, "connect"); + raw.cork(); + raw.write("STARTTLS\r\n"); + const tlsSocket = tls.connect({ socket: raw, rejectUnauthorized: false }); + raw.uncork(); + observe("raw", raw); + observe("tls", tlsSocket); + setImmediate(() => tlsSocket.destroy()); + await Promise.all(closed); + assert.deepStrictEqual(events, ["raw close", "tls close hadError=false"]); + } finally { + raw.destroy(); + server.close(); + } +}); + +describe("the wrapped socket goes away before it connected", () => { + async function wrapped(raw: net.Socket, wrap: (raw: Duplex) => tls.TLSSocket, rawListeners = true) { + const { events, closed, observe } = lifecycleLog(); + if (rawListeners) observe("raw", raw); + const tlsSocket = wrap(raw); + tlsSocket.on("_tlsError", (err: NodeJS.ErrnoException) => events.push(`tls _tlsError ${err.code}`)); + observe("tls", tlsSocket); + return { events, closed }; + } + + // Only a socket from tls.connect() has released control, so only it re-emits '_tlsError' as 'error'. + for (const [name, wrap] of wraps) { + const tlsError = ["tls _tlsError ECONNREFUSED", ...(wrap === wraps[0][1] ? ["tls error ECONNREFUSED"] : [])]; + + test(`${name}: a refused connect`, async () => { + await using refused = await refusedPort(); + const { events, closed } = await wrapped(net.connect(refused.port, "127.0.0.1"), wrap); + await Promise.all(closed); + assert.deepStrictEqual(events, ["raw error ECONNREFUSED", ...tlsError, "raw close", "tls close hadError=false"]); + }); + + test(`${name}: a refused connect() made after the wrap, with listeners on the TLS socket alone`, async () => { + await using refused = await refusedPort(); + const raw = new net.Socket(); + const { events, closed } = await wrapped(raw, wrap, false); + raw.connect(refused.port, "127.0.0.1"); + await Promise.all(closed); + assert.deepStrictEqual(events, [...tlsError, "tls close hadError=false"]); + }); + + test(`${name}: destroy() of the wrapped socket`, async () => { + await using refused = await refusedPort(); + const raw = net.connect(refused.port, "127.0.0.1"); + const { events, closed } = await wrapped(raw, wrap); + raw.destroy(); + await Promise.all(closed); + assert.deepStrictEqual(events, ["raw close", "tls close hadError=false"]); + }); + } + + test("a socket dialed again after the failure is no longer tied to the closed TLS socket", async () => { + await using refused = await refusedPort(); + const plain = net.createServer(socket => socket.resume().end("plain")); + const port = await listen(plain); + const raw = net.connect(refused.port, "127.0.0.1"); + try { + const { events, closed } = await wrapped(raw, wraps[0][1]); + await Promise.all(closed); + events.length = 0; + raw.connect(port, "127.0.0.1"); + const received: Buffer[] = []; + raw.on("data", chunk => received.push(chunk)); + await once(raw, "close"); + assert.deepStrictEqual([Buffer.concat(received).toString(), ...events], ["plain", "raw close"]); + } finally { + raw.destroy(); + plain.close(); + } + }); +}); + +describe("new TLSSocket(socket, { isServer }) over a socket that is still connecting", () => { + const lookups: [string, net.LookupFunction | undefined][] = [ + ["an IP address", undefined], + ["a lookup that answers later", (_host, _options, callback) => void setImmediate(callback, null, "127.0.0.1", 4)], + ]; + for (const [name, lookup] of lookups) { + test(`handshakes once it connected to ${name}, and lets the process exit`, async () => { + // The listener plays the TLS client over the connection it accepts. + const script = ` + const net = require("node:net"), tls = require("node:tls"), { readFileSync } = require("node:fs"); + const [key, cert] = ${JSON.stringify([join(fixtures, "agent1-key.pem"), join(fixtures, "agent1-cert.pem")])}.map(f => readFileSync(f)); + const lookup = ${lookup}; + const log = []; + const server = net.createServer(accepted => { + const client = tls.connect({ socket: accepted, rejectUnauthorized: false }, () => client.write("ping")); + client.once("data", data => { log.push("client data " + data); client.end(); }); + }).listen(0, "127.0.0.1", () => { + const raw = net.connect({ port: server.address().port, host: lookup ? "localhost" : "127.0.0.1", family: 4, lookup }); + raw.on("connect", () => log.push("raw connect")); + raw.on("close", () => log.push("raw close")); + const tlsSocket = new tls.TLSSocket(raw, { isServer: true, key, cert }); + tlsSocket.on("data", data => tlsSocket.write("echo:" + data + " from " + tlsSocket.remoteAddress)); + tlsSocket.on("close", () => { log.push("tls close"); server.close(); }); + }); + process.on("exit", () => console.log(JSON.stringify(log))); + `; + const child = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "inherit"] }); + let stdout = ""; + child.stdout.on("data", chunk => (stdout += chunk)); + const [exitCode] = await once(child, "exit"); + assert.deepStrictEqual(JSON.parse(stdout), [ + "raw connect", + "client data echo:ping from 127.0.0.1", + "raw close", + "tls close", + ]); + assert.strictEqual(exitCode, 0); + }); + } +}); + +test("a transport is still intact inside the 'error' of a handshake that failed over it", async () => { + const { events, closed, observe } = lifecycleLog(); + const transport = new Duplex({ read() {}, write: (_chunk, _encoding, callback) => callback() }); + const tlsSocket = new tls.TLSSocket(transport as net.Socket, { isServer: true, key, cert }); + observe("raw", transport); + tlsSocket.on("error", () => events.push(`tls error, raw.destroyed=${transport.destroyed}`)); + observe("tls", tlsSocket, false); + transport.push("this is not a ClientHello\r\n"); + await Promise.all(closed); + assert.deepStrictEqual(events, ["tls error, raw.destroyed=false", "raw close", "tls close hadError=true"]); +}); + +test("a secureContext that is not one is refused by the constructor", () => { + const invalid = { name: "TypeError", code: "ERR_TLS_INVALID_CONTEXT", message: "context must be a SecureContext" }; + for (const secureContext of [{ context: {} }, {}, tls.createSecureContext().context, "context"]) { + for (const isServer of [true, false]) { + assert.throws(() => new tls.TLSSocket(new net.Socket(), { isServer, secureContext }), invalid); + } + assert.throws(() => tls.connect({ port: 1, secureContext }), invalid); + } +}); + // Only in Bun: when Node.js runs this file it must not spawn itself again. if (typeof Bun !== "undefined") { const node = Bun.which("node"); From 10e282de681de3a4db0b58e101ee65a706a7f7b0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:45:50 +0000 Subject: [PATCH 064/174] node:tls: keep the handle attached while a client handshake protocol failure is reported (#38311) Node's onerror sets _hadError and _closeAfterHandlingError before it destroys a socket whose handshake never finished, on both sides. Bun did so for servers only, so inside a client's 'error' the peer address was gone, _hadError was false and a wrapped socket was already destroyed. --- src/js/node/net.ts | 3 + .../tls/node-tls-wrapped-socket-close.test.ts | 73 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 38f26b08e42c..5a6c1883408c 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -573,6 +573,9 @@ function onClientHandshake(self, socket, success, verifyError) { if (isProtocolFailure) { // Surface the OpenSSL reason instead of letting the close path report a // generic disconnect. + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L480-L488 + self._hadError = true; + self._closeAfterHandlingError = true; self.destroy(tlsHandshakeError(verifyError)); return; } diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index 28b008193aaa..7ed044cfb6b3 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -518,6 +518,79 @@ test("a transport is still intact inside the 'error' of a handshake that failed assert.deepStrictEqual(events, ["tls error, raw.destroyed=false", "raw close", "tls close hadError=true"]); }); +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L480-L488 +describe("a client handshake that fails with a protocol error leaves the socket inspectable inside 'error'", () => { + function observe(tlsSocket: tls.TLSSocket, raw?: net.Socket) { + const events: unknown[] = []; + const { promise, resolve } = Promise.withResolvers(); + tlsSocket.on("secureConnect", () => events.push("secureConnect")); + tlsSocket.on("error", (error: NodeJS.ErrnoException) => { + events.push({ + error: error.code, + destroyed: tlsSocket.destroyed, + _hadError: (tlsSocket as unknown as { _hadError: boolean })._hadError, + remoteAddress: tlsSocket.remoteAddress, + remotePort: tlsSocket.remotePort, + rawDestroyed: raw?.destroyed, + }); + }); + tlsSocket.on("close", hadError => { + events.push({ close: hadError }); + resolve(events); + }); + return promise; + } + + const failure = (error: string, remotePort: number, rawDestroyed?: boolean) => [ + { error, destroyed: true, _hadError: true, remoteAddress: "127.0.0.1", remotePort, rawDestroyed }, + { close: true }, + ]; + + const plaintextPeer = () => + net.createServer(peer => { + peer.on("error", () => {}); + peer.on("data", () => peer.end("HTTP/1.1 400 Bad Request\r\n\r\n")); + }); + + test("tls.connect({ port }) to a peer that does not speak TLS", async () => { + const server = plaintextPeer(); + const port = await listen(server); + try { + const events = await observe(tls.connect({ port, host: "127.0.0.1" })); + assert.deepStrictEqual(events, failure("ERR_SSL_WRONG_VERSION_NUMBER", port)); + } finally { + server.close(); + } + }); + + for (const state of ["connected", "connecting"]) { + test(`tls.connect({ socket }) over a ${state} socket to a peer that does not speak TLS`, async () => { + const server = plaintextPeer(); + const port = await listen(server); + const raw = net.connect(port, "127.0.0.1"); + try { + if (state === "connected") await once(raw, "connect"); + const events = await observe(tls.connect({ socket: raw }), raw); + assert.deepStrictEqual(events, failure("ERR_SSL_WRONG_VERSION_NUMBER", port, false)); + } finally { + raw.destroy(); + server.close(); + } + }); + } + + test("a fatal alert from a TLS peer", async () => { + const server = tls.createServer({ key, cert, minVersion: "TLSv1.3" }).on("tlsClientError", () => {}); + const port = await listen(server); + try { + const events = await observe(tls.connect({ port, host: "127.0.0.1", maxVersion: "TLSv1.2" })); + assert.deepStrictEqual(events, failure("ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION", port)); + } finally { + server.close(); + } + }); +}); + test("a secureContext that is not one is refused by the constructor", () => { const invalid = { name: "TypeError", code: "ERR_TLS_INVALID_CONTEXT", message: "context must be a SecureContext" }; for (const secureContext of [{ context: {} }, {}, tls.createSecureContext().context, "context"]) { From e853a879963a0195b0c82300fb20351fac61b995 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:45:51 +0000 Subject: [PATCH 065/174] node:tls: refuse connect(port) on a TLSSocket that runs over a caller's transport (#39008) Such a socket has no connection of its own to dial again. Over a Duplex the dial reached connect_finish with the stream-level engine still live (debug assertion at Listener.rs, two connections on one wrapper in release); over a net.Socket it dropped the live TLS handle unclosed. It now fails with EISCONN, which destroys the socket and its transport. --- src/js/node/net.ts | 6 ++ test/js/node/tls/node-tls-connect.test.ts | 70 +++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 5a6c1883408c..199b1ce3d9a9 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -2194,6 +2194,12 @@ Socket.prototype.connect = function connect(...args) { let connection = this[ksocket]; let upgradeDuplex = false; let { port, host, path, socket, rejectUnauthorized, checkServerIdentity, session, fd, pauseOnConnect } = options; + // A session over a transport the caller handed in has no connection of its own to dial again. + if (!socket && this[kupgraded] && !this.destroyed) { + const ex = new ExceptionWithHostPort(uv().UV_EISCONN, "connect", path || host || "localhost", port); + process.nextTick(destroyNT, this, ex); + return this; + } this.servername = options.servername; if (socket) { connection = socket; diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index f19b2196a0b6..efd475d7e768 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -4784,3 +4784,73 @@ describe("tls.connect() attaches onConnectEnd to 'end' exactly once", () => { }); }); }); + +// Bun only: node asserts (ERR_INTERNAL_ASSERTION) or stays connecting forever. +describe("connect(port) on a TLSSocket that runs over a transport the caller handed in reports EISCONN", () => { + const wraps: [name: string, established: string, wrap: (socket: Duplex) => tls.TLSSocket, proxied?: boolean][] = [ + [ + "tls.connect({ socket: net.Socket })", + "secureConnect", + socket => tls.connect({ socket, rejectUnauthorized: false }), + ], + [ + "tls.connect({ socket: Duplex })", + "secureConnect", + socket => tls.connect({ socket, rejectUnauthorized: false }), + true, + ], + [ + "new TLSSocket(net.Socket)", + "secure", + socket => new TLSSocket(socket as net.Socket, { rejectUnauthorized: false }), + ], + ]; + for (const [name, established, wrap, proxied] of wraps) { + it(name, async () => { + let accepted = 0; + await using server = tls.createServer(COMMON_CERT_, socket => { + accepted++; + socket.on("error", () => {}); + socket.resume(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = portOf(server); + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const transport = proxied ? new SocketProxy(raw) : raw; + const client = wrap(transport); + try { + await once(client, established); + // Not from inside the handshake callback. + await new Promise(resolve => setImmediate(resolve)); + const errored = once(client, "error"); + const closed = new Promise(resolve => client.once("close", resolve)); + const transportClosed = once(transport, "close"); + const returned = client.connect(port, "127.0.0.1"); + const [error] = await errored; + expect({ + returned: returned === client, + message: error.message, + code: error.code, + syscall: error.syscall, + address: error.address, + port: error.port, + hadError: await closed, + }).toEqual({ + returned: true, + message: `connect EISCONN 127.0.0.1:${port}`, + code: "EISCONN", + syscall: "connect", + address: "127.0.0.1", + port, + hadError: true, + }); + await transportClosed; + expect(accepted).toBe(1); + } finally { + client.destroy(); + raw.destroy(); + } + }); + } +}); From deadf37cf26f99c8da717ec58076d0b6a43b5b0e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:45:52 +0000 Subject: [PATCH 066/174] node:http2: destroy the accepted socket with the TLS proxy of an injected connection (#38154) Closing the proxy's native handle only end()s the raw socket, so a peer that never closes kept the fd and a slot in the net.Server after a failed handshake, a destroyed session, a rejected certificate or an unknown protocol. --- src/js/node/_http2_upgrade.ts | 7 +- .../js/node/http2/node-http2-upgrade.test.mts | 126 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index 711013d4b46b..121e63e6d343 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -103,11 +103,14 @@ function tlsSocketWrite(this: TLSProxySocket, chunk: Buffer, encoding: string, c // Cleans up the native TLS handle. // Mirrors net.ts Socket.prototype._destroy. function tlsSocketDestroy(this: TLSProxySocket, err: Error | null, callback: (err?: Error | null) => void) { - const h = this._ctx.nativeHandle; + const ctx = this._ctx; + const h = ctx.nativeHandle; if (h) { h.close(); - this._ctx.nativeHandle = null; + ctx.nativeHandle = null; } + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L676-L688 + ctx.rawSocket.destroy(); // Must invoke pending write callback with error per Writable stream contract const writeCb = this._writeCallback; if (writeCb) { diff --git a/test/js/node/http2/node-http2-upgrade.test.mts b/test/js/node/http2/node-http2-upgrade.test.mts index 57a8b73d93bc..966dbb14cce9 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -16,6 +16,7 @@ import fs from "node:fs"; import http2 from "node:http2"; import net from "node:net"; import path from "node:path"; +import { Duplex } from "node:stream"; import { afterEach, describe, test } from "node:test"; import tls from "node:tls"; import { fileURLToPath } from "node:url"; @@ -461,6 +462,131 @@ describe("HTTP/2 upgrade — server TLS options", () => { }); }); +// The peer keeps its end of the TCP connection open in every case. +describe("HTTP/2 upgrade — the accepted socket is released when the server side goes down", () => { + type Accepted = { raw: net.Socket; closed: Promise }; + + async function acceptInto(h2Server: http2.Http2SecureServer) { + const accepted = Promise.withResolvers(); + const netServer = net.createServer(raw => { + const closed = new Promise(resolve => raw.once("close", hadError => resolve(hadError))); + accepted.resolve({ raw, closed }); + h2Server.emit("connection", raw); + }); + const port = await new Promise(resolve => { + netServer.listen(0, "127.0.0.1", () => resolve((netServer.address() as net.AddressInfo).port)); + }); + return { netServer, port, accepted: accepted.promise }; + } + + function connectHeldOpen(port: number) { + const tcp = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true }); + tcp.on("error", () => {}); + return tcp; + } + + // Whatever the client's TLS layer does when the server closes the session stays on the carrier. + function connectTlsHeldOpen(port: number, options: tls.ConnectionOptions) { + const tcp = connectHeldOpen(port); + const carrier = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + if (tcp.destroyed) return callback(); + tcp.write(chunk, () => callback()); + }, + final(callback) { + callback(); + }, + }); + tcp.on("data", chunk => carrier.push(chunk)); + tcp.on("end", () => carrier.push(null)); + const client = tls.connect({ socket: carrier, rejectUnauthorized: false, ...options }); + client.on("error", () => {}); + client.resume(); + return { tcp, client }; + } + + async function assertReleased(netServer: net.Server, accepted: Promise) { + const { raw, closed } = await accepted; + assert.strictEqual(await closed, false); + assert.strictEqual(raw.destroyed, true); + const connections = await new Promise((resolve, reject) => { + netServer.getConnections((err, count) => (err ? reject(err) : resolve(count))); + }); + assert.strictEqual(connections, 0); + await new Promise(resolve => netServer.close(() => resolve())); + } + + function cleanup(netServer: net.Server, tcp: net.Socket) { + tcp.destroy(); + if (netServer.listening) netServer.close(); + } + + test("after a failed handshake", async () => { + const h2Server = http2.createSecureServer(TLS); + const tlsClientError = once(h2Server, "tlsClientError"); + const { netServer, port, accepted } = await acceptInto(h2Server); + + const tcp = connectHeldOpen(port); + tcp.on("connect", () => tcp.write("GET / HTTP/1.1\r\nHost: example\r\n\r\n")); + tcp.resume(); + try { + await tlsClientError; + await assertReleased(netServer, accepted); + } finally { + cleanup(netServer, tcp); + } + }); + + test("after the session is destroyed", async () => { + const h2Server = http2.createSecureServer(TLS); + const sessionClosed = new Promise(resolve => { + h2Server.once("session", (session: http2.ServerHttp2Session) => { + session.once("close", resolve); + session.destroy(); + }); + }); + const { netServer, port, accepted } = await acceptInto(h2Server); + + const { tcp } = connectTlsHeldOpen(port, { ALPNProtocols: ["h2"] }); + try { + await sessionClosed; + await assertReleased(netServer, accepted); + } finally { + cleanup(netServer, tcp); + } + }); + + test("after the client certificate is rejected", async () => { + const h2Server = http2.createSecureServer({ ...TLS, requestCert: true, rejectUnauthorized: true }); + h2Server.on("session", () => assert.fail("a rejected client must not get a session")); + const tlsClientError = once(h2Server, "tlsClientError"); + const { netServer, port, accepted } = await acceptInto(h2Server); + + const { tcp } = connectTlsHeldOpen(port, { ALPNProtocols: ["h2"], key: TLS.key, cert: TLS.cert }); + try { + const [err] = await tlsClientError; + assert.ok(err instanceof Error); + await assertReleased(netServer, accepted); + } finally { + cleanup(netServer, tcp); + } + }); + + test("after a client that negotiated no protocol is turned away", async () => { + const h2Server = http2.createSecureServer({ ...TLS, unknownProtocolTimeout: 0 }); + h2Server.on("session", () => assert.fail("a client without ALPN must not get a session")); + const { netServer, port, accepted } = await acceptInto(h2Server); + + const { tcp } = connectTlsHeldOpen(port, {}); + try { + await assertReleased(netServer, accepted); + } finally { + cleanup(netServer, tcp); + } + }); +}); + if (typeof Bun !== "undefined") { describe("Node.js compatibility", () => { test("tests should run on node.js", async () => { From 009b6e2fa7b43a798b3f1cbe56b6986d999b01ca Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:56:48 +0000 Subject: [PATCH 067/174] node:tls: follow node's `connecting` rule and emit 'connect' on a TLSSocket that wraps a socket (#42340, #42343, #42339, #42453) Node's whole rule is `connecting = socket.connecting || !socket._handle`, and the wrapped socket's 'connect' clears it and is re-emitted. _write, _final, _read, ref and unref all wait on that pair, in Bun too. Bun broke it both ways. A wrap of a connected socket on the stream-level engine said connecting with no 'connect' to come, so end() never finished. A wrap of a connecting socket never emitted 'connect', so a second resume path grew (open -> drain) and TLSSocket._final called back at once with no handle: 'finish' with no FIN, and the connection stayed open. The rule is now an assignment in linkUpgraded() and 'connect' is emitted after the engine is attached, so the drain in SocketHandlers2.open and the early return in TLSSocket._final are gone. endNT picks the handle on the tick that sends the FIN: a server-side wrap attaches its handle on the tick its constructor queued, and a wrap made around a socket's own end() left the fd to the raw half. An engine that fails before it opens has no connect pending, so connectError reports it itself. --- src/js/node/net.ts | 49 +-- src/js/node/tls.ts | 1 - test/js/node/tls/node-tls-connect.test.ts | 329 ++++++++++++++++++ test/js/node/tls/node-tls-namedpipes.test.ts | 76 ++++ test/js/node/tls/node-tls-upgrade.test.ts | 83 +++++ .../tls-shutdown-before-handshake-fixture.mjs | 139 ++++++++ 6 files changed, 652 insertions(+), 25 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 199b1ce3d9a9..d1d5ca09e287 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -387,12 +387,14 @@ function writeErrnoException(negErrno) { } return er; } -function endNT(socket, callback, err) { +function endNT(self, socket, callback) { // Node's _final half-closes the writable side (sends FIN) and leaves the // readable side open; the Duplex's allowHalfOpen drives the eventual destroy. // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L500 - socket.shutdown(); - callback(err); + // A TLS wrap made since _final attached this socket's handle, or left the fd to the raw half of `socket`. + const current = self._handle; + (!socket || current?.[kAdoptedTLSRaw] ? current : socket)?.shutdown(); + callback(); } function emitCloseNT(self, hasError) { self.emit("close", hasError); @@ -442,6 +444,13 @@ function linkUpgraded(self, connection: SocketInstance) { self._parent = isSocket ? connection : null; destroyWhenUpgradedCloses(self, connection); forwardUpgradedError(self, connection); + self.connecting = connection instanceof Socket && (connection.connecting || !connection._handle); +} +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L964-L973 +function onUpgradedConnect(self, connection) { + if (self.destroyed || self[kupgraded] !== connection) return; + self.connecting = false; + self.emit("connect"); } function attachTLSEngine(self, connection, options) { const [handle, events] = upgradeDuplexToTLS(connection, options); @@ -462,7 +471,7 @@ function adoptTLSPair(self, connection, pair) { function attachClientTLS(self, connection, tls, upgradeDuplex) { const handle = connection._handle; if (self.destroyed || (!upgradeDuplex && !handle)) return; - const options = { data: { self, req: { oncomplete: afterConnect } }, tls, socket: self[khandlers], isServer: false }; + const options = { data: { self, req: {} }, tls, socket: self[khandlers], isServer: false }; try { if (upgradeDuplex || isNamedPipeSocket(handle) || hasUnflushedWrites(connection)) { attachTLSEngine(self, connection, options); @@ -1578,10 +1587,6 @@ const SocketHandlers2 = { } if (!self[kupgraded]) req.oncomplete(0, self._handle, req, true, true); socket.data.req = undefined; - if (self[kupgraded]) { - self.connecting = false; - SocketHandlers2.drain(socket); - } }, data(socket, buffer) { $debug("Bun.Socket data"); @@ -1717,6 +1722,11 @@ const SocketHandlers2 = { req.errno = error.errno || uv().UV_ECANCELED; return; } + // A TLS engine that failed before it opened has no connect pending: afterConnect would drop the failure. + if (self[kupgraded]) { + if (!self.destroyed) self.destroy(new ErrnoException(error.errno, "connect")); + return; + } // Closing the handle cancels the request (ECANCELED). libuv completes it on a later loop turn, // after destroy(err)'s 'error'. Not deferred here: it would land on a connect() made right // after destroy() and fail that one. @@ -2289,14 +2299,6 @@ Socket.prototype.connect = function connect(...args) { } // start using existing connection if (connection) { - // A generic duplex transport is already established, so this socket is - // not "connecting" - only the TLS layer is pending, which - // secureConnecting tracks. Node reports false here. A provided - // net.Socket keeps its existing accounting (its own connect lifecycle - // drives this flag). - if (!(connection instanceof Socket)) { - this.connecting = false; - } if (connectListener != null) this.once("secureConnect", connectListener); // reset the underlying writable object when establishing a new connection // this is a function on `Duplex`, originally defined on `Writable` @@ -2305,11 +2307,9 @@ Socket.prototype.connect = function connect(...args) { this._undestroy(); linkUpgraded(this, connection); // upgradeTLS() takes an established socket. - if (upgradeDuplex || (connection._handle && !connection.connecting)) { - attachClientTLS(this, connection, tls, upgradeDuplex); - } else { - connection.once("connect", attachClientTLS.bind(null, this, connection, tls, false)); - } + if (upgradeDuplex || !this.connecting) attachClientTLS(this, connection, tls, upgradeDuplex); + else connection.once("connect", attachClientTLS.bind(null, this, connection, tls, false)); + if (this.connecting) connection.once("connect", onUpgradedConnect.bind(null, this, connection)); return this; } } @@ -2467,11 +2467,11 @@ Socket.prototype._final = function _final(callback) { } const socket = this._handle; - // already closed call destroy - if (!socket) return callback(); + // already closed call destroy. A server-side wrap attaches its handle on the tick its constructor queued, ahead of endNT. + if (!socket && !this[kupgraded]) return callback(); // emit FIN allowHalfOpen only allow the readable side to close first - process.nextTick(endNT, socket, callback); + process.nextTick(endNT, this, socket, callback); }; Object.defineProperty(Socket.prototype, "localAddress", { @@ -2584,6 +2584,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, } else { process.nextTick(adoptServerTLS, this, connection, options); } + if (this.connecting) connection.once("connect", onUpgradedConnect.bind(null, this, connection)); }; // Client-side `new tls.TLSSocket(socket)`: the tls.connect({ socket }) upgrade, without onConnectSecure and onConnectEnd. diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 78e5f8b9a6db..717b94cf294c 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -801,7 +801,6 @@ TLSSocket.prototype._start = function _start() { }; TLSSocket.prototype._final = function _final(callback) { - if (!this._handle) return callback(); // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1119-L1133 if (this.secureConnecting && this[kPreHandshakeWrite]) { return this.once(kSecureConnectDone, NetSocket.prototype._final.bind(this, callback)); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index efd475d7e768..a14ed703025e 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -3688,6 +3688,68 @@ describe.each([ }); }); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L964-L973 + describe.concurrent("before the TLS handle is attached", () => { + it.skipIf(!exe)("end() and destroySoon() wait for a tls.connect({ socket }) transport to connect", async () => { + const ended = { + log: ["transport connect", "finish"], + peerSawFin: true, + writableFinished: true, + readyState: "readOnly", + destroyed: false, + }; + const destroyed = { + log: ["transport connect", "finish", "close"], + peerSawFin: true, + writableFinished: true, + readyState: "closed", + destroyed: true, + }; + expect(await run("pending-transport")).toEqual({ + "end connecting": ended, + "end unconnected": ended, + "destroySoon connecting": destroyed, + "destroySoon unconnected": destroyed, + }); + }); + + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 + it.skipIf(!exe)( + "a tls.connect({ socket }) transport that closes before it connects closes the socket", + async () => { + const closed = { log: ["close"], writableFinished: false, readyState: "closed", destroyed: true }; + // A 'connect' listener that ran first destroyed the transport: the upgrade finds no handle. + const closedOnConnect = { readyState: "closed", destroyed: true, transportDestroyed: true }; + expect(await run("closed-transport")).toEqual({ + "end refused": closed, + "end destroyed": closed, + "end destroyed on connect": closedOnConnect, + "destroySoon refused": closed, + "destroySoon destroyed": closed, + "destroySoon destroyed on connect": closedOnConnect, + }); + }, + ); + + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1119-L1133 + it.skipIf(!exe)( + "end('') over a tls.connect({ socket }) transport that is still connecting follows the handshake", + async () => { + expect(await run("end-over-connecting-socket")).toEqual({ + log: ["end connecting=true", "secureConnect", "finish", "close"], + serverSawEnd: true, + }); + }, + ); + + it.skipIf(!exe)("a server-side TLSSocket end()s and destroySoon()s in the tick that wraps the socket", async () => { + expect(await run("server-same-tick")).toEqual({ + end: { log: ["finish"], clientSawFin: true, writableFinished: true, destroyed: false }, + destroySoon: { log: ["finish", "close"], clientSawFin: true, writableFinished: true, destroyed: true }, + }); + }); + }); + it.skipIf(!exe)("a server-side TLSSocket end()s while it waits for the client's first flight", async () => { expect(await run("server-end")).toEqual({ log: ["end secureConnecting=true", "finish"], @@ -4854,3 +4916,270 @@ describe("connect(port) on a TLSSocket that runs over a transport the caller han }); } }); + +// Bun only: node has one engine, and its handle cannot be closed from outside. +describe("bun's deferred TLS upgrade", () => { + async function connectingTransport() { + const accepted: net.Socket[] = []; + const server = net.createServer(socket => { + accepted.push(socket); + socket.on("error", () => {}); + socket.resume(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + raw.on("error", () => {}); + return { + raw, + [Symbol.dispose]() { + raw.destroy(); + for (const socket of accepted) socket.destroy(); + server.close(); + }, + }; + } + + it("tls.connect({ socket }).end() finishes after a transport with queued plaintext connects", async () => { + using transport = await connectingTransport(); + const { raw } = transport; + const client = tls.connect({ socket: raw, rejectUnauthorized: false }); + const finished = Promise.withResolvers(); + client.on("error", finished.reject); + client.once("close", () => finished.reject(new Error("closed before 'finish'"))); + try { + const events: string[] = []; + raw.on("connect", () => events.push("transport connect")); + client.on("finish", () => { + events.push("finish"); + finished.resolve(); + }); + // Still queued when the transport connects, so the upgrade takes the stream-level engine. + raw.write("plain"); + client.end(); + await finished.promise; + expect(events).toEqual(["transport connect", "finish"]); + } finally { + client.destroy(); + } + }); + + it("a server-side TLSSocket wrap finishes and closes after its native handle was closed", async () => { + const events: string[] = []; + const closed = Promise.withResolvers(); + let wrapped: TLSSocket | undefined; + const server = net.createServer(raw => { + raw.on("error", () => {}); + wrapped = new TLSSocket(raw, { isServer: true, ...COMMON_CERT_ }); + wrapped.on("error", closed.reject); + wrapped.on("secure", () => (wrapped as any)._handle.close()); + for (const event of ["finish", "close"]) wrapped.on(event, () => events.push(event)); + wrapped.on("close", () => closed.resolve()); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + client.on("error", () => {}); + try { + await closed.promise; + expect(events).toEqual(["finish", "close"]); + } finally { + client.destroy(); + wrapped?.destroy(); + server.close(); + } + }); +}); + +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L964-L973 +describe("tls.connect({ socket }) over a net.Socket that is still connecting", () => { + // A TLS server that reports everything the client sent once the client ends. + async function tlsSink() { + const received = Promise.withResolvers(); + const server = tls.createServer(COMMON_CERT_, socket => { + let data = ""; + socket.on("error", received.reject); + socket.on("data", chunk => (data += chunk)); + socket.on("end", () => { + received.resolve(data); + socket.end(); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + port: (server.address() as AddressInfo).port, + received: received.promise, + [Symbol.asyncDispose]: () => server[Symbol.asyncDispose](), + }; + } + + it("emits 'connect' when the wrapped socket connects, and a write from that listener is delivered", async () => { + await using sink = await tlsSink(); + const raw = net.connect(sink.port, "127.0.0.1"); + const client = tls.connect({ socket: raw, rejectUnauthorized: false }); + const log = [`wrapped connecting=${client.connecting}`]; + raw.on("connect", () => log.push("wrapped socket's later 'connect' listener")); + client.on("connect", () => { + log.push( + `connect connecting=${client.connecting} pending=${client.pending} secureConnecting=${client.secureConnecting}`, + ); + client.write("from connect;"); + }); + // Only 'connect' is re-emitted: node has no 'ready' on this socket. + client.on("ready", () => log.push("ready")); + client.on("secureConnect", () => { + log.push("secureConnect"); + client.end("from secureConnect;"); + }); + + const [received] = await Promise.all([sink.received, once(client, "close")]); + + expect({ log, received }).toEqual({ + log: [ + "wrapped connecting=true", + "connect connecting=false pending=false secureConnecting=true", + "wrapped socket's later 'connect' listener", + "secureConnect", + ], + received: "from connect;from secureConnect;", + }); + }); + + it("sends a write issued before the wrapped socket connects once, ahead of a write from 'connect'", async () => { + await using sink = await tlsSink(); + const raw = net.connect(sink.port, "127.0.0.1"); + const client = tls.connect({ socket: raw, rejectUnauthorized: false }); + const callbacks: string[] = []; + client.write("before connect;", () => callbacks.push("before connect")); + client.on("connect", () => client.write("from connect;", () => callbacks.push("from connect"))); + client.on("secureConnect", () => client.end()); + + const [received] = await Promise.all([sink.received, once(client, "close")]); + + expect({ received, callbacks }).toEqual({ + received: "before connect;from connect;", + callbacks: ["before connect", "from connect"], + }); + }); + + // A parked plaintext write makes the TLS engine run over the stream instead of taking over the fd. + it.each([ + [ + "a preamble written before the wrap", + (port: number) => { + const raw = net.connect(port, "127.0.0.1"); + raw.write("preamble;"); + return { raw, client: tls.connect({ socket: raw, rejectUnauthorized: false }) }; + }, + ], + [ + "a socket wrapped before it dials", + (port: number) => { + const raw = new net.Socket(); + const client = tls.connect({ socket: raw, rejectUnauthorized: false }); + raw.connect(port, "127.0.0.1"); + raw.write("preamble;"); + return { raw, client }; + }, + ], + ])("also when the TLS engine runs over the stream: %s", async (_shape, dial) => { + // Reads the preamble, then speaks TLS on the same connection. + const received = Promise.withResolvers<{ preamble: string; data: string }>(); + await using server = net.createServer(socket => { + socket.once("readable", () => { + const preamble = String(socket.read(9)); + const secure = new tls.TLSSocket(socket, { isServer: true, ...COMMON_CERT_ }); + let data = ""; + secure.on("error", received.reject); + secure.on("data", chunk => (data += chunk)); + secure.on("end", () => { + received.resolve({ preamble, data }); + secure.end(); + }); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { client } = dial((server.address() as AddressInfo).port); + const log = [`wrapped connecting=${client.connecting}`]; + client.write("before connect;"); + client.on("connect", () => { + log.push(`connect connecting=${client.connecting} pending=${client.pending}`); + client.write("from connect;"); + }); + client.on("secureConnect", () => { + log.push("secureConnect"); + client.end(); + }); + + const [fromClient] = await Promise.all([received.promise, once(client, "close")]); + + expect({ log, fromClient }).toEqual({ + log: ["wrapped connecting=true", "connect connecting=false pending=false", "secureConnect"], + fromClient: { preamble: "preamble;", data: "before connect;from connect;" }, + }); + }); +}); + +// Each connection the server accepts gets a second, server-side TLS layer on top of it. +async function listenTLSInTLS(onInner: (inner: TLSSocket) => void) { + const secureContext = tls.createSecureContext(COMMON_CERT_); + const server = tls.createServer(COMMON_CERT_, outer => { + outer.on("error", () => {}); + const inner = new TLSSocket(outer, { isServer: true, secureContext }); + inner.on("error", () => {}); + onInner(inner); + }); + server.listen(0); + await once(server, "listening"); + const outer = tls.connect({ port: (server.address() as AddressInfo).port, rejectUnauthorized: false }); + await once(outer, "secureConnect"); + return { server, outer }; +} + +describe("tls.connect({ socket }) over an established TLSSocket", () => { + it("is not connecting, and end() in the same tick emits 'finish'", async () => { + const { server, outer } = await listenTLSInTLS(inner => inner.resume()); + let client: TLSSocket | undefined; + try { + client = tls.connect({ socket: outer, rejectUnauthorized: false }); + const state = { connecting: client.connecting, pending: client.pending, readyState: client.readyState }; + client.end(); + expect(state).toEqual({ connecting: false, pending: false, readyState: "open" }); + await once(client, "finish"); + expect(client.writableFinished).toBe(true); + } finally { + client?.destroy(); + outer.destroy(); + server.close(); + } + }); +}); + +// Bun only: node reports ERR_STREAM_WRAP and leaves the socket open. +describe("a TLS engine over a wrapped stream that fails before it starts", () => { + function outcomeOf(client: TLSSocket) { + const { promise, resolve } = Promise.withResolvers<{ reported: boolean; hadError: boolean; destroyed: boolean }>(); + let reported = false; + client.on("error", () => (reported = true)); + client.on("close", hadError => resolve({ reported, hadError, destroyed: client.destroyed })); + return promise; + } + const failed = { reported: true, hadError: true, destroyed: true }; + + it("destroys a TLSSocket over a Duplex with the error", async () => { + const transport = new Duplex({ + read() {}, + write(_chunk, _encoding, callback) { + callback(); + }, + }); + transport.setEncoding("utf8"); + transport.push("not a TLS record"); + const client = tls.connect({ socket: transport, rejectUnauthorized: false }); + try { + expect(await outcomeOf(client)).toEqual(failed); + } finally { + client.destroy(); + transport.destroy(); + } + }); +}); diff --git a/test/js/node/tls/node-tls-namedpipes.test.ts b/test/js/node/tls/node-tls-namedpipes.test.ts index ca2bf212fdf5..2df3f88e1cf5 100644 --- a/test/js/node/tls/node-tls-namedpipes.test.ts +++ b/test/js/node/tls/node-tls-namedpipes.test.ts @@ -258,3 +258,79 @@ it.if(isWindows)("tls.Server wraps a named-pipe connection handed in via emit('c tlsServer.close(); } }); + +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L964-L973 +it.if(isWindows)("a TLSSocket over a named pipe that is still connecting emits 'connect'", async () => { + const received = Promise.withResolvers(); + let client: ReturnType | null = null; + const server = createServer(tls, socket => { + let data = ""; + socket.on("error", received.reject); + socket.on("data", chunk => (data += chunk)); + socket.on("end", () => { + received.resolve(data); + socket.end(); + }); + }); + try { + const pipeName = `\\\\.\\pipe\\test\\${randomUUID()}`; + server.listen(pipeName); + await once(server, "listening"); + + const socket = connect({ socket: net.connect(pipeName), ca: tls.cert }); + client = socket; + const log = [`wrapped connecting=${socket.connecting}`]; + socket.write("before connect;"); + socket.on("connect", () => { + log.push(`connect connecting=${socket.connecting} pending=${socket.pending}`); + socket.write("from connect;"); + }); + socket.on("secureConnect", () => { + log.push("secureConnect"); + socket.end(); + }); + + const [fromClient] = await Promise.all([received.promise, once(socket, "close")]); + + expect({ log, fromClient }).toEqual({ + log: ["wrapped connecting=true", "connect connecting=false pending=false", "secureConnect"], + fromClient: "before connect;from connect;", + }); + } finally { + client?.destroy(); + server.close(); + } +}); + +describe("tls.connect({ socket }) over a named pipe that is already connected", () => { + async function connectedPipe(onConnection: (socket: TLSSocket) => void) { + const server = createServer(tls, socket => { + socket.on("error", () => {}); + onConnection(socket); + }); + server.on("tlsClientError", () => {}); + const pipeName = `\\\\.\\pipe\\test\\${randomUUID()}`; + server.listen(pipeName); + await once(server, "listening"); + const pipe = net.connect(pipeName); + await once(pipe, "connect"); + return { server, pipe }; + } + + it.if(isWindows)("is not connecting, and end() in the same tick emits 'finish'", async () => { + const { server, pipe } = await connectedPipe(socket => socket.resume()); + let client: TLSSocket | undefined; + try { + client = connect({ socket: pipe, rejectUnauthorized: false }); + const state = { connecting: client.connecting, pending: client.pending, readyState: client.readyState }; + client.end(); + expect(state).toEqual({ connecting: false, pending: false, readyState: "open" }); + await once(client, "finish"); + expect(client.writableFinished).toBe(true); + } finally { + client?.destroy(); + pipe.destroy(); + server.close(); + } + }); +}); diff --git a/test/js/node/tls/node-tls-upgrade.test.ts b/test/js/node/tls/node-tls-upgrade.test.ts index 2e62f79d53a7..a9d8d6da4c5e 100644 --- a/test/js/node/tls/node-tls-upgrade.test.ts +++ b/test/js/node/tls/node-tls-upgrade.test.ts @@ -206,3 +206,86 @@ test.each([ server.close(); } }); + +// server.close() leaves live connections open, and a failed assertion must not leave one behind. +function closeAll(server: net.Server, sockets: net.Socket[]) { + for (const socket of sockets) socket.destroy(); + server.close(); +} + +// The wrap takes the fd over one tick after the constructor, the tick the wrapped socket's end() shuts it down in. +test.each<[string, (raw: net.Socket, tlsSocket: tls.TLSSocket) => void]>([ + ["end()", raw => raw.end()], + ["destroySoon()", raw => raw.destroySoon()], + [ + "end() before the TLSSocket's end()", + (raw, tlsSocket) => { + raw.end(); + tlsSocket.end(); + }, + ], +])( + "the wrapped socket's %s in the tick of new tls.TLSSocket(socket, { isServer: true }) sends the FIN", + async (_, endWrapped) => { + const sockets: net.Socket[] = []; + const server = net.createServer(raw => { + raw.on("error", () => {}); + const tlsSocket = new tls.TLSSocket(raw, { isServer: true, ...certs }); + tlsSocket.on("error", () => {}); + sockets.push(raw, tlsSocket); + endWrapped(raw, tlsSocket); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const client = tls.connect({ + port: (server.address() as net.AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + }); + sockets.push(client); + const events: string[] = []; + const { promise, resolve } = Promise.withResolvers(); + // Reached only when the FIN never arrives: the handshake completes and the connection stays open. + client.on("secureConnect", () => { + events.push("secureConnect"); + client.destroy(); + }); + client.on("end", () => events.push("end")); + client.on("error", err => events.push(`error ${(err as NodeJS.ErrnoException).code}`)); + client.on("close", () => resolve(events)); + expect(await promise).toEqual(["end", "error ECONNRESET"]); + } finally { + closeAll(server, sockets); + } + }, +); + +// The takeover lands between end() and the shutdown that end() deferred. +test.each(["connected", "connecting"])( + "end() on a %s socket before tls.connect({ socket }) in the same tick sends the FIN", + async state => { + const sockets: net.Socket[] = []; + const { promise, resolve } = Promise.withResolvers(); + // Reached only when the FIN never arrives. + const server = tls.createServer(certs, () => resolve("secureConnection")); + server.on("connection", socket => { + socket.on("error", () => {}); + sockets.push(socket); + }); + server.on("tlsClientError", err => resolve(`tlsClientError ${(err as NodeJS.ErrnoException).code}`)); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const raw = net.connect({ port: (server.address() as net.AddressInfo).port, host: "127.0.0.1" }); + raw.on("error", () => {}); + sockets.push(raw); + if (state === "connected") await once(raw, "connect"); + raw.end(); + const tlsSocket = tls.connect({ socket: raw, rejectUnauthorized: false }); + tlsSocket.on("error", () => {}); + sockets.push(tlsSocket); + expect(await promise).toBe("tlsClientError ECONNRESET"); + } finally { + closeAll(server, sockets); + } + }, +); diff --git a/test/js/node/tls/tls-shutdown-before-handshake-fixture.mjs b/test/js/node/tls/tls-shutdown-before-handshake-fixture.mjs index db1f0d647c75..e71cea71bd4b 100644 --- a/test/js/node/tls/tls-shutdown-before-handshake-fixture.mjs +++ b/test/js/node/tls/tls-shutdown-before-handshake-fixture.mjs @@ -138,6 +138,145 @@ if (mode === "end" || mode === "end-strict" || mode === "destroySoon") { readyState, destroyed, }); +} else if (mode === "pending-transport") { + // tls.connect({ socket }) over a net.Socket that is not connected yet, shut down in the same tick. + async function shutDown(method, transport) { + const log = []; + const peer = await stalledPeer(); + const raw = transport === "connecting" ? net.connect(peer.port, "127.0.0.1") : new net.Socket(); + raw.on("connect", () => log.push("transport connect")); + + const client = tls.connect({ socket: raw, rejectUnauthorized: false }); + for (const event of ["secureConnect", "finish", "error", "close"]) { + client.on(event, arg => log.push(arg?.code ? `${event}:${arg.code}` : event)); + } + client[method](); + if (transport === "unconnected") raw.connect(peer.port, "127.0.0.1"); + + await Promise.all([once(client, method === "end" ? "finish" : "close"), peer.sawFin]); + + const { writableFinished, readyState, destroyed } = client; + const result = { log: [...log], peerSawFin: true, writableFinished, readyState, destroyed }; + client.destroy(); + raw.destroy(); + peer.close(); + return result; + } + + const reports = {}; + await Promise.all( + ["end", "destroySoon"].flatMap(method => + ["connecting", "unconnected"].map(async transport => { + reports[`${method} ${transport}`] = await shutDown(method, transport); + }), + ), + ); + console.log(JSON.stringify(reports)); + process.exit(0); +} else if (mode === "closed-transport") { + // The same shapes over a net.Socket that closes before it connects. + async function shutDown(method, transport) { + const log = []; + const peer = await stalledPeer(); + // The local port of a live connection: nothing listens on it, and no listen(0) can be handed it. + const holder = net.connect(peer.port, "127.0.0.1"); + await once(holder, "connect"); + + const raw = net.connect(transport === "refused" ? holder.localPort : peer.port, "127.0.0.1"); + raw.on("error", () => {}); + // Registered before tls.connect() adds its own, so the transport is gone when that one runs. + if (transport === "destroyed on connect") raw.once("connect", () => raw.destroy()); + const client = tls.connect({ socket: raw, rejectUnauthorized: false }); + client.on("error", () => {}); + for (const event of ["finish", "close"]) client.on(event, () => log.push(event)); + client[method](); + if (transport === "destroyed") raw.destroy(); + + // events.once() rejects on the 'error' that node emits first. + await new Promise(resolve => client.once("close", resolve)); + + const { writableFinished, readyState, destroyed } = client; + const transportDestroyed = raw.destroyed; + holder.destroy(); + raw.destroy(); + peer.close(); + // Node runs its shutdown on the closed handle here: 'finish' and an EINVAL follow 'close'. + if (transport === "destroyed on connect") return { readyState, destroyed, transportDestroyed }; + return { log, writableFinished, readyState, destroyed }; + } + + const reports = {}; + await Promise.all( + ["end", "destroySoon"].flatMap(method => + ["refused", "destroyed", "destroyed on connect"].map(async transport => { + reports[`${method} ${transport}`] = await shutDown(method, transport); + }), + ), + ); + console.log(JSON.stringify(reports)); + process.exit(0); +} else if (mode === "end-over-connecting-socket") { + // The zero-length chunk reaches the engine ahead of the handshake, so the close_notify and the FIN follow it. + const serverSawEnd = Promise.withResolvers(); + const server = tls.createServer({ key: process.env.TLS_KEY, cert: process.env.TLS_CERT }, socket => { + socket.on("error", () => {}); + socket.on("data", () => {}); + socket.on("end", () => serverSawEnd.resolve()); + }); + server.on("tlsClientError", serverSawEnd.reject); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + + const raw = net.connect({ port: server.address().port, host: "127.0.0.1" }); + const client = tls.connect({ socket: raw, rejectUnauthorized: false }); + for (const event of ["secureConnect", "finish", "close"]) client.on(event, () => log.push(event)); + client.on("error", error => log.push(`error:${error.code}`)); + client.on("data", () => {}); + log.push(`end connecting=${client.connecting}`); + client.end(""); + + await Promise.all([once(client, "close"), serverSawEnd.promise]); + + server.close(); + report({ serverSawEnd: true }); +} else if (mode === "server-same-tick") { + // new TLSSocket(socket, { isServer: true }) shut down in the tick that wraps it. + async function shutDown(method) { + const log = []; + const clientSawFin = Promise.withResolvers(); + const wrapped = Promise.withResolvers(); + const server = net.createServer(raw => { + const socket = new TLSSocket(raw, { + isServer: true, + key: process.env.TLS_KEY, + cert: process.env.TLS_CERT, + }); + socket.on("error", () => {}); + raw.on("error", () => {}); + for (const event of ["finish", "close"]) socket.on(event, () => log.push(event)); + wrapped.resolve({ socket, done: once(socket, method === "end" ? "finish" : "close") }); + socket[method](); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + + const client = net.connect({ port: server.address().port, host: "127.0.0.1", allowHalfOpen: true }); + client.on("data", () => {}); + client.on("error", () => {}); + client.on("end", () => clientSawFin.resolve()); + + const { socket, done } = await wrapped.promise; + await Promise.all([done, clientSawFin.promise]); + + const { writableFinished, destroyed } = socket; + const result = { log: [...log], clientSawFin: true, writableFinished, destroyed }; + client.destroy(); + socket.destroy(); + server.close(); + return result; + } + + const [end, destroySoon] = await Promise.all(["end", "destroySoon"].map(shutDown)); + console.log(JSON.stringify({ end, destroySoon })); + process.exit(0); } else if (mode === "server-end") { const clientSawFin = Promise.withResolvers(); let serverSocket; From d3c5e201d2b441f24326a40563eb0c60b1565fed Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:05:08 +0000 Subject: [PATCH 068/174] node:tls: report a wrapped net.Socket's error on the TLS socket (#43791) Node listens for the wrapped socket's 'error' for the life of the wrap and routes it through _emitTLSError. Bun did so for a generic Duplex, and for a net.Socket only until it connected, so raw.destroy(err), an AbortSignal or a timeout handler on the wrapped socket was an uncaught exception when only the TLS socket had a listener, and a tls.Server never heard of it. The forward now stays wherever the TLS socket owns, or is about to own, the fd. The raw half of an adopted fd reports nothing of its own (closeWithTLSSocket), so no close path can mistake the forward for a user's listener. A connected net.Socket under the stream-level engine is left as it was: a listener there makes its close synthesize ECONNRESET. --- src/js/node/net.ts | 22 +-- .../tls/node-tls-wrapped-socket-close.test.ts | 150 ++++++++++++++++++ 2 files changed, 162 insertions(+), 10 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index d1d5ca09e287..1c0a9b01b379 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -425,17 +425,16 @@ function onUpgradedClose(self, connection) { function destroyWhenUpgradedCloses(self, connection) { connection.once("close", (self[kOnUpgradedClose] = onUpgradedClose.bind(null, self, connection))); } -// Node's wrap 'error' -> _emitTLSError. Not for a connected net.Socket: a listener there makes its close synthesize ECONNRESET. +// Node's wrap 'error' -> _emitTLSError. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L65 // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977 -function forwardUpgradedError(self, connection: SocketInstance) { - const isSocket = connection instanceof Socket; - if (isSocket && connection._handle && !connection.connecting) return; - const onError = err => { - if (!self.destroyed) self._emitTLSError(err); - }; - connection.on("error", onError); - if (isSocket) connection.once("connect", connection.removeListener.bind(connection, "error", onError)); +function forwardUpgradedError(self, connection) { + connection.on("error", err => { + if (self.destroyed) return; + // The close that follows is no second error. + self._hadError = true; + self._emitTLSError(err); + }); } // Whatever state the wrapped stream is in: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 function linkUpgraded(self, connection: SocketInstance) { @@ -443,8 +442,9 @@ function linkUpgraded(self, connection: SocketInstance) { self[kupgraded] = connection; self._parent = isSocket ? connection : null; destroyWhenUpgradedCloses(self, connection); - forwardUpgradedError(self, connection); self.connecting = connection instanceof Socket && (connection.connecting || !connection._handle); + // A listener on a connected net.Socket makes its close synthesize ECONNRESET: that one is listened to once its fd is taken. + if (self.connecting || !isSocket) forwardUpgradedError(self, connection); } // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L964-L973 function onUpgradedConnect(self, connection) { @@ -477,6 +477,7 @@ function attachClientTLS(self, connection, tls, upgradeDuplex) { attachTLSEngine(self, connection, options); } else { adoptTLSPair(self, connection, upgradeTLSDeferred(handle, options)); + if (!self.connecting) forwardUpgradedError(self, connection); } } catch (error) { self.destroy(error); @@ -2582,6 +2583,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, } else if (connection.connecting) { connection.once("connect", process.nextTick.bind(null, adoptServerTLS, this, connection, options)); } else { + forwardUpgradedError(this, connection); process.nextTick(adoptServerTLS, this, connection, options); } if (this.connecting) connection.once("connect", onUpgradedConnect.bind(null, this, connection)); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index 7ed044cfb6b3..3c47d995a6ec 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -591,6 +591,156 @@ describe("a client handshake that fails with a protocol error leaves the socket }); }); +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977 +describe("an error of the wrapped socket is reported on the TLS socket", () => { + const failure = () => Object.assign(new Error("transport failed"), { code: "TRANSPORT_FAILED" }); + + // The events of the TLS socket, and the 'error' of the wrapped socket when `rawListener` asks for one. + function observe(tlsSocket: tls.TLSSocket, raw: net.Socket, rawListener: boolean) { + const events: string[] = []; + const { promise, resolve } = Promise.withResolvers(); + tlsSocket.on("_tlsError", (err: NodeJS.ErrnoException) => events.push(`tls _tlsError ${err.code}`)); + tlsSocket.on("error", (err: NodeJS.ErrnoException) => events.push(`tls error ${err.code}`)); + if (rawListener) raw.on("error", (err: NodeJS.ErrnoException) => events.push(`raw error ${err.code}`)); + tlsSocket.on("close", hadError => { + events.push(`tls close hadError=${hadError}`); + resolve(events); + }); + return promise; + } + + for (const rawListener of [false, true]) { + const listeners = rawListener ? "both sockets" : "the TLS socket alone"; + const rawError = rawListener ? ["raw error TRANSPORT_FAILED"] : []; + + for (const state of ["connected", "connecting"]) { + test(`tls.connect({ socket }) over a ${state} socket, after the handshake, listeners on ${listeners}`, async () => { + const server = tls.createServer({ key, cert }, peer => peer.on("error", () => {}).resume()); + const raw = net.connect(await listen(server), "127.0.0.1"); + try { + if (state === "connected") await once(raw, "connect"); + const tlsSocket = tls.connect({ socket: raw, rejectUnauthorized: false }); + const events = observe(tlsSocket, raw, rawListener); + tlsSocket.on("secureConnect", () => raw.destroy(failure())); + assert.deepStrictEqual(await events, [ + "tls _tlsError TRANSPORT_FAILED", + "tls error TRANSPORT_FAILED", + ...rawError, + "tls close hadError=false", + ]); + } finally { + raw.destroy(); + server.close(); + } + }); + } + + test(`tls.connect({ socket }), before the handshake completes, listeners on ${listeners}`, async () => { + const server = net.createServer(peer => peer.on("error", () => {}).resume()); + const raw = net.connect(await listen(server), "127.0.0.1"); + try { + await once(raw, "connect"); + const events = observe(tls.connect({ socket: raw, rejectUnauthorized: false }), raw, rawListener); + setImmediate(() => raw.destroy(failure())); + assert.deepStrictEqual(await events, [ + "tls _tlsError TRANSPORT_FAILED", + "tls error TRANSPORT_FAILED", + ...rawError, + "tls close hadError=false", + ]); + } finally { + raw.destroy(); + server.close(); + } + }); + + // A server-side wrap has not released control, so '_tlsError' is not re-emitted as 'error'. + for (const when of ["before the handshake completes", "after the handshake"]) { + test(`new TLSSocket(socket, { isServer }), ${when}, listeners on ${listeners}`, async () => { + const accepted = Promise.withResolvers(); + const server = net.createServer(raw => { + const tlsSocket = new tls.TLSSocket(raw, { isServer: true, key, cert }); + accepted.resolve(observe(tlsSocket, raw, rawListener)); + if (when === "after the handshake") tlsSocket.on("secure", () => raw.destroy(failure())); + else setImmediate(() => raw.destroy(failure())); + }); + const port = await listen(server); + const peer = + when === "after the handshake" + ? tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }) + : net.connect(port, "127.0.0.1"); + peer.on("error", () => {}); + try { + assert.deepStrictEqual(await accepted.promise, [ + "tls _tlsError TRANSPORT_FAILED", + ...rawError, + "tls close hadError=false", + ]); + } finally { + peer.destroy(); + server.close(); + } + }); + } + } + + test("a socket injected into a tls.Server, before the handshake completes: 'tlsClientError'", async () => { + const reported = Promise.withResolvers(); + const tlsServer = tls.createServer({ key, cert }); + tlsServer.on("tlsClientError", (err: NodeJS.ErrnoException) => reported.resolve(err.code)); + const server = net.createServer(raw => { + tlsServer.emit("connection", raw); + setImmediate(() => raw.destroy(failure())); + }); + const peer = net.connect(await listen(server), "127.0.0.1"); + peer.on("error", () => {}); + try { + assert.strictEqual(await reported.promise, "TRANSPORT_FAILED"); + } finally { + peer.destroy(); + server.close(); + } + }); + + // Bun only: node re-emits it on the destroyed TLS socket. + test("not once the TLS socket has closed", { skip: typeof Bun === "undefined" }, async () => { + const server = net.createServer(peer => peer.on("error", () => {}).resume()); + const port = await listen(server); + const raw = net.connect(port, "127.0.0.1"); + try { + const events = observe(tls.connect({ socket: raw, rejectUnauthorized: false }), raw, true); + raw.destroy(); + const log = await events; + await once(raw.connect(port, "127.0.0.1"), "connect"); + raw.destroy(failure()); + await new Promise(resolve => raw.once("close", resolve)); + assert.deepStrictEqual(log, ["tls close hadError=false", "raw error TRANSPORT_FAILED"]); + } finally { + raw.destroy(); + server.close(); + } + }); + + test("the AbortSignal of the wrapped socket", async () => { + const server = tls.createServer({ key, cert }, peer => peer.on("error", () => {}).resume()); + const controller = new AbortController(); + const raw = net.connect({ port: await listen(server), host: "127.0.0.1", signal: controller.signal }); + try { + const tlsSocket = tls.connect({ socket: raw, rejectUnauthorized: false }); + const events = observe(tlsSocket, raw, false); + tlsSocket.on("secureConnect", () => controller.abort()); + assert.deepStrictEqual(await events, [ + "tls _tlsError ABORT_ERR", + "tls error ABORT_ERR", + "tls close hadError=false", + ]); + } finally { + raw.destroy(); + server.close(); + } + }); +}); + test("a secureContext that is not one is refused by the constructor", () => { const invalid = { name: "TypeError", code: "ERR_TLS_INVALID_CONTEXT", message: "context must be a SecureContext" }; for (const secureContext of [{ context: {} }, {}, tls.createSecureContext().context, "context"]) { From d4e6fcf862fa12d86d641046183919786067b692 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:07:45 +0000 Subject: [PATCH 069/174] tls: do not attach an SSL to a socket that was not adopted us_socket_adopt() hands a shut-down socket back untouched, and us_socket_adopt_tls() attached an SSL to it anyway, leaving a TCP-kind socket with s->ssl set: `raw.end(); new tls.TLSSocket(raw, { isServer: true })` aborted a debug build (s_ref.kind() == SocketKind::BunSocketTls). It returns NULL now, as for a closed socket, and the wrap fails like any refused upgrade. Also covers `new tls.TLSSocket(net.connect(port), { isServer: true })` followed by end() or destroy(), which underflowed the native connection count until the wrap learned to wait for 'connect'. --- packages/bun-usockets/src/crypto/openssl.c | 3 +- .../tls/node-tls-wrapped-socket-close.test.ts | 94 +++++++++++++++---- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 07c12832bf49..cb42d7b70e12 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2994,7 +2994,8 @@ struct us_socket_t *us_socket_adopt_tls(struct us_socket_t *s, const char *sni, int is_client, int request_cert, int reject_unauthorized, int old_ext_size, int ext_size) { - if (us_socket_is_closed(s)) return NULL; + /* us_socket_adopt() hands either of these back as it is. */ + if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) return NULL; struct us_socket_t *new_s = us_socket_adopt(s, group, kind, old_ext_size, ext_size); if (!new_s) return NULL; diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index 3c47d995a6ec..99ecff77793f 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -465,6 +465,25 @@ describe("the wrapped socket goes away before it connected", () => { }); }); +// In a process of its own: these shapes used to abort it, or to keep it from exiting. Resolves with what `body` pushed to `log`. +async function logOfChild(body: string) { + const script = ` + const net = require("node:net"), tls = require("node:tls"), { readFileSync } = require("node:fs"); + const [key, cert] = ${JSON.stringify([join(fixtures, "agent1-key.pem"), join(fixtures, "agent1-cert.pem")])}.map(f => readFileSync(f)); + const log = []; + process.on("exit", () => console.log(JSON.stringify(log))); + // A process that cannot exit must not outlive the test. + setTimeout(() => process.exit(3), 10_000).unref(); + ${body} + `; + const child = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "inherit"] }); + let stdout = ""; + child.stdout.on("data", chunk => (stdout += chunk)); + const [exitCode, signal] = await once(child, "exit"); + assert.deepStrictEqual({ exitCode, signal }, { exitCode: 0, signal: null }); + return JSON.parse(stdout); +} + describe("new TLSSocket(socket, { isServer }) over a socket that is still connecting", () => { const lookups: [string, net.LookupFunction | undefined][] = [ ["an IP address", undefined], @@ -473,11 +492,8 @@ describe("new TLSSocket(socket, { isServer }) over a socket that is still connec for (const [name, lookup] of lookups) { test(`handshakes once it connected to ${name}, and lets the process exit`, async () => { // The listener plays the TLS client over the connection it accepts. - const script = ` - const net = require("node:net"), tls = require("node:tls"), { readFileSync } = require("node:fs"); - const [key, cert] = ${JSON.stringify([join(fixtures, "agent1-key.pem"), join(fixtures, "agent1-cert.pem")])}.map(f => readFileSync(f)); + const log = await logOfChild(` const lookup = ${lookup}; - const log = []; const server = net.createServer(accepted => { const client = tls.connect({ socket: accepted, rejectUnauthorized: false }, () => client.write("ping")); client.once("data", data => { log.push("client data " + data); client.end(); }); @@ -489,21 +505,65 @@ describe("new TLSSocket(socket, { isServer }) over a socket that is still connec tlsSocket.on("data", data => tlsSocket.write("echo:" + data + " from " + tlsSocket.remoteAddress)); tlsSocket.on("close", () => { log.push("tls close"); server.close(); }); }); - process.on("exit", () => console.log(JSON.stringify(log))); - `; - const child = spawn(process.execPath, ["-e", script], { stdio: ["ignore", "pipe", "inherit"] }); - let stdout = ""; - child.stdout.on("data", chunk => (stdout += chunk)); - const [exitCode] = await once(child, "exit"); - assert.deepStrictEqual(JSON.parse(stdout), [ - "raw connect", - "client data echo:ping from 127.0.0.1", - "raw close", - "tls close", - ]); - assert.strictEqual(exitCode, 0); + `); + assert.deepStrictEqual(log, ["raw connect", "client data echo:ping from 127.0.0.1", "raw close", "tls close"]); }); } + + for (const method of ["end", "destroy"]) { + test(`${method}() once it connected closes the connection`, async () => { + const log = await logOfChild(` + let tlsSocket; + const server = net.createServer(peer => { + peer.on("end", () => log.push("peer end")).resume(); + setImmediate(() => tlsSocket.${method}()); + }).listen(0, "127.0.0.1", () => { + tlsSocket = new tls.TLSSocket(net.connect(server.address().port, "127.0.0.1"), { isServer: true, key, cert }); + tlsSocket.on("close", () => { log.push("tls close"); server.close(); }).resume(); + }); + `); + assert.deepStrictEqual(log.sort(), ["peer end", "tls close"]); + }); + } +}); + +describe("a TLS socket made over a socket that has sent its FIN", () => { + for (const when of ["in the same tick", "after 'finish'"]) { + test(`new TLSSocket(socket, { isServer }), end() ${when}`, async () => { + const log = await logOfChild(` + const server = net.createServer(raw => { + raw.end(); + const wrap = () => { + const tlsSocket = new tls.TLSSocket(raw, { isServer: true, key, cert }); + raw.on("close", () => log.push("raw close")); + tlsSocket.on("close", () => { log.push("tls close"); server.close(); }); + }; + ${when === "in the same tick" ? "wrap()" : `raw.on("finish", wrap)`}; + }).listen(0, "127.0.0.1", () => { + const peer = tls.connect({ port: server.address().port, host: "127.0.0.1", rejectUnauthorized: false }); + for (const event of ["secureConnect", "end", "error"]) peer.on(event, err => log.push("peer " + event + (err ? " " + err.code : ""))); + }); + `); + assert.deepStrictEqual(log.sort(), ["peer end", "peer error ECONNRESET", "raw close", "tls close"]); + }); + } + + test("tls.connect({ socket })", async () => { + const log = await logOfChild(` + const server = net.createServer({ allowHalfOpen: true }, peer => { + peer.on("data", () => log.push("peer data")).on("end", () => peer.end()); + }).listen(0, "127.0.0.1", () => { + const raw = net.connect({ port: server.address().port, host: "127.0.0.1", allowHalfOpen: true }, () => raw.end()); + raw.on("finish", () => { + const tlsSocket = tls.connect({ socket: raw, rejectUnauthorized: false }); + raw.on("close", () => log.push("raw close")); + tlsSocket.on("secureConnect", () => log.push("tls secureConnect")).on("error", () => log.push("tls error")); + tlsSocket.on("close", hadError => { log.push("tls close hadError=" + hadError); server.close(); }); + }); + }); + `); + assert.deepStrictEqual(log.sort(), ["raw close", "tls close hadError=true", "tls error"]); + }); }); test("a transport is still intact inside the 'error' of a handshake that failed over it", async () => { From 4e3a703e2a2b0b0d5323a51a867dc9a179f91326 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:14:41 +0000 Subject: [PATCH 070/174] node:tls: hand a wrapped socket over whole (#36534) What is left of #36534 once the fd adoption stopped surfacing ciphertext: - A ClientHello buffered as several chunks (read(1) + unshift() sniffing) was only half handed over, because read() returns one chunk: the handshake hung. - A transport that is paused when the stream-level engine takes it never handshook: on('data') does not resume an explicitly paused stream, node's JSStreamSocket does. - upgradeTLS() enabled the raw tap before it fed initialData, so the bytes the raw half had just handed over were shown to it again. - ref() and unref() on the wrapped socket did nothing after the adoption, where node has one handle under both sockets. - _parent was never set. --- src/js/node/net.ts | 11 +- src/runtime/socket/socket_body.rs | 4 +- test/js/node/tls/node-tls-upgrade.test.ts | 167 +++++++++++++++++++++- 3 files changed, 175 insertions(+), 7 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 1c0a9b01b379..60bb9bf64405 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -442,7 +442,7 @@ function linkUpgraded(self, connection: SocketInstance) { self[kupgraded] = connection; self._parent = isSocket ? connection : null; destroyWhenUpgradedCloses(self, connection); - self.connecting = connection instanceof Socket && (connection.connecting || !connection._handle); + self.connecting = isSocket && (connection.connecting || !connection._handle); // A listener on a connected net.Socket makes its close synthesize ECONNRESET: that one is listened to once its fd is taken. if (self.connecting || !isSocket) forwardUpgradedError(self, connection); } @@ -458,6 +458,8 @@ function attachTLSEngine(self, connection, options) { connection.on("end", events[1]); connection.on("drain", events[2]); connection.on("close", events[3]); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L120 + connection.resume(); self._handle = handle; } function adoptTLSPair(self, connection, pair) { @@ -496,7 +498,7 @@ function adoptServerTLS(self, connection, options) { attachTLSEngine(self, connection, options); } else { // What was read off the fd before the wrap sits in the connection's readable buffer. - options.initialData = connection.read() || undefined; + options.initialData = connection.read(connection.readableLength) || undefined; adoptTLSPair(self, connection, handle.upgradeTLS(options)); } } catch (error) { @@ -2674,7 +2676,8 @@ Socket.prototype.ref = function ref() { this.once("connect", this.ref); return this; } - socket.ref(); + // Node has one handle under both sockets. Here the TLS socket that adopted the fd holds the loop. + (socket[kAdoptedTLSRaw] ?? socket).ref(); return this; }; @@ -2871,7 +2874,7 @@ Socket.prototype.unref = function unref() { this.once("connect", this.unref); return this; } - socket.unref(); + (socket[kAdoptedTLSRaw] ?? socket).unref(); return this; }; diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 40610435c4cc..576fa2f83f02 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -3762,8 +3762,6 @@ impl NewSocket { // SAFETY: `raw` came from `TLSSocket::new` (heap::alloc); intrusive +1 held. tls.twin .set(Some(unsafe { RefPtr::from_raw(raw.as_ptr()) })); - // S008: `us_socket_t` is an `opaque_ffi!` ZST — safe deref. - bun_opaque::opaque_deref_mut(new_raw.as_ptr()).set_ssl_raw_tap(true); let tls_js_value = tls.get_this_value(global); let raw_js_value = raw_ref.get_this_value(global); @@ -3805,6 +3803,8 @@ impl NewSocket { if !initial_data.is_empty() { bun_opaque::opaque_deref_mut(new_raw.as_ptr()).tls_feed(initial_data.as_slice()); } + // After the feed: `raw` handed those bytes over, it is not shown them again. + bun_opaque::opaque_deref_mut(new_raw.as_ptr()).set_ssl_raw_tap(true); let array = JSValue::create_empty_array(global, 2)?; array.put_index(global, 0, raw_js_value)?; diff --git a/test/js/node/tls/node-tls-upgrade.test.ts b/test/js/node/tls/node-tls-upgrade.test.ts index a9d8d6da4c5e..f23a366d3c5f 100644 --- a/test/js/node/tls/node-tls-upgrade.test.ts +++ b/test/js/node/tls/node-tls-upgrade.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; +import { spawn } from "child_process"; import { once } from "events"; -import { tls as certs } from "harness"; +import { bunEnv, bunExe, tls as certs } from "harness"; import net from "net"; import { Duplex } from "stream"; import tls from "tls"; @@ -289,3 +290,167 @@ test.each(["connected", "connecting"])( } }, ); + +// A "ping" round trip over tls.connect({ socket }) with a peer that echoes. +function pingOverTLS(socket: Duplex) { + const { promise, resolve, reject } = Promise.withResolvers(); + const tlsSocket = tls.connect({ socket, rejectUnauthorized: false }, () => tlsSocket.write("ping")); + tlsSocket.on("error", reject); + tlsSocket.on("close", () => reject(new Error("closed before the echo arrived"))); + tlsSocket.on("data", chunk => resolve(chunk.toString())); + return promise; +} + +function wrapAndEcho(transport: Duplex) { + const secure = new tls.TLSSocket(transport as net.Socket, { isServer: true, ...certs }); + secure.on("error", () => {}); + secure.on("data", chunk => secure.write(chunk)); + return secure; +} + +test("new TLSSocket(accepted, { isServer: true }) hands over a ClientHello buffered in several chunks", async () => { + // Protocol sniffing: read(1) and unshift() leave the ClientHello in the readable buffer as two chunks. + const server = net.createServer(accepted => { + accepted.once("readable", () => { + accepted.unshift(accepted.read(1)); + wrapAndEcho(accepted); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const socket = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + try { + expect(await pingOverTLS(socket)).toBe("ping"); + } finally { + socket.destroy(); + server.close(); + } +}); + +test("new TLSSocket(accepted, { isServer: true }) hands an already buffered ClientHello over once", async () => { + const surfaced = Promise.withResolvers<{ handedOver: number; emitted: number; buffered: number }>(); + const server = net.createServer(accepted => { + accepted.once("readable", () => { + const handedOver = accepted.readableLength; + const secure = wrapAndEcho(accepted); + let emitted = 0; + accepted.on("data", chunk => (emitted += chunk.length)); + secure.once("data", () => surfaced.resolve({ handedOver, emitted, buffered: accepted.readableLength })); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const socket = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + try { + expect(await pingOverTLS(socket)).toBe("ping"); + const { handedOver, ...rest } = await surfaced.promise; + expect(handedOver).toBeGreaterThan(0); + expect(rest).toEqual({ emitted: handedOver, buffered: 0 }); + } finally { + socket.destroy(); + server.close(); + } +}); + +test("a Duplex that is paused with the ClientHello buffered when it is wrapped still handshakes", async () => { + const pipeTo = (peer: () => Duplex) => + new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + peer().push(chunk); + callback(); + }, + }); + const serverSide: Duplex = pipeTo(() => clientSide); + const clientSide: Duplex = pipeTo(() => serverSide); + const echoed = pingOverTLS(clientSide); + await once(serverSide, "readable"); + serverSide.pause(); + const secure = wrapAndEcho(serverSide); + try { + expect(await echoed).toBe("ping"); + } finally { + secure.destroy(); + clientSide.destroy(); + } +}); + +test("TLS over TLS: an outer socket that is paused with the inner ClientHello unshifted still handshakes", async () => { + const server = tls.createServer(certs, outer => { + outer.on("error", () => {}); + outer.once("data", chunk => { + outer.pause(); + if (chunk.length > 8) outer.unshift(chunk.subarray(8)); + wrapAndEcho(outer); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const outer = tls.connect({ + port: (server.address() as net.AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + }); + try { + await once(outer, "secureConnect"); + outer.cork(); + outer.write("STARTTLS"); + const echoed = pingOverTLS(outer); + outer.uncork(); + expect(await echoed).toBe("ping"); + } finally { + outer.destroy(); + server.close(); + } +}); + +// Node has one handle under both sockets, so the last call on either decides. +test.each(["socket.unref();", "socket.ref(); tlsSocket.unref();"])( + "after the upgrade, %s lets the process exit", + async calls => { + const server = net.createServer(accepted => wrapAndEcho(accepted)); + await once(server.listen(0, "127.0.0.1"), "listening"); + const script = ` + const net = require("net"), tls = require("tls"); + // A process that cannot exit must not outlive the test. + setTimeout(() => process.exit(3), 10_000).unref(); + const socket = net.connect(${(server.address() as net.AddressInfo).port}, "127.0.0.1", () => { + const tlsSocket = tls.connect({ socket, rejectUnauthorized: false }, () => { + tlsSocket.resume(); + ${calls} + }); + });`; + const child = spawn(bunExe(), ["-e", script], { env: bunEnv, stdio: ["ignore", "inherit", "inherit"] }); + try { + expect(await once(child, "exit")).toEqual([0, null]); + } finally { + child.kill(); + server.close(); + } + }, +); + +test("_parent is the wrapped net.Socket", async () => { + const server = net.createServer(socket => socket.on("error", () => {})); + await once(server.listen(0, "127.0.0.1"), "listening"); + const sockets: net.Socket[] = []; + const parentOf = (wrap: (raw: net.Socket) => tls.TLSSocket) => { + const raw = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + const tlsSocket = wrap(raw).on("error", () => {}); + sockets.push(raw, tlsSocket); + // @ts-expect-error not in @types/node + return tlsSocket._parent === raw; + }; + try { + expect({ + "tls.connect({ socket })": parentOf(socket => tls.connect({ socket })), + "new TLSSocket(socket)": parentOf(socket => new tls.TLSSocket(socket)), + "new TLSSocket(socket, { isServer })": parentOf( + socket => new tls.TLSSocket(socket, { isServer: true, ...certs }), + ), + }).toEqual({ + "tls.connect({ socket })": true, + "new TLSSocket(socket)": true, + "new TLSSocket(socket, { isServer })": true, + }); + } finally { + closeAll(server, sockets); + } +}); From a048b9793bf11464c5f1d6a46cbb8fd3310b8167 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:16:32 +0000 Subject: [PATCH 071/174] node:tls: run a server-side TLSSocket's own ALPNCallback, and report no SNI as false (#42683, #39088) alpnCallback looked the callback up on `socket.server ?? socket`. http.Server's 'connection' hook sets socket.server to a server that has none, so the wrap's own callback was skipped, including one that refuses the connection. Node keeps it per socket: the socket's own slot goes first. Node reports a ClientHello that carried no SNI as servername `false`, on the socket and in the ALPNCallback argument. Bun reported undefined. --- src/js/node/net.ts | 15 +-- test/js/node/tls/node-tls-server.test.ts | 122 +++++++++++++++++++++++ 2 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 60bb9bf64405..7f88e37317d3 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -117,7 +117,7 @@ interface ServerInternals { _ALPNCallback?: ALPNCallback; } type SNICallback = (servername: string, callback: (err: unknown, context?: unknown) => void) => void; -type ALPNCallback = (arg: { servername: string; protocols: string[] }) => string | undefined; +type ALPNCallback = (arg: { servername: string | false; protocols: string[] }) => string | undefined; type ServerInstance = Omit & ServerInternals; interface ConnectReq { @@ -179,7 +179,11 @@ interface InternalSocketHandler extends SocketHandler { } interface InternalServerHandler extends InternalSocketHandler { - alpnCallback?(socket: Socket, servername: string, protocolsWire: Uint8Array): string | false | undefined; + alpnCallback?( + socket: Socket, + servername: string | undefined, + protocolsWire: Uint8Array, + ): string | false | undefined; serverName?( server: ServerInstance | TLSSocketInstance, servername: string, @@ -1096,8 +1100,7 @@ const ServerHandlers = { // falls through to the static ALPNProtocols list), the selected protocol // string, or undefined to refuse the connection - Node's contract. const self = socket.data; - const server = self?.server ?? self; - const cb = server?._ALPNCallback; + const cb = self?._ALPNCallback ?? self?.server?._ALPNCallback; if (typeof cb !== "function") return false; const wire = Buffer.isBuffer(protocolsWire) ? protocolsWire : Buffer.from(protocolsWire); const protocols: string[] = []; @@ -1108,7 +1111,7 @@ const ServerHandlers = { } let result; try { - result = cb.$call(self, { servername, protocols }); + result = cb.$call(self, { servername: servername ?? false, protocols }); } catch (err) { // Node: a throwing ALPNCallback refuses the connection (fatal // no_application_protocol alert) and surfaces the thrown error as @@ -1273,7 +1276,7 @@ const ServerHandlers = { self._securePending = false; self.secureConnecting = false; self._secureEstablished = !!success; - self.servername = socket.getServername(); + self.servername = socket.getServername() ?? false; 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 12d37509d332..ce64f1bba8fc 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2,6 +2,7 @@ import cluster from "cluster"; import crypto from "crypto"; import { readFileSync, realpathSync } from "fs"; import { bunEnv, bunExe, tls as cert1, isASAN, isDebug, isWindows } from "harness"; +import http from "http"; import http2 from "http2"; import https from "https"; import net, { AddressInfo } from "net"; @@ -4265,3 +4266,124 @@ it("resetAndDestroy() of a server wrap on the stream-level engine destroys the w rawServer.close(); } }); + +// http.Server's 'connection' hook sets socket.server to a server that has no ALPNCallback. +describe("a server-side TLSSocket wrap that an http.Server adopts through emit('connection')", () => { + async function handshake(select: (protocols: string[]) => string | undefined) { + const calls: string[] = []; + const httpServer = http.createServer((req, res) => { + const body = `alpn=${(req.socket as TLSSocket).alpnProtocol}`; + res.writeHead(200, { "Connection": "close", "Content-Length": body.length }); + res.end(body); + }); + const front = net.createServer(raw => { + const wrapped = new TLSSocket(raw, { + isServer: true, + ...COMMON_CERT, + ALPNCallback: ({ protocols }) => { + calls.push(protocols.join(",")); + return select(protocols); + }, + }); + wrapped.on("error", () => {}); + httpServer.emit("connection", wrapped); + }); + await once(front.listen(0, "127.0.0.1"), "listening"); + const client = connect({ + port: (front.address() as AddressInfo).port, + host: "127.0.0.1", + ALPNProtocols: ["h2", "http/1.1"], + rejectUnauthorized: false, + }); + try { + const outcome = await new Promise(resolve => { + client.once("secureConnect", () => resolve(`secureConnect ${client.alpnProtocol}`)); + client.once("error", () => resolve("error")); + }); + let response: string | undefined; + if (outcome !== "error") { + client.setEncoding("latin1"); + let received = ""; + client.on("data", chunk => (received += chunk)); + client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await once(client, "close"); + response = received.slice(received.indexOf("\r\n\r\n") + 4); + } + return { outcome, calls, response }; + } finally { + client.destroy(); + front.close(); + } + } + + it("negotiates the protocol the wrap's ALPNCallback selects", async () => { + expect(await handshake(() => "http/1.1")).toEqual({ + outcome: "secureConnect http/1.1", + calls: ["h2,http/1.1"], + response: "alpn=http/1.1", + }); + }); + + it("refuses the connection when the wrap's ALPNCallback returns undefined", async () => { + expect(await handshake(() => undefined)).toEqual({ + outcome: "error", + calls: ["h2,http/1.1"], + response: undefined, + }); + }); +}); + +// https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1359-L1373 +describe("a ClientHello with no SNI is reported as servername false", () => { + // @types/node does not declare the property. + const servernameOf = (socket: TLSSocket) => (socket as unknown as { servername: unknown }).servername; + + it("by socket.servername and the ALPNCallback of a tls.Server", async () => { + let alpn: unknown = "ALPNCallback did not run"; + const server = createServer({ + ...COMMON_CERT, + ALPNCallback: ({ servername, protocols }) => { + alpn = servername; + return protocols[0]; + }, + }); + const observed = Promise.withResolvers<{ socket: unknown; alpn: unknown }>(); + server.on("secureConnection", socket => { + observed.resolve({ socket: servernameOf(socket), alpn }); + socket.end(); + }); + server.on("tlsClientError", observed.reject); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + const client = connect({ port, host: "127.0.0.1", ALPNProtocols: ["a"], rejectUnauthorized: false }); + try { + client.on("error", observed.reject); + expect(await observed.promise).toEqual({ socket: false, alpn: false }); + } finally { + client.destroy(); + server.close(); + } + }); + + it("by a server-side TLSSocket wrap after 'secure'", async () => { + const observed = Promise.withResolvers(); + const raw = net.createServer(socket => { + const secured = new TLSSocket(socket, { isServer: true, ...COMMON_CERT }); + secured.on("error", observed.reject); + secured.on("secure", () => { + observed.resolve(servernameOf(secured)); + secured.end(); + }); + }); + await once(raw.listen(0, "127.0.0.1"), "listening"); + const { port } = raw.address() as AddressInfo; + const client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + try { + client.on("error", observed.reject); + expect(await observed.promise).toBe(false); + } finally { + client.destroy(); + raw.close(); + } + }); +}); From bd25bb210f3a3e21605538788da43ebd8af3d3bf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:16:33 +0000 Subject: [PATCH 072/174] node:tls: honor setServername() called before connect() (#39040) Socket.prototype.connect resets `servername` from its own options before [buntls] reads it, so a name set while the socket had no handle was lost. It is kept next to the session a setSession() call leaves for the next connect. --- src/js/node/tls.ts | 6 ++-- test/js/node/tls/node-tls-connect.test.ts | 43 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 717b94cf294c..2d59c60328c2 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -600,6 +600,7 @@ function translatePeerCertificate(c) { const ksecureContext = Symbol("ksecureContext"); const kcheckServerIdentity = Symbol("kcheckServerIdentity"); const ksession = Symbol("ksession"); +const kservername = Symbol("kservername"); const krenegotiationDisabled = Symbol("renegotiationDisabled"); const kcontexts = Symbol("kcontexts"); @@ -614,6 +615,7 @@ function TLSSocket(socket?, options?) { this.ALPNProtocols = undefined; this[kcheckServerIdentity] = undefined; this[ksession] = undefined; + this[kservername] = undefined; this.alpnProtocol = null; this._secureEstablished = false; this._rejectUnauthorized = false; @@ -946,7 +948,7 @@ TLSSocket.prototype.setServername = function setServername(name) { if (this.isServer) { throw $ERR_TLS_SNI_FROM_SERVER(); } - // if the socket is detached we can't set the servername but we set this property so when open will auto set to it + this[kservername] = name; this.servername = name; this._handle?.setServername?.(name); }; @@ -1025,7 +1027,7 @@ TLSSocket.prototype[buntls] = function (port, host) { // RFC 6066 forbids IP literals in SNI. Match Node.js: only default servername to host // when host is not an IP. For IP hosts, pass "" so the native layer skips SNI instead of // falling back to the connection host. - let servername = this.servername || ctx?.servername; + let servername = this.servername || this[kservername] || ctx?.servername; if (servername === undefined) { servername = host && !net.isIP(host) ? host : ""; } diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index a14ed703025e..144d0cedcbb2 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -5183,3 +5183,46 @@ describe("a TLS engine over a wrapped stream that fails before it starts", () => } }); }); + +// Node starts the handshake of a bare TLSSocket in _start() and reports it with 'secure' alone. +describe("a setServername() made before TLSSocket#connect() is the SNI of the handshake", () => { + const NAME = "set.before.connect"; + + async function names(options: (port: number) => net.TcpSocketConnectOpts) { + const seen = Promise.withResolvers(); + await using server = tls.createServer(COMMON_CERT_, socket => { + seen.resolve(socket.servername); + socket.end(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + // @ts-expect-error @types/node requires a socket + const socket: TLSSocket = new TLSSocket(undefined, { rejectUnauthorized: false }); + try { + socket.setServername(NAME); + socket.connect(options(portOf(server)), function (this: TLSSocket) { + // @ts-expect-error not in @types/node + this._start(); + }); + await once(socket, "secure"); + return { client: socket.servername, server: await seen.promise }; + } finally { + socket.destroy(); + } + } + + it("on a host and port connect", async () => { + expect(await names(port => ({ port, host: "127.0.0.1" }))).toEqual({ client: NAME, server: NAME }); + }); + + it("on an autoSelectFamily connect, instead of a name derived from the host", async () => { + const lookup = ((_host, _options, callback) => + callback(null, [ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ])) as net.LookupFunction; + expect(await names(port => ({ port, host: "derived.from.host", autoSelectFamily: true, lookup }))).toEqual({ + client: NAME, + server: NAME, + }); + }); +}); From 01ede9793ef89d95371a71adcb3f8320fd02df1d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:19:08 +0000 Subject: [PATCH 073/174] node:tls: let a pause() or a pauseOnConnect around the handshake neither stall nor outlive it (#40375) Brings #40375's coverage of pause() and pauseOnConnect on accepted and dialed sockets, and fixes two of the three gaps it documented as todo rows. pause() stops the handle of a connected onread socket. In a 'connect' listener that also stopped the reads the handshake needs, so it never completed; node's handle is not reading yet there. It now waits for the handshake. A dialed pauseOnConnect stops the native reads as the handshake completes, after any resume() made since the dial, and nothing started them again: the stream said flowing and read nothing. --- src/js/node/net.ts | 4 +- test/js/node/tls/node-tls-connect.test.ts | 151 ++++++++++++++++++++++ test/js/node/tls/node-tls-server.test.ts | 95 ++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 7f88e37317d3..b2742b4ff287 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -646,6 +646,8 @@ function onClientHandshake(self, socket, success, verifyError) { } // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1697-L1698 self.secureConnecting = false; + // pauseOnConnect stopped the handle as the handshake completed, whatever resume() did before that. + if (!self.isPaused()) self.read(0); self.emit(kSecureConnectDone); self.emit("secureConnect", verifyError); const pendingSession = self[kpendingSession]; @@ -2565,7 +2567,7 @@ Socket.prototype.resume = function resume() { // Only a connected onread socket stops reading here: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L817-L827 Socket.prototype.pause = function pause() { const handle = this._handle; - if (handle && this[kOnreadBuffer] !== undefined && !this.connecting && !this.destroyed) { + if (handle && this[kOnreadBuffer] !== undefined && !this.connecting && !this.secureConnecting && !this.destroyed) { readStop(this, handle); } return Duplex.prototype.pause.$call(this); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 144d0cedcbb2..99783efde213 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -5226,3 +5226,154 @@ describe("a setServername() made before TLSSocket#connect() is the SNI of the ha }); }); }); + +describe("a dialed socket paused before its handshake completed", () => { + // The TLS handle has to read to complete the handshake. + type Dialed = { + client: TLSSocket; + socket: TLSSocket; + onread: string[]; + fence: () => Promise; + [Symbol.dispose]: () => void; + }; + + async function dial(options: { + pauseOnConnect?: boolean; + onread?: boolean; + onConnect?: (client: TLSSocket) => void; + afterDial?: (client: TLSSocket) => void; + }): Promise { + const accepted = Promise.withResolvers(); + const server = tls.createServer(COMMON_CERT_, accepted.resolve); + server.on("tlsClientError", accepted.reject); + // The fence: its bytes reach this process after the server's reply reached the client's receive buffer. + const probe = net.createServer(); + let client: TLSSocket | undefined; + const dispose = () => { + client?.destroy(); + server.close(); + probe.close(); + }; + try { + await Promise.all([ + once(server.listen(0, "127.0.0.1"), "listening"), + once(probe.listen(0, "127.0.0.1"), "listening"), + ]); + const onread: string[] = []; + const dialed = tlsConnect({ + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + pauseOnConnect: options.pauseOnConnect, + onread: options.onread + ? { + buffer: Buffer.alloc(1024), + callback: (n: number, buf: Buffer) => onread.push(String(buf.subarray(0, n))), + } + : undefined, + } as tls.ConnectionOptions); + client = dialed; + if (options.onConnect) dialed.on("connect", () => options.onConnect!(dialed)); + options.afterDial?.(dialed); + const [socket] = await Promise.all([accepted.promise, once(dialed, "secureConnect")]); + const fence = async () => { + const probed = Promise.withResolvers(); + probe.once("connection", s => s.once("data", () => probed.resolve())); + const probeClient = net.connect((probe.address() as AddressInfo).port, "127.0.0.1", () => probeClient.end("x")); + await probed.promise; + }; + return { client: dialed, socket, onread, fence, [Symbol.dispose]: dispose }; + } catch (error) { + dispose(); + throw error; + } + } + + async function untilBuffered(socket: TLSSocket, length: number) { + const deadline = performance.now() + 10_000; + while (socket.readableLength < length) { + if (performance.now() > deadline) throw new Error(`readableLength stayed at ${socket.readableLength}`); + await new Promise(resolve => setImmediate(resolve)); + } + } + + async function writeReply(socket: TLSSocket) { + await new Promise((resolve, reject) => socket.write("from-server", err => (err ? reject(err) : resolve()))); + } + + async function roundTripAfterResume(client: TLSSocket, socket: TLSSocket) { + const received = once(client, "data"); + client.resume(); + expect(String((await received)[0])).toBe("from-server"); + const reply = once(socket, "data"); + client.write("from-client"); + expect(String((await reply)[0])).toBe("from-client"); + const clientClosed = once(client, "close"); + socket.end(); + client.end(); + await clientClosed; + } + + it("pause() in a 'connect' listener: the handshake completes and the reply waits in the stream's buffer", async () => { + using t = await dial({ onConnect: client => client.pause() }); + const { client, socket } = t; + expect({ paused: client.isPaused(), flowing: client.readableFlowing }).toEqual({ paused: true, flowing: false }); + await writeReply(socket); + // Like Node: the handle reads, the decrypted bytes wait in the paused stream. + await untilBuffered(client, "from-server".length); + expect({ paused: client.isPaused(), readableLength: client.readableLength }).toEqual({ + paused: true, + readableLength: "from-server".length, + }); + await roundTripAfterResume(client, socket); + }); + + it("pause() in a 'connect' listener of an onread socket: the handshake still completes", async () => { + using t = await dial({ onread: true, onConnect: client => client.pause() }); + const { client, socket } = t; + expect(client.isPaused()).toBe(true); + await writeReply(socket); + await t.fence(); + expect(t.onread).toEqual(["from-server"]); + const reply = once(socket, "data"); + client.write("from-client"); + expect(String((await reply)[0])).toBe("from-client"); + // Node's handle keeps reading here. Ours stops after a delivery to a paused stream. + client.resume(); + const clientClosed = once(client, "close"); + socket.end(); + client.end(); + await clientClosed; + }); + + // Bun only: node ignores pauseOnConnect on a dialed socket. + it("pauseOnConnect: the handshake completes, then the socket reads nothing until resume()", async () => { + using t = await dial({ pauseOnConnect: true }); + const { client, socket } = t; + expect({ paused: client.isPaused(), flowing: client.readableFlowing }).toEqual({ paused: true, flowing: false }); + await writeReply(socket); + await t.fence(); + // The handle stopped reading once the handshake completed, so the reply stays in the kernel. + expect({ paused: client.isPaused(), bytesRead: client.bytesRead }).toEqual({ paused: true, bytesRead: 0 }); + await roundTripAfterResume(client, socket); + }); + + it("pauseOnConnect: a resume() before 'secureConnect' is not undone by the handshake", async () => { + const received = Promise.withResolvers(); + using t = await dial({ + pauseOnConnect: true, + afterDial: client => { + client.resume(); + client.once("data", chunk => received.resolve(String(chunk))); + }, + }); + const { client, socket } = t; + expect(client.isPaused()).toBe(false); + await writeReply(socket); + expect(await received.promise).toBe("from-server"); + const clientClosed = once(client, "close"); + socket.end(); + client.end(); + await clientClosed; + }); +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index ce64f1bba8fc..9f00c3acea12 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4387,3 +4387,98 @@ describe("a ClientHello with no SNI is reported as servername false", () => { } }); }); + +describe("an accepted socket whose stream state changes before or during its handshake", () => { + // A pause() may only hold the decrypted bytes back: the handle has to read to complete the handshake. + async function accept(options: { + pauseOnConnect?: boolean; + on?: Partial void>>; + }) { + const server = createServer({ ...COMMON_CERT, pauseOnConnect: options.pauseOnConnect }); + const accepted = Promise.withResolvers(); + for (const [event, listener] of Object.entries(options.on ?? {})) server.on(event, listener); + server.on("secureConnection", accepted.resolve); + server.on("tlsClientError", accepted.reject); + let client: TLSSocket | undefined; + const dispose = () => { + client?.destroy(); + server.close(); + }; + try { + await once(server.listen(0, "127.0.0.1"), "listening"); + client = connect({ + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + }); + const [socket] = await Promise.all([accepted.promise, once(client, "secureConnect")]); + return { client, socket, [Symbol.dispose]: dispose }; + } catch (error) { + dispose(); + throw error; + } + } + + async function untilBuffered(socket: TLSSocket, length: number) { + const deadline = performance.now() + 10_000; + while (socket.readableLength < length) { + if (performance.now() > deadline) throw new Error(`readableLength stayed at ${socket.readableLength}`); + await new Promise(resolve => setImmediate(resolve)); + } + } + + async function endBothSides(client: TLSSocket, socket: TLSSocket) { + const clientClosed = once(client, "close"); + socket.end(); + client.end(); + await clientClosed; + } + + // The 'connection' row is Bun only: node hands that event the raw socket, so the TLSSocket is not paused. + it.each(["connection", "secureConnection"] as const)( + "s.pause() in a '%s' listener completes the handshake and buffers the bytes until resume()", + async event => { + using t = await accept({ on: { [event]: socket => socket.pause() } }); + const { client, socket } = t; + expect({ paused: socket.isPaused(), flowing: socket.readableFlowing }).toEqual({ paused: true, flowing: false }); + const chunks: string[] = []; + // A 'data' listener leaves a paused stream paused. + socket.on("data", chunk => chunks.push(String(chunk))); + await new Promise((resolve, reject) => client.write("hello", err => (err ? reject(err) : resolve()))); + // The stream's buffer fills because the handle still reads. Like Node, 'data' waits for resume(). + await untilBuffered(socket, 5); + expect({ chunks, flowing: socket.readableFlowing, readableLength: socket.readableLength }).toEqual({ + chunks: [], + flowing: false, + readableLength: 5, + }); + const received = once(socket, "data"); + socket.resume(); + await received; + expect(chunks).toEqual(["hello"]); + await endBothSides(client, socket); + }, + ); + + it("pauseOnConnect: a resume() in the 'secureConnection' listener is not undone afterwards", async () => { + using t = await accept({ pauseOnConnect: true, on: { secureConnection: socket => socket.resume() } }); + const { client, socket } = t; + expect({ paused: socket.isPaused(), flowing: socket.readableFlowing }).toEqual({ paused: false, flowing: true }); + const received = once(socket, "data"); + client.write("hello"); + expect(String((await received)[0])).toBe("hello"); + await endBothSides(client, socket); + }); + + // Node's TLSWrap reads ahead. Ours stops reading once the handshake completed (#39830). + it.todo("pauseOnConnect: a socket still paused emits 'end' when the peer ends", async () => { + using t = await accept({ pauseOnConnect: true }); + const { client, socket } = t; + const ended = once(socket, "end"); + client.end(); + await ended; + expect(socket.isPaused()).toBe(true); + socket.end(); + await once(client, "close"); + }); +}); From 8491fa8fbf5240697eb2748688bcfc0898a34837 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 02:23:28 +0000 Subject: [PATCH 074/174] node:tls: bind the wrapped stream's 'error' listener instead of closing over the wrap --- src/js/node/net.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index b2742b4ff287..616980ea0360 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -432,13 +432,14 @@ function destroyWhenUpgradedCloses(self, connection) { // Node's wrap 'error' -> _emitTLSError. // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L65 // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977 +function onUpgradedError(self, err) { + if (self.destroyed) return; + // The close that follows is no second error. + self._hadError = true; + self._emitTLSError(err); +} function forwardUpgradedError(self, connection) { - connection.on("error", err => { - if (self.destroyed) return; - // The close that follows is no second error. - self._hadError = true; - self._emitTLSError(err); - }); + connection.on("error", onUpgradedError.bind(null, self)); } // Whatever state the wrapped stream is in: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 function linkUpgraded(self, connection: SocketInstance) { From 5d40e7990d3f5be532b4a5cc275bcca52f46c8d3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 02:23:30 +0000 Subject: [PATCH 075/174] tls: the close of a TLS socket over a Duplex no longer resumes the transport (#42332) _destroy now destroys any transport that is not an adopted fd's twin, so a paused net.Socket transport closes without being resumed first. --- src/runtime/socket/UpgradedDuplex.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 985aeae06814..f6640a0fa0a6 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -217,8 +217,6 @@ impl UpgradedDuplex { js_wrapper.ensure_still_alive(); (self.handlers.on_close)(self.handlers.ctx); - // Left paused, a net.Socket transport never reads its peer's FIN and stays open. - self.call_origin("resume"); // closes the underlying duplex self.call_write_or_end(None, false); From 5ac00b8750f7dca7ebf1902c5947154bc2c75e75 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:03:11 +0000 Subject: [PATCH 076/174] usockets: resume a paused TLS socket when its graceful close waits for the peer (#42352) us_internal_ssl_close(s, 0) sends close_notify and keeps the fd until it reads the peer's reply. A paused socket never reads, and end() detached the owner, so nobody could resume it: no close event, fd held for the life of the process. The tail that decides to wait now resumes the socket. --- packages/bun-usockets/src/crypto/openssl.c | 2 + test/js/bun/net/socket.test.ts | 129 +++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index cb42d7b70e12..5c3dba61b060 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2176,6 +2176,8 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void if (code != LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN || ssl_handle_shutdown(s)) { return us_internal_socket_close_raw(s, code, reason); } + /* Only a read delivers the peer's reply, and the owner that paused let go with this close. */ + us_socket_resume(s); return s; } #define ssl_close us_internal_ssl_close diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 3f5d443aa34a..b5473269f524 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -5210,6 +5210,135 @@ it("a paused socket with a backpressured write still closes when its peer resets expect(error?.code).toBe("ECONNRESET"); }); +// The graceful close keeps the fd until it has read the peer's close_notify or FIN. +describe.concurrent("tls socket that is paused when its graceful close starts", () => { + const ENDINGS = { + "end()": (socket: Socket) => void socket.end(), + "[Symbol.dispose]()": (socket: Socket) => socket[Symbol.dispose](), + }; + + describe.each([ + ["end()", "end"], + ["end()", "close"], + ["[Symbol.dispose]()", "end"], + ] as const)("pause() then %s, peer answers with %s()", (ending, answer) => { + it("reads the peer's reply and closes", async () => { + const closed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + data(socket) { + socket.write("pong"); + socket.pause(); + ENDINGS[ending](socket); + }, + close() { + closed.resolve(); + }, + }, + }); + + const peerClosed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + socket: { + handshake(socket, success, authorizationError) { + if (success) socket.write("ping"); + else peerClosed.reject(authorizationError ?? new Error("client handshake failed")); + }, + // end() replies with close_notify and a FIN, close() with the FIN alone. + data: socket => void socket[answer](), + error: (_socket, error) => peerClosed.reject(error), + connectError: (_socket, error) => peerClosed.reject(error), + close: () => peerClosed.resolve(), + }, + }); + await peerClosed.promise; + await closed.promise; + }); + }); + + it("pause() then end() on the connecting side closes", async () => { + const closed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + data: socket => void socket.write("pong"), + }, + }); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + socket: { + handshake(socket, success, authorizationError) { + if (success) socket.write("ping"); + else closed.reject(authorizationError ?? new Error("client handshake failed")); + }, + data(socket) { + socket.pause(); + socket.end(); + }, + error: (_socket, error) => closed.reject(error), + connectError: (_socket, error) => closed.reject(error), + close: () => closed.resolve(), + }, + }); + await closed.promise; + }); + + it("pause() then end() behind unsent ciphertext closes, and the peer gets every byte", async () => { + const chunk = Buffer.alloc(64 * 1024, "x"); + let sent = 0; + const closed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + data(socket) { + socket.pause(); + // The peer cannot read before this callback returns, so the writes end in a short one. + for (let wrote = chunk.length; wrote === chunk.length; sent += wrote) wrote = socket.write(chunk); + socket.end(); + }, + close() { + closed.resolve(); + }, + }, + }); + + let received = 0; + const peerClosed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + socket: { + handshake(socket, success, authorizationError) { + if (success) socket.write("ping"); + else peerClosed.reject(authorizationError ?? new Error("client handshake failed")); + }, + data(_socket, data) { + received += data.length; + }, + error: (_socket, error) => peerClosed.reject(error), + connectError: (_socket, error) => peerClosed.reject(error), + close: () => peerClosed.resolve(), + }, + }); + await Promise.all([closed.promise, peerClosed.promise]); + expect(sent).toBeGreaterThan(chunk.length); + expect(received).toBe(sent); + }); +}); + // A close that the event loop initiated passes the read error to close(). usockets // reports that error in the platform's own numbering (an errno on POSIX, a WSA code // such as WSAECONNRESET = 10054 on Windows) and on_close has to map it: unmapped, a From 31ac8508815010dd6f9331c8b8a1f03ec67bae27 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:03:12 +0000 Subject: [PATCH 077/174] fetch: do not pick sendfile for a Bun.file() body when the environment's proxy is TLS (#44458) The sendfile choice only looked at the explicit proxy option. A proxy from the environment is resolved later, so http_proxy=https://... with a file body of 32 KiB or more reached a TLS socket with a Sendfile body and aborted the process. --- src/runtime/webcore/fetch.rs | 11 ++++++++++- test/js/bun/http/proxy.test.ts | 22 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 88c9666110d7..7ca2d4a9fb93 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1575,7 +1575,16 @@ fn fetch_impl( // An explicit `compress` request always wins over the sendfile // heuristic — otherwise the same `Bun.file()` body would compress // over https/proxy/<32 KiB/Windows but silently not over plain http. - if proxy.is_none() && compress.is_none() && http::SendFile::is_eligible(&url) { + let env_proxy_is_tls = !proxy_direct + && vm + .env_loader() + .get_http_proxy_for(&url) + .is_some_and(|env_proxy| env_proxy.is_https()); + if proxy.is_none() + && !env_proxy_is_tls + && compress.is_none() + && http::SendFile::is_eligible(&url) + { 'use_sendfile: { let stat: bun_sys::Stat = match bun_sys::fstat(opened_fd) { Ok(result) => result, diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 9c899788dc40..c4eddd71d8af 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -2,7 +2,7 @@ import axios from "axios"; import type { Server } from "bun"; import { proxyInternals } from "bun:internal-for-testing"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isIPv6, isWindows, tls as tlsCert } from "harness"; +import { bunEnv, bunExe, isASAN, isIPv6, isWindows, tempDir, tls as tlsCert } from "harness"; import { HttpsProxyAgent } from "https-proxy-agent"; import { once } from "node:events"; import http from "node:http"; @@ -231,6 +231,26 @@ for (const proxy_tls of [false, true]) { } } +test.concurrent("POST of a Bun.file() large enough for sendfile through a TLS proxy from the environment", async () => { + const content = Buffer.alloc(200_000, "abcdefgh"); + using dir = tempDir("proxy-sendfile-body", { "body.bin": content }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const file = Bun.file("body.bin"); + const res = await fetch(${JSON.stringify(String(httpServer.url))}, { method: "POST", body: file, tls: { rejectUnauthorized: false } }); + console.log(res.status, (await res.text()) === (await file.text()));`, + ], + env: { ...bunEnv, ...proxyFreeEnv, http_proxy: httpsProxyServer.url }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode }).toEqual({ stdout: "200 true\n", stderr: "", exitCode: 0 }); +}); + for (const server_tls of [false, true]) { describe.concurrent(`proxy can handle redirects with ${server_tls ? "TLS" : "non-TLS"} server`, () => { test("with empty body #12007", async () => { From 934775611ac484d346ca68c9e1b8d97e2daf4ea8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:04:41 +0000 Subject: [PATCH 078/174] usockets: split the failed-send classification out of us_socket_write_check_error (#42336, #34510) Pure move. The TLS write path needs the same answer for its raw sends. --- packages/bun-usockets/src/socket.c | 98 ++++++++++++++++-------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 284dab2bf9c3..d02121994624 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -516,6 +516,54 @@ static int us_internal_send_errno_is_peer_gone(int e) { #define US_UNCLASSIFIED_SEND_RETRY_LIMIT 32 #endif +/* Classifies a send() on `s` that just returned < 0: 0 = retry from a writable event, else the + * platform's error code (errno, a WSA code on Windows) of a send that no retry can save. */ +static int us_internal_classify_failed_send(struct us_socket_t *s) { + /* bsd_send already retries EINTR; bsd_would_block() reads errno on + * POSIX and WSAGetLastError() on Windows. ENOBUFS/ENOMEM are + * transient kernel resource exhaustion on a healthy connection - + * classifying them as fatal made the node:net drain path drop the + * buffered bytes on a socket that kept flowing. */ + if (bsd_would_block() || bsd_send_is_transient_error()) { + return 0; + } +#ifndef _WIN32 + /* Anything else that is not a known peer-gone errno gets a BOUNDED + * retry through the same rearm/writable machinery as would-block. + * Kernels return racy, non-terminal errnos from send() on sockets + * that are still perfectly usable - macOS EPROTOTYPE while the + * connection is concurrently mutating is the canonical case, and + * libuv retries it (RETRY_ON_WRITE_ERROR, + * https://github.com/libuv/libuv/blob/v1.x/src/unix/stream.c) - + * so failing the write on first sight killed live connections. + * But the retry must not be unbounded: an errno that persists across + * many consecutive writable dispatches with no successful send in + * between means the transport is dead in a way the kernel will not + * name on the write side, and silently re-arming forever jams the + * connection (buffered bytes never drain and no error ever + * surfaces). After the limit, report the errno like the peer-gone + * class so the caller can fail the write. */ + if (!us_internal_send_errno_is_peer_gone(errno) && + s->unclassified_send_failures < US_UNCLASSIFIED_SEND_RETRY_LIMIT) { + s->unclassified_send_failures++; + return 0; + } + return errno; +#else + /* Windows: report the raw (positive) WSA code. The JS layer already + * traffics in raw negative WSA values on this platform (see + * SocketEmitEndNT / failWrite, which shape unnameable ones as + * ECONNRESET), and a real code keeps fatal sends distinguishable from + * the legacy -1 closed/shutdown sentinel so the h2 parser can latch + * them (flood tests hung on Windows because a fatal send looked like + * backpressure). Keep 1 as the floor for a zero/garbage WSA value. + * See a5e7ba5905 before widening what counts as fatal + * (drain-into-RST on test-http-no-content-length). */ + int wsa_send_error = WSAGetLastError(); + return wsa_send_error > 1 ? wsa_send_error : 1; +#endif +} + int us_socket_write_check_error(struct us_socket_t *s, const char *data, int length, int *fatal_write_error) { if (fatal_write_error) *fatal_write_error = 0; if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { @@ -528,57 +576,15 @@ int us_socket_write_check_error(struct us_socket_t *s, const char *data, int len int written = bsd_send(us_poll_fd(&s->p), data, length); if (written < 0) { - /* bsd_send already retries EINTR; bsd_would_block() reads errno on - * POSIX and WSAGetLastError() on Windows. ENOBUFS/ENOMEM are - * transient kernel resource exhaustion on a healthy connection - - * classifying them as fatal made the node:net drain path drop the - * buffered bytes on a socket that kept flowing. */ - if (bsd_would_block() || bsd_send_is_transient_error()) { + int fatal_send_error = us_internal_classify_failed_send(s); + if (!fatal_send_error) { s->flags.last_write_failed = 1; us_internal_rearm_writable(s); return 0; } -#ifndef _WIN32 - /* Anything else that is not a known peer-gone errno gets a BOUNDED - * retry through the same rearm/writable machinery as would-block. - * Kernels return racy, non-terminal errnos from send() on sockets - * that are still perfectly usable - macOS EPROTOTYPE while the - * connection is concurrently mutating is the canonical case, and - * libuv retries it (RETRY_ON_WRITE_ERROR, - * https://github.com/libuv/libuv/blob/v1.x/src/unix/stream.c) - - * so failing the write on first sight killed live connections. - * But the retry must not be unbounded: an errno that persists across - * many consecutive writable dispatches with no successful send in - * between means the transport is dead in a way the kernel will not - * name on the write side, and silently re-arming forever jams the - * connection (buffered bytes never drain and no error ever - * surfaces). After the limit, report the errno like the peer-gone - * class so the caller can fail the write. */ - if (!us_internal_send_errno_is_peer_gone(errno) && - s->unclassified_send_failures < US_UNCLASSIFIED_SEND_RETRY_LIMIT) { - s->unclassified_send_failures++; - s->flags.last_write_failed = 1; - us_internal_rearm_writable(s); - return 0; - } - /* Fatal send error: report the errno to callers that opt in and stop + /* Fatal send error: report the code to callers that opt in and stop * polling writable - retrying can never succeed. */ - if (fatal_write_error) *fatal_write_error = errno; -#else - /* Windows: report the raw (positive) WSA code. The JS layer already - * traffics in raw negative WSA values on this platform (see - * SocketEmitEndNT / failWrite, which shape unnameable ones as - * ECONNRESET), and a real code keeps fatal sends distinguishable from - * the legacy -1 closed/shutdown sentinel so the h2 parser can latch - * them (flood tests hung on Windows because a fatal send looked like - * backpressure). Keep 1 as the floor for a zero/garbage WSA value. - * See a5e7ba5905 before widening what counts as fatal - * (drain-into-RST on test-http-no-content-length). */ - if (fatal_write_error) { - int wsa_send_error = WSAGetLastError(); - *fatal_write_error = wsa_send_error > 1 ? wsa_send_error : 1; - } -#endif + if (fatal_write_error) *fatal_write_error = fatal_send_error; return 0; } s->unclassified_send_failures = 0; From 977352779fd730cfb8f8dac899d57989d2dc1f54 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:48:44 +0000 Subject: [PATCH 079/174] usockets(tls): a write that can never succeed is an error, not backpressure (#34510, #38176, #42336) Every failed TLS write came back as "0 bytes, wait for writable": - send() rejected (EPIPE, ECONNRESET, ...): us_socket_raw_write folded -1 to 0 and re-armed writable. The kernel kept reporting writable, so the loop spun at 100% CPU (#24845), and a real reset ended as a clean close with the lost write reported as done. - SSL_write failed, or sealed records were lost to an allocation failure: the socket was marked fatal and nothing ever closed it. - SSL_write failed on a later 16 KiB record of one write: a plain partial write. A rejected send() of sealed records now ends the write side (the state our own FIN already has, so the BIO and the batch flush drop what can no longer leave) and asks for one writable event. There, outside any caller's write, the socket closes with an error, unless the peer's earlier bytes are still queued: then the read side delivers them first (a 413 sent before the server reset an upload). A fatal socket is closed at the same boundary. us_socket_write_check_error reports both: the errno of the send, or EPROTO, so node:tls fails the write like Node and Bun.connect's write()/end() return the documented -1. No new per-socket state: one int of loop scratch carries the errno from the raw send to the write that is still on the stack. --- packages/bun-usockets/src/crypto/openssl.c | 146 ++++++++--- .../bun-usockets/src/internal/fault_inject.h | 3 + packages/bun-usockets/src/internal/internal.h | 5 + packages/bun-usockets/src/libusockets.h | 4 +- packages/bun-usockets/src/socket.c | 28 +- src/js/internal-for-testing.ts | 5 +- src/runtime/api/bun/h2_frame_parser.rs | 3 +- src/runtime/socket/socket_body.rs | 15 +- src/uws_sys/lib.rs | 2 + src/uws_sys/socket.rs | 5 +- test/js/bun/net/socket.test.ts | 136 ++++++++++ .../bun/util/socket-fault-injection.test.ts | 9 +- test/js/node/http2/node-http2.test.js | 109 +++++++- test/js/node/tls/node-tls-connect.test.ts | 120 +++++++++ test/js/node/tls/node-tls-server.test.ts | 55 ++++ test/js/node/tls/tls-syscall-fault.test.ts | 242 ++++++++++++++++++ test/js/web/fetch/fetch.tls.test.ts | 27 ++ 17 files changed, 847 insertions(+), 67 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 5c3dba61b060..95432d32491d 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -111,6 +111,9 @@ struct loop_ssl_data { unsigned int ssl_spill_len; unsigned int ssl_spill_off; + /* Why ssl_raw_write ended a write side inside the running us_internal_ssl_writev or spill drain. */ + int ssl_send_error; + /* us_socket_sni_resolve's answer; us_select_cert_cb takes it in the same re-drive. */ SSL_CTX *ssl_sni_resolved_ctx; }; @@ -516,6 +519,19 @@ void us_internal_ssl_loop_state_restore(void **saved) { d->ssl_write_batching = (int)(uintptr_t)saved[5]; } +/* A send() that no retry can save loses sealed records: nothing may follow them, but what the peer sent is still read. */ +static int ssl_raw_write(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s, const char *data, int length) { + int send_error = 0; + int written = us_internal_socket_raw_write(s, data, length, &send_error); + if (send_error) { + loop_ssl_data->ssl_send_error = send_error; + us_internal_socket_raw_shutdown(s); + /* No write may close under its caller: us_internal_ssl_on_writable does. */ + us_internal_rearm_writable(s); + } + return written; +} + static int BIO_s_custom_write(BIO *bio, const char *data, int length) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)BIO_get_data(bio); @@ -579,7 +595,7 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { BIO_clear_retry_flags(bio); return length; } - int written = us_socket_raw_write(loop_ssl_data->ssl_socket, data, length); + int written = ssl_raw_write(loop_ssl_data, loop_ssl_data->ssl_socket, data, length); BIO_clear_retry_flags(bio); if (!written) { @@ -611,8 +627,7 @@ static int ssl_flush_write_batch(struct loop_ssl_data *loop_ssl_data, struct us_ } loop_ssl_data->ssl_write_batch_len = 0; loop_ssl_data->ssl_write_batch_owner = NULL; - int written = us_socket_raw_write(s, loop_ssl_data->ssl_write_batch, (int)len); - if (written < 0) written = 0; + int written = ssl_raw_write(loop_ssl_data, s, loop_ssl_data->ssl_write_batch, (int)len); if ((unsigned int)written < len) { unsigned int remainder = len - (unsigned int)written; if (!us_internal_socket_can_raw_write(s)) { @@ -656,20 +671,24 @@ static void ssl_release_batch(struct us_loop_t *loop, struct us_socket_t *s) { } } -/* Try to drain the spill slot for `s`. Returns 1 when clear (or not ours), - * 0 while ciphertext is still pending for this socket. */ +static void ssl_discard_spill(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s) { + if (loop_ssl_data->ssl_spill_owner != s) return; + us_free(loop_ssl_data->ssl_spill); + loop_ssl_data->ssl_spill = NULL; + loop_ssl_data->ssl_spill_len = 0; + loop_ssl_data->ssl_spill_off = 0; + loop_ssl_data->ssl_spill_owner = NULL; +} + +/* Try to drain the spill slot for `s`. Returns 1 when clear (not ours, sent, or + * dropped with the write side), 0 while ciphertext is still pending for this socket. */ static int ssl_drain_spill(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s) { if (loop_ssl_data->ssl_spill_owner != s) return 1; unsigned int pending = loop_ssl_data->ssl_spill_len - loop_ssl_data->ssl_spill_off; - int written = us_socket_raw_write(s, loop_ssl_data->ssl_spill + loop_ssl_data->ssl_spill_off, (int)pending); - if (written < 0) written = 0; - loop_ssl_data->ssl_spill_off += (unsigned int)written; - if (loop_ssl_data->ssl_spill_off == loop_ssl_data->ssl_spill_len) { - us_free(loop_ssl_data->ssl_spill); - loop_ssl_data->ssl_spill = NULL; - loop_ssl_data->ssl_spill_len = 0; - loop_ssl_data->ssl_spill_off = 0; - loop_ssl_data->ssl_spill_owner = NULL; + loop_ssl_data->ssl_spill_off += (unsigned int)ssl_raw_write( + loop_ssl_data, s, loop_ssl_data->ssl_spill + loop_ssl_data->ssl_spill_off, (int)pending); + if (loop_ssl_data->ssl_spill_off == loop_ssl_data->ssl_spill_len || !us_internal_socket_can_raw_write(s)) { + ssl_discard_spill(loop_ssl_data, s); return 1; } return 0; @@ -678,18 +697,11 @@ static int ssl_drain_spill(struct loop_ssl_data *loop_ssl_data, struct us_socket /* Release the spill slot when its owner dies (close path). */ static void ssl_release_spill(struct us_loop_t *loop, struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)loop->data.ssl_data; - if (loop_ssl_data && loop_ssl_data->ssl_spill_owner == s) { - /* Hard close with ciphertext still spilled: give the kernel one last - * chance to take it (it usually can - the spill is bounded small). */ - ssl_drain_spill(loop_ssl_data, s); - } - if (loop_ssl_data && loop_ssl_data->ssl_spill_owner == s) { - us_free(loop_ssl_data->ssl_spill); - loop_ssl_data->ssl_spill = NULL; - loop_ssl_data->ssl_spill_len = 0; - loop_ssl_data->ssl_spill_off = 0; - loop_ssl_data->ssl_spill_owner = NULL; - } + if (!loop_ssl_data) return; + /* Hard close with ciphertext still spilled: give the kernel one last + * chance to take it (it usually can - the spill is bounded small). */ + ssl_drain_spill(loop_ssl_data, s); + ssl_discard_spill(loop_ssl_data, s); } void us_internal_ssl_socket_relocated(struct us_loop_t *loop, struct us_socket_t *old_s, @@ -2333,6 +2345,8 @@ static struct us_socket_t *ssl_deliver_eof(struct us_socket_t *s) { return us_dispatch_end(s); } +static struct us_socket_t *ssl_on_writable(struct us_socket_t *s); + /* Retry a JS write parked on WANT_READ (written before the handshake * finished). No-op while this socket's spill is undrained: the flag is kept * so the retry happens after on_writable drains it. */ @@ -2341,7 +2355,7 @@ static struct us_socket_t *ssl_retry_parked_write(struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; if (loop_ssl_data && loop_ssl_data->ssl_spill_owner == s) return s; s->ssl_write_wants_read = 0; - return us_internal_ssl_on_writable(s); + return ssl_on_writable(s); } struct us_socket_t *us_internal_ssl_on_end(struct us_socket_t *s) { @@ -2391,14 +2405,34 @@ struct us_socket_t *us_internal_ssl_on_end(struct us_socket_t *s) { return s; } +/* A fatal socket neither sends nor receives, and no write may close under its caller: the writable event does. */ +static struct us_socket_t *ssl_close_if_fatal(struct us_socket_t *s) { + if (!s || ssl_gone(s) || !s->ssl_fatal_error) return s; + return ssl_close(s, 0, NULL); +} + +/* The read side delivers what the peer sent before it closes, unless the owner already let go. */ +static struct us_socket_t *ssl_close_after_rejected_send(struct us_socket_t *s, int send_error) { + if (!s->ssl_close_after_spill && us_socket_queued_input(s) == LIBUS_QUEUED_INPUT_DATA) return s; + return us_internal_socket_close_raw(s, send_error > 2 ? send_error : LIBUS_ECONNRESET, NULL); +} + struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { - ssl_set_loop_data(s); + /* Past its FIN a socket polls for writable only when ssl_raw_write asked for this event. */ + if (!us_internal_socket_can_raw_write(s)) return ssl_close_after_rejected_send(s, LIBUS_ECONNRESET); + return ssl_on_writable(s); +} + +static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { + struct loop_ssl_data *loop_ssl_data = ssl_set_loop_data(s); + s = ssl_close_if_fatal(s); + if (!s || ssl_gone(s)) return s; { - struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; /* Ciphertext from a partial batch flush goes out before anything else; * while it is pending nothing new may be written for this socket. */ - unsigned int spill_off_before = loop_ssl_data ? loop_ssl_data->ssl_spill_off : 0; - if (loop_ssl_data && !ssl_drain_spill(loop_ssl_data, s)) { + unsigned int spill_off_before = loop_ssl_data->ssl_spill_off; + loop_ssl_data->ssl_send_error = 0; + if (!ssl_drain_spill(loop_ssl_data, s)) { /* A writable event that moves zero spill bytes after the peer's * readable side has already ended means the peer is gone (send() * hit EPIPE/ECONNRESET, folded to 0 by us_socket_raw_write) and @@ -2416,10 +2450,11 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { us_socket_stalled_write_means_peer_gone(s)) { ssl_release_spill(s->group->loop, s); s->ssl_fatal_error = 1; - return us_dispatch_writable(s); + return ssl_close_if_fatal(us_dispatch_writable(s)); } return s; } + if (loop_ssl_data->ssl_send_error) return ssl_close_after_rejected_send(s, loop_ssl_data->ssl_send_error); if (s->ssl_shutdown_after_spill) { s->ssl_shutdown_after_spill = 0; us_internal_ssl_shutdown(s); @@ -2441,7 +2476,8 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { s = us_internal_ssl_on_data(s, "", 0); if (!s || ssl_gone(s)) return s; } - if (ssl_gone(s) || s->ssl_fatal_error) return s; + s = ssl_close_if_fatal(s); + if (!s || ssl_gone(s)) return s; /* uWS HTTP sockets keep the pre-existing SENT_SHUTDOWN suppression: their * onWritable clears the teardown timeout armed at shutdown. node sockets * still get write-completion dispatch after a half-close in either @@ -2450,7 +2486,8 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { if (s->ssl_handshake_state == HANDSHAKE_COMPLETED) { s->ssl_write_parked = 0; - s = us_dispatch_writable(s); + /* loop.c drops the writable interest of a shut-down socket after this dispatch. */ + s = ssl_close_if_fatal(us_dispatch_writable(s)); } return s; } @@ -2801,7 +2838,8 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, /* Earlier batched records of ours must reach the wire before anything new: * SSL already counts them as written. While they cannot be delivered, the * caller buffers plaintext (return 0), bounding the in-flight ciphertext. */ - if (!ssl_drain_spill(loop_ssl_data, s)) { + loop_ssl_data->ssl_send_error = 0; + if (!ssl_drain_spill(loop_ssl_data, s) || !us_internal_socket_can_raw_write(s)) { return 0; } @@ -2830,6 +2868,16 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, } size_t part_left = iov->iov_len - part_offset; int chunk = part_left > 16384 ? 16384 : (int)part_left; +#if defined(LIBUS_SOCKET_FAULT_INJECTION) && LIBUS_SOCKET_FAULT_INJECTION + ssize_t injected = 0; + int unused = 0; + if (US_FAULT_CHECK(US_FAULT_SSL_WRITE, us_poll_fd(&s->p), injected, unused)) { + ERR_clear_error(); + ERR_put_error(ERR_LIB_SSL, 0, ERR_R_INTERNAL_ERROR, __FILE__, __LINE__); + last_ssl_written = -1; + break; + } +#endif /* Same deferred-close protocol as the SSL_do_handshake/SSL_read drivers. */ s->ssl_in_use = 1; last_ssl_written = SSL_write(s_ssl(s), (const char *)iov->iov_base + part_offset, chunk); @@ -2846,9 +2894,9 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, if (last_ssl_written <= 0) break; total += last_ssl_written; part_offset += (size_t)last_ssl_written; - /* A batching allocation failure marks the socket fatal from inside the BIO; - * stop sealing records for a connection that is being torn down. */ - if (s->ssl_fatal_error) break; + /* A batching allocation failure marks the socket fatal from inside the BIO, a + * rejected send() ends the write side: stop sealing records that cannot leave. */ + if (s->ssl_fatal_error || !us_internal_socket_can_raw_write(s)) break; if (batching && loop_ssl_data->ssl_write_batch_len >= 131072) { if (!ssl_flush_write_batch(loop_ssl_data, s)) break; /* wire blocked: stop consuming */ if (s->ssl_fatal_error) break; @@ -2858,9 +2906,8 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, if (batching) { ssl_flush_write_batch(loop_ssl_data, s); } - if (s->ssl_fatal_error) return 0; - if (total > 0) return total; - if (last_ssl_written <= 0) { + if (!us_internal_socket_can_raw_write(s)) return 0; + if (!s->ssl_fatal_error && last_ssl_written <= 0) { int err = SSL_get_error(s_ssl(s), last_ssl_written); if (err == SSL_ERROR_WANT_READ) { s->ssl_write_wants_read = 1; @@ -2874,9 +2921,26 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, ssl_park_fatal_reason(s); } } + if (!s->ssl_fatal_error) return total; + s->flags.last_write_failed = 1; + us_internal_rearm_writable(s); return 0; } +int us_internal_ssl_fatal_write_error(struct us_socket_t *s) { + /* Before the handshake is reported, its dispatch carries the failure. */ + if (!s->ssl || s->ssl_handshake_state != HANDSHAKE_COMPLETED) return 0; + if (!us_internal_socket_can_raw_write(s)) { + return ((struct loop_ssl_data *)s->group->loop->data.ssl_data)->ssl_send_error; + } + if (!s->ssl_fatal_error) return 0; +#ifdef _WIN32 + return -UV_EPROTO; +#else + return EPROTO; +#endif +} + void us_internal_ssl_shutdown(struct us_socket_t *s) { if (us_socket_is_closed(s) || us_internal_ssl_is_shut_down(s)) return; if (s->ssl_first_flight_before_fin) { diff --git a/packages/bun-usockets/src/internal/fault_inject.h b/packages/bun-usockets/src/internal/fault_inject.h index 8668c1bb86ce..4478391401d8 100644 --- a/packages/bun-usockets/src/internal/fault_inject.h +++ b/packages/bun-usockets/src/internal/fault_inject.h @@ -54,6 +54,9 @@ enum us_fault_syscall { * US_FAULT_ERRNO applies, and the errno value is ignored — the simulated * failure is a thrown JS out-of-memory error, not an errno. */ US_FAULT_SESSION_BUFFER, + /* Not a syscall: one SSL_write call in us_internal_ssl_writev fails with a fatal SSL + * error. Only US_FAULT_ERRNO applies, and the errno value is ignored. */ + US_FAULT_SSL_WRITE, US_FAULT_COUNT }; diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 6e7a334a934d..48602ca85190 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -267,6 +267,8 @@ int us_internal_ssl_is_shut_down(us_socket_r s); void us_internal_ssl_shutdown(us_socket_r s); int us_internal_ssl_write(us_socket_r s, const char *data, int length); int us_internal_ssl_writev(us_socket_r s, const struct us_iovec_t *iov, int count); +/* Platform error code of the write that just returned if it killed the connection, else 0. */ +int us_internal_ssl_fatal_write_error(us_socket_r s); unsigned int us_internal_ssl_spill_pending(us_socket_r s); void *us_internal_ssl_get_native_handle(us_socket_r s); struct us_bun_verify_error_t us_internal_ssl_verify_error(us_socket_r s); @@ -278,6 +280,9 @@ void us_internal_ssl_ctx_up_ref(struct ssl_ctx_st *ssl_ctx); void us_internal_ssl_ctx_unref(struct ssl_ctx_st *ssl_ctx); /* TCP-level FIN, bypassing the SSL layer (used by ssl_on_end). */ void us_internal_socket_raw_shutdown(us_socket_r s); +/* us_socket_raw_write that stores us_internal_classify_failed_send's answer for a failed send(). */ +int us_internal_socket_raw_write(us_socket_r s, const char *data, int length, int *fatal_send_error); +void us_internal_rearm_writable(us_socket_r s); int us_internal_handle_dns_results(us_loop_r loop); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 95b125671751..1307c7cbcfb1 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -666,8 +666,8 @@ int us_socket_writev(us_socket_r s, const struct us_iovec_t *iov, int count) non int us_socket_raw_write(us_socket_r s, const char *data, int length); /* Like us_socket_write, but additionally reports a fatal (non-would-block) * send error through *fatal_write_error so opted-in callers can fail the - * write instead of retrying forever. TLS sockets fall back to - * us_socket_write (their errors propagate through the SSL layer). */ + * write instead of retrying forever. A TLS socket reports the failed send of + * its ciphertext, or EPROTO for a failed SSL_write, once its handshake was reported. */ int us_socket_write_check_error(us_socket_r s, const char *data, int length, int *fatal_write_error); void us_socket_timeout(us_socket_r s, unsigned int seconds) nonnull_fn_decl; diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index d02121994624..576837653aca 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -380,7 +380,7 @@ struct us_socket_t *us_socket_pair(struct us_socket_group_t *group, unsigned cha * a paused socket: us_poll_change sets absolute flags, so including READABLE * unconditionally would silently undo us_socket_pause mid-backpressure and * deliver data the caller asked to defer. */ -static void us_internal_rearm_writable(struct us_socket_t *s) { +void us_internal_rearm_writable(struct us_socket_t *s) { us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_WRITABLE | ((s->flags.is_paused || s->read_eof) ? 0 : LIBUS_SOCKET_READABLE)); } @@ -570,8 +570,9 @@ int us_socket_write_check_error(struct us_socket_t *s, const char *data, int len return 0; } if (s->ssl) { - /* TLS writes have their own error propagation; keep the existing path. */ - return us_socket_write(s, data, length); + int written = us_internal_ssl_write(s, data, length); + if (fatal_write_error) *fatal_write_error = us_internal_ssl_fatal_write_error(s); + return written; } int written = bsd_send(us_poll_fd(&s->p), data, length); @@ -619,7 +620,7 @@ int us_socket_raw_writev(struct us_socket_t *s, const struct us_iovec_t *iov, in return written < 0 ? 0 : (int)written; } -int us_socket_raw_write(struct us_socket_t *s, const char *data, int length) { +int us_internal_socket_raw_write(struct us_socket_t *s, const char *data, int length, int *fatal_send_error) { /* Bypass-TLS path: openssl.c uses this to flush close_notify *after* * SSL_shutdown() has marked the SSL layer shut down, so checking * us_socket_is_shut_down() here would deadlock the alert in userspace. @@ -629,14 +630,24 @@ int us_socket_raw_write(struct us_socket_t *s, const char *data, int length) { } int written = bsd_send(us_poll_fd(&s->p), data, length); + int fatal = 0; + if (written >= 0) { + s->unclassified_send_failures = 0; + } else if (fatal_send_error) { + fatal = *fatal_send_error = us_internal_classify_failed_send(s); + } if (written != length) { s->flags.last_write_failed = 1; - us_internal_rearm_writable(s); + if (!fatal) us_internal_rearm_writable(s); } return written < 0 ? 0 : written; } +int us_socket_raw_write(struct us_socket_t *s, const char *data, int length) { + return us_internal_socket_raw_write(s, data, length, NULL); +} + #if !defined(_WIN32) /* Send a message with data and an attached file descriptor, for use in IPC. Returns the number of bytes written. If that number is less than the length, the file descriptor was not sent. */ @@ -841,8 +852,9 @@ void us_socket_pause(struct us_socket_t *s) { if (s->flags.is_paused) return; // closed cannot be paused because it is already closed if (us_socket_is_closed(s)) return; - // we are readable and writable so we can just pause readable side - us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_WRITABLE); + // we are readable and writable so we can just pause readable side; after our FIN nothing new waits for writable + us_poll_change(&s->p, s->group->loop, + us_internal_socket_can_raw_write(s) ? LIBUS_SOCKET_WRITABLE : us_poll_events(&s->p) & LIBUS_SOCKET_WRITABLE); s->flags.is_paused = 1; } @@ -852,7 +864,7 @@ void us_socket_resume(struct us_socket_t *s) { // closed cannot be resumed if (us_socket_is_closed(s)) return; - int events = s->read_eof ? 0 : LIBUS_SOCKET_READABLE; + int events = (s->read_eof ? 0 : LIBUS_SOCKET_READABLE) | (us_poll_events(&s->p) & LIBUS_SOCKET_WRITABLE); if (!us_socket_is_shut_down(s)) { // still writable: a FIN of ours would have left the socket read-only events |= LIBUS_SOCKET_WRITABLE; diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 68183d47f35e..2bbb5eabf317 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -592,7 +592,7 @@ export const setSocketOptions: setSocketOptionsFn = $newRustFunction( /** * The syscalls instrumented in bsd.c, plus non-syscall hooks whose failure * paths are otherwise unreachable without injection ("ssl_loop_buffer", - * "poll_start", "session_buffer"; see fault_inject.h for the per-hook + * "poll_start", "session_buffer", "ssl_write"; see fault_inject.h for the per-hook * description). Arming anything else is rejected. */ export type SocketFaultSyscall = @@ -605,7 +605,8 @@ export type SocketFaultSyscall = | "accept" | "ssl_loop_buffer" | "poll_start" - | "session_buffer"; + | "session_buffer" + | "ssl_write"; export type SocketFaultRule = { syscall: SocketFaultSyscall; diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index eb060cc0399a..3ecb769831be 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -3341,7 +3341,8 @@ impl NativeSocketWrite for &TLSSocket { fn write_maybe_corked(&mut self, buf: &[u8]) -> i32 { // Forward to the inherent NewSocket::write_maybe_corked (R-2: now // takes `&self`). UFCS to avoid resolving back to this trait impl. - TLSSocket::write_maybe_corked(*self, buf) + // No fatal latch: its close would drop what the peer sent before; the TLS layer closes after reading it. + TLSSocket::write_maybe_corked(*self, buf).max(-1) } } impl NativeSocketWrite for &TCPSocket { diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 576fa2f83f02..34b2aa65e849 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -2508,7 +2508,8 @@ impl NewSocket { Ok( match this.write_or_end::(global, args.mut_(), false) { WriteResult::Fail => JSValue::ZERO, - WriteResult::Success { wrote, .. } => JSValue::js_number_from_int32(wrote), + // The errno of a fatal send (< -1) is for node:net; this API documents -1. + WriteResult::Success { wrote, .. } => JSValue::js_number_from_int32(wrote.max(-1)), }, ) } @@ -2668,8 +2669,7 @@ impl NewSocket { // The raw [raw, tls] upgrade twin shares the TLS half's us_socket_t // (`s->ssl` is set) but must write raw bytes: write_check_error would - // route it through the SSL-encrypting us_socket_write, and its fatal - // signal is never set for TLS sockets anyway. + // route it through the SSL-encrypting us_socket_write. if flags.contains(Flags::BYPASS_TLS) { let res = self.do_socket_write(buffer); let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); @@ -3167,8 +3167,7 @@ impl NewSocket { // the initial write does: once the peer is gone the kernel rejects // every retry (EPIPE/ECONNRESET), and treating that as would-block // kept this buffer parked forever (the FIN-terminated-response hang). - // BYPASS_TLS twins keep the raw write path; TLS errors propagate - // through the SSL layer. + // BYPASS_TLS twins keep the raw write path. let res: i32 = if self.flags.get().contains(Flags::BYPASS_TLS) { self.do_socket_write(self.buffered_data_for_node_net.get().slice()) } else { @@ -3313,7 +3312,7 @@ impl NewSocket { if wrote >= 0 && usize::try_from(wrote).expect("int cast") == total { let _ = this.internal_flush(); } - JSValue::js_number(wrote as f64) + JSValue::js_number_from_int32(wrote.max(-1)) } }; Ok(result) @@ -5210,11 +5209,13 @@ pub(crate) mod testing_apis { fi::POLL_START } else if syscall_str.eq_ascii(b"session_buffer") { fi::SESSION_BUFFER + } else if syscall_str.eq_ascii(b"ssl_write") { + fi::SSL_WRITE } else { // socket/close/shutdown have enum slots but no bsd.c hooks; // accepting them would arm rules that can never fire. return Err(global.throw(format_args!( - "rule.syscall must be one of: recv, send, writev, sendmsg, recvmsg, connect, accept, ssl_loop_buffer, poll_start, session_buffer" + "rule.syscall must be one of: recv, send, writev, sendmsg, recvmsg, connect, accept, ssl_loop_buffer, poll_start, session_buffer, ssl_write" ))); }; diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 5272edc476f5..4aafd42201c7 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -482,6 +482,8 @@ pub mod fault_inject { /// Not a syscall: the JS `Buffer` allocated for a TLS session/keylog /// payload in the `on_session`/`on_keylog` dispatch. pub const SESSION_BUFFER: c_int = 12; + /// Not a syscall: one `SSL_write` call in `us_internal_ssl_writev`. + pub const SSL_WRITE: c_int = 13; pub const ACTION_NONE: c_int = 0; pub const ACTION_ERRNO: c_int = 1; diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 6ab5036a70d0..1b89ba33042e 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -246,9 +246,8 @@ impl NewSocketHandler { // ── state queries ─────────────────────────────────────────────────────── - /// Raw-TCP write that also reports a fatal send error as the positive - /// errno of the failed `send()` (0 = none); non-Connected and TLS-wrapped - /// sockets fall back to the plain write (no fatal signal). + /// Write that also reports a fatal write error as a positive errno (0 = + /// none); non-Connected sockets fall back to the plain write (no fatal signal). pub fn write_check_error(&self, data: &[u8]) -> (i32, i32) { on_socket!(self.socket; connected s => s.write_check_error(data), diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index b5473269f524..d119948b62e4 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -5210,6 +5210,38 @@ it("a paused socket with a backpressured write still closes when its peer resets expect(error?.code).toBe("ECONNRESET"); }); +it("a tls socket that pauses after shutdown() still reads the peer's reply once resumed", async () => { + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + allowHalfOpen: true, + socket: { + data() {}, + end: socket => void socket.end("reply"), + }, + }); + let received = ""; + const closed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + allowHalfOpen: true, + socket: { + handshake(socket) { + socket.shutdown(); + socket.pause(); + setImmediate(() => socket.resume()); + }, + data: (_socket, chunk) => void (received += chunk), + close: (_socket, error) => closed.resolve(error), + connectError: (_socket, error) => closed.reject(error), + }, + }); + expect({ error: await closed.promise, received }).toEqual({ error: undefined, received: "reply" }); +}); + // The graceful close keeps the fd until it has read the peer's close_notify or FIN. describe.concurrent("tls socket that is paused when its graceful close starts", () => { const ENDINGS = { @@ -5621,3 +5653,107 @@ it("setSession() after the handshake started is ignored on every Bun socket door expect(JSON.parse(stdout)).toEqual(expected); expect(exitCode).toBe(0); }); + +// The peer resets the connection while the client's loop is blocked, so the kernel rejects the send() of its write(). +describe.concurrent("write() that is the first to observe the peer's reset returns -1", () => { + it.each([["tcp"], ["tls"]])("%s", async kind => { + using dir = tempDir("socket-write-sees-reset", {}); + const resetDoneFile = join(String(dir), "reset-done"); + + const opened = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: kind === "tls" ? { key: tls.key, cert: tls.cert } : undefined, + socket: { + open(s) { + if (kind === "tcp") { + s.write("hello"); + opened.resolve(s); + } + }, + handshake(s) { + s.write("hello"); + opened.resolve(s); + }, + data() {}, + error() {}, + close() {}, + }, + }); + + const script = ` + const fs = require("node:fs"); + const writes = []; + let received = ""; + let reported = false; + function report(closed) { + if (reported) return; + reported = true; + fs.writeSync(1, JSON.stringify({ writes, received, closed }) + "\\n"); + process.exit(0); + } + // Report whatever happened if close() never runs. + setTimeout(report, 20000, false); + Bun.connect({ + hostname: "127.0.0.1", + port: Number(process.env.PEER_PORT), + tls: process.env.PEER_KIND === "tls" ? { rejectUnauthorized: false } : undefined, + socket: { + data(socket, chunk) { + received += chunk; + if (writes.length) return; + // Block the loop until the parent has reset the connection. + // Nothing is polled in between, so write() meets the reset first. + fs.writeSync(1, "busy\\n"); + const cell = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 30000; + while (!fs.existsSync(process.env.RESET_DONE_FILE) && Date.now() < deadline) { + Atomics.wait(cell, 0, 0, 5); + } + writes.push(socket.write("ping"), socket.write("ping")); + }, + error() {}, + close() { + report(true); + }, + }, + }); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, PEER_PORT: String(server.port), PEER_KIND: kind, RESET_DONE_FILE: resetDoneFile }, + stdout: "pipe", + stderr: "pipe", + }); + const stderrText = proc.stderr.text(); + let stdout = ""; + let reset = false; + for await (const chunk of proc.stdout) { + stdout += Buffer.from(chunk).toString(); + if (!reset && stdout.includes("busy\n")) { + reset = true; + // terminate() closes the fd with SO_LINGER 0, so the RST is on the wire + // when it returns, behind the bytes the client has yet to read. + (await opened.promise).write(" and bye"); + (await opened.promise).terminate(); + await Bun.write(resetDoneFile, ""); + } + } + const [stderr, exitCode] = await Promise.all([stderrText, proc.exited]); + const lines = stdout.trim().split("\n"); + // Debug builds may write benign diagnostics to stderr, so it is only shown + // when the fixture failed. + expect({ + reset, + result: JSON.parse(lines[lines.length - 1]), + failureDetail: exitCode === 0 ? "" : stderr, + }).toEqual({ + reset: true, + result: { writes: [-1, -1], received: "hello and bye", closed: true }, + failureDetail: "", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/util/socket-fault-injection.test.ts b/test/js/bun/util/socket-fault-injection.test.ts index 1d1698ad405f..662725bcc631 100644 --- a/test/js/bun/util/socket-fault-injection.test.ts +++ b/test/js/bun/util/socket-fault-injection.test.ts @@ -24,7 +24,7 @@ describe.skipIf(skip)("socketFaultInjection control surface", () => { // syscall used to succeed silently and never fire. ssl_loop_buffer is an // allocation, so it has no byte count either. test("set() rejects 'short' for syscalls that cannot clamp a byte count", () => { - for (const syscall of ["sendmsg", "recvmsg", "connect", "accept", "ssl_loop_buffer"] as const) { + for (const syscall of ["sendmsg", "recvmsg", "connect", "accept", "ssl_loop_buffer", "ssl_write"] as const) { expect(() => fault.set({ syscall, action: "short", bytes: 1 })).toThrow(/only supported for syscall/); } expect(fault.set({ syscall: "recv", action: "short", bytes: 1 })).toBe(true); @@ -35,7 +35,7 @@ describe.skipIf(skip)("socketFaultInjection control surface", () => { // A zero return only means something for the data syscalls (EOF on the read // side, backpressure on the write side); connect's wrapper returns errno. test("set() rejects 'zero' for syscalls with no zero-return semantics", () => { - for (const syscall of ["connect", "accept", "ssl_loop_buffer"] as const) { + for (const syscall of ["connect", "accept", "ssl_loop_buffer", "ssl_write"] as const) { expect(() => fault.set({ syscall, action: "zero" })).toThrow(/only supported for syscall/); } for (const syscall of ["recv", "send", "writev", "sendmsg", "recvmsg"] as const) { @@ -47,6 +47,11 @@ describe.skipIf(skip)("socketFaultInjection control surface", () => { expect(fault.set({ syscall: "ssl_loop_buffer", action: "errno", errno: "ENOMEM" })).toBe(true); }); + test("set() accepts ssl_write with action 'errno', with or without an fd", () => { + expect(fault.set({ syscall: "ssl_write", action: "errno", errno: "EINVAL" })).toBe(true); + expect(fault.set({ syscall: "ssl_write", action: "errno", errno: "EINVAL", fd: 3 })).toBe(true); + }); + // ssl_loop_buffer's hook is an allocation, so it checks with fd = -1; a rule // pinned to a descriptor would arm and then silently never fire. test("set() rejects 'fd' for ssl_loop_buffer, which has no descriptor", () => { diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 472ab6f93cde..705bfb3980e6 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -1,5 +1,5 @@ import { jscDescribe } from "bun:jsc"; -import { bunEnv, bunExe, isASAN, isCI, isDebug, nodeExe } from "harness"; +import { bunEnv, bunExe, isASAN, isCI, isDebug, nodeExe, tempDir } from "harness"; import { createTest } from "node-harness"; import { AsyncLocalStorage } from "node:async_hooks"; import dc from "node:diagnostics_channel"; @@ -6706,3 +6706,110 @@ it("originSet is undefined on a destroyed TLS session that never read it", async server.close(); } }); + +it("a session over TLS whose peer reset is first seen by a write ends without a hang", async () => { + // How the reset looks differs by kernel, in Node as well: a clean end on Linux (the failed send() + // consumed the socket's error), ECONNRESET on Windows. Never EBADF. + using dir = tempDir("h2-tls-write-sees-reset", {}); + const resetDoneFile = path.join(String(dir), "reset-done"); + const fixture = ` + const http2 = require("node:http2"); + const fs = require("node:fs"); + const state = { requested: false, sawResponse: false, streamClosed: false, sessionClosed: false, errors: [] }; + let reported = false; + function report() { + if (reported) return; + reported = true; + fs.writeSync(1, JSON.stringify(state) + "\\n"); + process.exit(0); + } + function closed() { + if (state.streamClosed && state.sessionClosed) report(); + } + const session = http2.connect("https://127.0.0.1:" + process.env.H2_PEER_PORT, { rejectUnauthorized: false }); + session.on("error", err => state.errors.push(err.code)); + session.on("close", () => { + state.sessionClosed = true; + closed(); + }); + session.on("remoteSettings", () => { + // Block the loop until the parent has reset the connection. Nothing is + // polled in between, so the request's write meets the reset first. + fs.writeSync(1, "busy\\n"); + const cell = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 30000; + while (!fs.existsSync(process.env.RESET_DONE_FILE) && Date.now() < deadline) { + Atomics.wait(cell, 0, 0, 5); + } + const req = session.request({ ":path": "/" }); + state.requested = true; + req.on("response", () => (state.sawResponse = true)); + req.on("error", err => state.errors.push(err.code)); + req.on("close", () => { + state.streamClosed = true; + closed(); + }); + req.resume(); + req.end(); + }); + // Report whatever happened if the teardown never completes. + setTimeout(report, 20000); + `; + const frame = (type, flags) => Buffer.from([0, 0, 0, type, flags, 0, 0, 0, 0]); + // The raw TCP socket under the server's TLSSocket: resetting it sends the RST + // with no TLS alert in front of it. + let raw = null; + const server = tls.createServer({ ...TLS_CERT, ALPNProtocols: ["h2"] }, socket => { + socket.on("error", () => {}); + socket.write(frame(4, 0)); // empty SETTINGS + socket.once("data", () => socket.write(frame(4, 1))); // ACK the client's SETTINGS + }); + server.on("connection", socket => { + raw = socket; + socket.on("error", () => {}); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { ...bunEnv, H2_PEER_PORT: String(server.address().port), RESET_DONE_FILE: resetDoneFile }, + stdout: "pipe", + stderr: "pipe", + }); + const stderrText = proc.stderr.text(); + let stdout = ""; + let reset = false; + for await (const chunk of proc.stdout) { + stdout += Buffer.from(chunk).toString(); + if (!reset && stdout.includes("busy\n")) { + reset = true; + const rawClosed = new Promise(resolve => raw.once("close", resolve)); + raw.resetAndDestroy(); + await rawClosed; + fs.writeFileSync(resetDoneFile, ""); + } + } + const [stderr, exitCode] = await Promise.all([stderrText, proc.exited]); + const lines = stdout.trim().split("\n"); + const state = JSON.parse(lines[lines.length - 1]); + // Debug builds may write benign diagnostics to stderr, so it is only shown + // when the fixture failed. + expect({ + reset, + ...state, + errors: state.errors.filter(code => code !== "ECONNRESET" && code !== "EPIPE"), + failureDetail: exitCode === 0 ? "" : stderr, + }).toEqual({ + reset: true, + requested: true, + sawResponse: false, + streamClosed: true, + sessionClosed: true, + errors: [], + failureDetail: "", + }); + expect(exitCode).toBe(0); + } finally { + server.close(); + } +}); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 99783efde213..91b48137b6b2 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -5377,3 +5377,123 @@ describe("a dialed socket paused before its handshake completed", () => { await clientClosed; }); }); + +// The peer resets the connection while the client's loop is blocked. On Linux the failed send() +// consumes the socket's pending error, so it is the only report of the reset. +describe.concurrent("a write that is the first to observe the peer's reset fails with a write error", () => { + it.each([ + ["tls.connect({ port })", "0"], + ["tls.connect({ socket })", "1"], + ])("%s", async (_name, overSocket) => { + using dir = tempDir("tls-write-sees-reset", {}); + const resetDoneFile = join(String(dir), "reset-done"); + + // The raw TCP socket under the server's TLSSocket: resetting it sends the + // RST with no TLS alert in front of it. + let raw: net.Socket | undefined; + const server = tls.createServer({ key: COMMON_CERT_.key, cert: COMMON_CERT_.cert }, socket => { + socket.on("error", () => {}); + socket.write("hello"); + }); + server.on("connection", socket => { + raw = socket; + socket.on("error", () => {}); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const script = ` + const tlsMod = require("node:tls"); + const netMod = require("node:net"); + const fs = require("node:fs"); + const port = Number(process.env.TLS_PEER_PORT); + const events = []; + let write; + let reported = false; + function report(hadError) { + if (reported) return; + reported = true; + fs.writeSync(1, JSON.stringify({ write, events, hadError }) + "\\n"); + process.exit(0); + } + // Report whatever happened if 'close' never fires. + setTimeout(report, 20000, "no close"); + function run(socket) { + socket.on("error", function onError(err) { + events.push("error:" + err.code + ":" + err.syscall); + }); + socket.on("end", function onEnd() { + events.push("end"); + }); + socket.on("close", report); + socket.once("data", function onData() { + // Block the loop until the parent has reset the connection. Nothing + // is polled in between, so the write below meets the reset first. + fs.writeSync(1, "busy\\n"); + const cell = new Int32Array(new SharedArrayBuffer(4)); + const deadline = Date.now() + 30000; + while (!fs.existsSync(process.env.RESET_DONE_FILE) && Date.now() < deadline) { + Atomics.wait(cell, 0, 0, 5); + } + socket.write("ping", function onWritten(err) { + write = err ? { code: err.code, syscall: err.syscall } : "ok"; + }); + }); + } + if (process.env.TLS_OVER_SOCKET === "1") { + const transport = netMod.connect({ port, host: "127.0.0.1" }, function onConnect() { + run(tlsMod.connect({ socket: transport, rejectUnauthorized: false })); + }); + } else { + run(tlsMod.connect({ port, host: "127.0.0.1", rejectUnauthorized: false })); + } + `; + + try { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { + ...bunEnv, + TLS_PEER_PORT: String((server.address() as AddressInfo).port), + TLS_OVER_SOCKET: overSocket, + RESET_DONE_FILE: resetDoneFile, + }, + stdout: "pipe", + stderr: "pipe", + }); + const stderrText = proc.stderr.text(); + let stdout = ""; + let reset = false; + for await (const chunk of proc.stdout) { + stdout += Buffer.from(chunk).toString(); + if (!reset && stdout.includes("busy\n")) { + reset = true; + const rawClosed = once(raw!, "close"); + raw!.resetAndDestroy(); + await rawClosed; + await Bun.write(resetDoneFile, ""); + } + } + const [stderr, exitCode] = await Promise.all([stderrText, proc.exited]); + const lines = stdout.trim().split("\n"); + // Debug builds may write benign diagnostics to stderr, so it is only + // shown when the fixture failed. + expect({ + reset, + result: JSON.parse(lines[lines.length - 1]), + failureDetail: exitCode === 0 ? "" : stderr, + }).toEqual({ + reset: true, + result: { + write: { code: expect.stringMatching(/^(ECONNRESET|EPIPE)$/), syscall: "write" }, + events: [expect.stringMatching(/^error:(ECONNRESET|EPIPE):write$/)], + hadError: true, + }, + failureDetail: "", + }); + expect(exitCode).toBe(0); + } finally { + server.close(); + } + }); +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 9f00c3acea12..a222c77ddd0a 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -990,6 +990,61 @@ it("keeps socket.authorized false when a client without a certificate resumes a ]); }); +it("completes a write that fails inside the TLS layer after the handshake instead of leaving the socket open", async () => { + // The server's first write carries its NewSessionTickets, each with the client's ~48 KiB chain: + // BoringSSL fails that SSL_write until #38120 lands, Node sends it. + const fixtures = join(import.meta.dir, "fixtures"); + const agent1Key = readFileSync(join(fixtures, "agent1-key.pem"), "utf8"); + const agent1Cert = readFileSync(join(fixtures, "agent1-cert.pem"), "utf8"); + const ca1 = readFileSync(join(fixtures, "ca1-cert.pem"), "utf8"); + // agent1 still verifies against ca1; the 52 extra copies of ca1 only pad + // the chain the client sends. + const paddedChain = agent1Cert + Buffer.alloc(ca1.length * 52, ca1).toString(); + + const serverEvents: string[] = []; + const serverSocketDone = Promise.withResolvers<{ writeError: NodeJS.ErrnoException | null | undefined }>(); + await using server = createServer( + { key: agent1Key, cert: agent1Cert, ca: [ca1], requestCert: true, minVersion: "TLSv1.3", maxVersion: "TLSv1.3" }, + socket => { + socket.on("error", err => serverEvents.push(`error:${(err as NodeJS.ErrnoException).code}`)); + socket.on("close", hadError => serverEvents.push(`close:${hadError}`)); + socket.on("data", () => { + socket.write("pong", writeError => { + if (writeError) { + socket.once("close", () => serverSocketDone.resolve({ writeError })); + } else { + serverSocketDone.resolve({ writeError }); + } + }); + }); + }, + ); + server.on("tlsClientError", serverSocketDone.reject); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + + const client = connect({ port, host: "127.0.0.1", key: agent1Key, cert: paddedChain, rejectUnauthorized: false }); + client.on("error", () => {}); + const clientClosed = new Promise(resolve => client.on("close", () => resolve())); + const clientReply = new Promise(resolve => client.once("data", chunk => resolve(String(chunk)))); + await once(client, "secureConnect"); + client.write("ping"); + + const { writeError } = await serverSocketDone.promise; + if (writeError) { + expect({ code: writeError.code, syscall: writeError.syscall, serverEvents }).toEqual({ + code: "EPROTO", + syscall: "write", + serverEvents: ["error:EPROTO", "close:true"], + }); + } else { + expect(await clientReply).toBe("pong"); + client.end(); + } + // In both outcomes the connection itself is gone afterwards. + await clientClosed; +}); + it("keeps req.socket.authorized false for an unverified client after the server socket shuts down", async () => { type Verdict = [boolean | undefined, string | null | undefined]; const { promise, resolve, reject } = Promise.withResolvers<{ before: Verdict; after: Verdict }>(); diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 47bbc0b3711d..88fa8cef511e 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -106,6 +106,10 @@ const RESET_CLOSE = { destroyed: true, }; +function fdOf(socket: tls.TLSSocket): number { + return (socket as any)._handle.fd; +} + function collect(socket: tls.TLSSocket) { const chunks: Buffer[] = []; socket.on("data", c => chunks.push(c)); @@ -122,6 +126,129 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { expect({ events: client.events, errors: client.errors, destroyed: p.client.destroyed }).toEqual(RESET_CLOSE); }); + test.each(["client", "server"] as const)("send → ECONNRESET fails the %s's write", async side => { + using p = await connectedTLSPair(); + const writer = side === "client" ? p.client : p.serverSock; + const seen = observe(writer); + const written = Promise.withResolvers(); + fault.set({ syscall: "send", action: "errno", errno: "ECONNRESET", repeat: -1, fd: fdOf(writer) }); + writer.write("hello", written.resolve); + const WRITE_RESET = { name: "Error", code: "ECONNRESET", syscall: "write", message: "write ECONNRESET" }; + expect(await written.promise).toMatchObject(WRITE_RESET); + await seen.closed; + expect({ events: seen.events, errors: seen.errors }).toEqual({ + events: ["error", "close(hadError=true)"], + errors: [WRITE_RESET], + }); + }); + + test("send → an errno that names no dead peer is retried 32 times in a row, then closes", async () => { + let received!: () => Buffer; + using p = await connectedTLSPair(s => (received = collect(s))); + const client = observe(p.client); + for (const round of ["a", "b"]) { + // A send that goes through starts the count again, or round "b" would close. + fault.set({ syscall: "send", action: "errno", errno: "EINVAL", repeat: 32, fd: fdOf(p.client) }); + p.client.write(round); + await once(p.serverSock, "data"); + } + fault.set({ syscall: "send", action: "errno", errno: "EINVAL", repeat: 33, fd: fdOf(p.client) }); + p.client.write("c"); + await client.closed; + expect({ received: received().toString(), events: client.events, codes: client.errors.map(e => e.code) }).toEqual({ + received: "ab", + events: ["error", "close(hadError=true)"], + codes: ["EINVAL"], + }); + }); + + // The write was reported as done with its tail still in userspace, so only the close can carry the error. + test.each([ + ["closes with the error", false], + ["first delivers what the peer sent before", true], + ])("send → EPIPE for ciphertext that waits for the writable event %s", async (_name, peerWroteFirst) => { + using p = await connectedTLSPair(); + const client = observe(p.client); + const received = collect(p.client); + if (peerWroteFirst) { + p.client.pause(); + await new Promise(resolve => p.serverSock.write("late", () => resolve())); + } + fault.set({ syscall: "send", action: "short", bytes: 10, fd: fdOf(p.client) }); + p.client.write("hello"); + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1, fd: fdOf(p.client) }); + p.client.resume(); + await client.closed; + expect({ received: received().toString(), events: client.events, codes: client.errors.map(e => e.code) }).toEqual( + peerWroteFirst + ? { received: "late", events: CLEAN_CLOSE, codes: [] } + : { received: "", events: ["error", "close(hadError=true)"], codes: ["EPIPE"] }, + ); + }); + + test("send → EPIPE for the ClientHello fails connect with an error (no hang)", async () => { + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1 }); + const c = connect(); + const client = observe(c); + await client.closed; + fault.clear(); + expect({ events: client.events, errors: client.errors, destroyed: c.destroyed }).toEqual(RESET_CLOSE); + }); + + // https://github.com/oven-sh/bun/issues/24845: the kernel rejects every send() and reports nothing on the read side. + describe("send → EPIPE while the peer stays silent", () => { + let silent: tls.Server; + let silentPort: number; + beforeAll(async () => { + silent = tls.createServer({ key: certs.key, cert: certs.cert, allowHalfOpen: true }, s => + s.on("error", () => {}), + ); + await once(silent.listen(0, "127.0.0.1"), "listening"); + silentPort = (silent.address() as AddressInfo).port; + }); + afterAll(() => silent.close()); + + test("Bun.connect: write() returns -1 and the socket closes with an error", async () => { + const closed = Promise.withResolvers(); + const writes: number[] = []; + const accepted = once(silent, "secureConnection") as Promise<[tls.TLSSocket]>; + await Bun.connect({ + hostname: "127.0.0.1", + port: silentPort, + tls: { ca: certs.cert }, + socket: { + async handshake(socket) { + await accepted; + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1 }); + writes.push(socket.write("ping"), socket.write("ping")); + }, + data() {}, + close: (_socket, error) => closed.resolve(error), + connectError: (_socket, error) => closed.reject(error), + }, + }); + expect(await closed.promise).toMatchObject({ code: "ECONNRESET" }); + expect(writes).toEqual([-1, -1]); + (await accepted)[0].destroy(); + }); + + test("fetch rejects when a chunk of its request body cannot be sent", async () => { + const accepted = once(silent, "secureConnection") as Promise<[tls.TLSSocket]>; + let body!: ReadableStreamDefaultController; + const response = fetch(`https://127.0.0.1:${silentPort}/`, { + method: "POST", + body: new ReadableStream({ start: controller => void (body = controller) }), + tls: { ca: certs.cert, checkServerIdentity: () => undefined }, + }); + const [serverSock] = await accepted; + await once(serverSock, "data"); + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1 }); + body.enqueue(Buffer.alloc(1024, "x")); + expect(await response.catch(error => error.code)).toBe("ECONNRESET"); + serverSock.destroy(); + }); + }); + test("recv → short reads (1 byte) still decrypt complete payload", async () => { using p = await connectedTLSPair(); const client = observe(p.client); @@ -209,6 +336,121 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { }); }); +// "ssl_write" fails one SSL_write call the way a record-layer failure does (#38120). +describe.skipIf(skip)("fatal SSL_write after the handshake", () => { + const WRITE_EPROTO = { name: "Error", code: "EPROTO", syscall: "write", message: "write EPROTO" }; + const EPROTO_CLOSE = { events: ["error", "close(hadError=true)"], errors: [WRITE_EPROTO] }; + + test.each([ + ["the first record of a write", 5, 0, 0], + ["a later record of a write", 3 * 16384, 1, 16384], + ])("node:tls fails the write with EPROTO when %s fails", async (_name, size, after, delivered) => { + using p = await connectedTLSPair(); + const client = observe(p.client); + const received = collect(p.client); + const serverSide = observe(p.serverSock); + const written = Promise.withResolvers(); + fault.set({ syscall: "ssl_write", action: "errno", errno: "EINVAL", after, fd: fdOf(p.serverSock) }); + p.serverSock.write(Buffer.alloc(size, "w"), written.resolve); + expect(await written.promise).toMatchObject(WRITE_EPROTO); + await Promise.all([serverSide.closed, client.closed]); + expect({ events: serverSide.events, errors: serverSide.errors, delivered: received().length }).toEqual({ + ...EPROTO_CLOSE, + delivered, + }); + }); + + test("node:tls fails a write whose tail is retried from the writable event", async () => { + using p = await connectedTLSPair(); + const serverSide = observe(p.serverSock); + // The client does not read, so a write ends up partial and its tail is held natively. + const chunk = Buffer.alloc(1024 * 1024, "z"); + let partialWrite: Promise; + do { + const { promise, resolve } = Promise.withResolvers(); + partialWrite = promise; + p.serverSock.write(chunk, resolve); + await new Promise(resolve => process.nextTick(resolve)); + } while (p.serverSock.writableLength === 0); + fault.set({ syscall: "ssl_write", action: "errno", errno: "EINVAL", repeat: -1, fd: fdOf(p.serverSock) }); + p.client.resume(); + await serverSide.closed; + expect(await partialWrite).toMatchObject({ code: "EPROTO", syscall: "write" }); + expect(serverSide.events).toEqual(EPROTO_CLOSE.events); + }); + + test("Bun.serve closes the connection instead of holding the response forever", async () => { + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: { key: certs.key, cert: certs.cert }, + fetch(req) { + // repeat: -1 also covers a client that retries on a fresh connection. + if (req.url.endsWith("/fail")) + fault.set({ syscall: "ssl_write", action: "errno", errno: "EINVAL", repeat: -1 }); + return new Response("hello"); + }, + }); + // The second request reuses the connection, which no longer polls for writable. + expect(await (await fetch(server.url, { tls: { ca: certs.cert } })).text()).toBe("hello"); + await expect(fetch(`${server.url}fail`, { tls: { ca: certs.cert } })).rejects.toThrow(); + }); + + test("Bun.serve closes the connection when its writable handler issues the failing write", async () => { + const body = Buffer.alloc(16 * 1024 * 1024, "y"); + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: { key: certs.key, cert: certs.cert }, + fetch: () => new Response(body), + }); + const response = await fetch(server.url, { tls: { ca: certs.cert } }); + const reader = response.body!.getReader(); + let received = (await reader.read()).value!.byteLength; + // The body is far larger than the socket buffers, so the writable handler writes the rest. + fault.set({ syscall: "ssl_write", action: "errno", errno: "EINVAL", repeat: -1 }); + const outcome = await (async () => { + for (;;) { + const { done, value } = await reader.read(); + if (done) return "ended"; + received += value.byteLength; + } + })().catch(() => "errored"); + fault.clear(); + expect({ outcome, truncated: received < body.byteLength }).toEqual({ outcome: "errored", truncated: true }); + }); + + test.each([ + ["flowing", false, false], + ["paused", true, false], + ["paused, then resumed", true, true], + ])("Bun.connect: write() returns -1 and the socket closes (%s)", async (_name, paused, resumed) => { + const closed = Promise.withResolvers(); + const writes: number[] = []; + const accepted = once(server, "secureConnection"); + await Bun.connect({ + hostname: "127.0.0.1", + port, + tls: { ca: certs.cert }, + socket: { + async handshake(socket) { + if (paused) socket.pause(); + // By then the socket no longer polls for writable. + await accepted; + fault.set({ syscall: "ssl_write", action: "errno", errno: "EINVAL" }); + writes.push(socket.write("ping"), socket.write("ping")); + if (resumed) socket.resume(); + }, + data() {}, + close: () => closed.resolve(), + connectError: (_socket, error) => closed.reject(error), + }, + }); + await Promise.all([closed.promise, accepted]); + expect(writes).toEqual([-1, -1]); + }); +}); + describe.skipIf(skip)("node:tls close_notify / shutdown under faults", () => { test("paused client resumed after the peer's end()+destroySoon() receives every byte", async () => { // The peer's data AND its FIN are already queued when the client resumes diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index f41b4b63f102..bf132838b35a 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1651,6 +1651,33 @@ describe.concurrent("fetch-tls", () => { } }); + it("reads a response that the server sent before it reset the upload", async () => { + const server = tls.createServer(validTls, socket => { + socket.on("error", () => {}); + socket.once("data", () => { + // destroy() with the body unread sends an RST, which fails the client's next send(). + socket.pause(); + socket.write("HTTP/1.1 413 Payload Too Large\r\nConnection: close\r\nContent-Length: 3\r\n\r\nbig", () => + socket.destroy(), + ); + }); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const body = Buffer.alloc(16 * 1024 * 1024, "x"); + for (let i = 0; i < 5; i++) { + const response = await fetch(`https://127.0.0.1:${(server.address() as net.AddressInfo).port}/`, { + method: "POST", + body, + tls: { rejectUnauthorized: false }, + }); + expect({ status: response.status, text: await response.text() }).toEqual({ status: 413, text: "big" }); + } + } finally { + server.close(); + } + }); + it("fetch should ignore NODE_EXTRA_CA_CERTS if it's contains invalid cert", async () => { using server = Bun.serve({ port: 0, From 16c1ab1d96d108369af67cfd17fa8bb1a0d8e78c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:55:21 +0000 Subject: [PATCH 080/174] usockets(tls): drop the "no spill progress after the peer's EOF means the peer is gone" guess It stood in for the errno that us_socket_raw_write folded to 0. The failed send() is classified now, so a drain that makes no progress is plain backpressure. --- packages/bun-usockets/src/crypto/openssl.c | 50 ++++++---------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 95432d32491d..5e9c02757c94 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2427,43 +2427,19 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = ssl_set_loop_data(s); s = ssl_close_if_fatal(s); if (!s || ssl_gone(s)) return s; - { - /* Ciphertext from a partial batch flush goes out before anything else; - * while it is pending nothing new may be written for this socket. */ - unsigned int spill_off_before = loop_ssl_data->ssl_spill_off; - loop_ssl_data->ssl_send_error = 0; - if (!ssl_drain_spill(loop_ssl_data, s)) { - /* A writable event that moves zero spill bytes after the peer's - * readable side has already ended means the peer is gone (send() - * hit EPIPE/ECONNRESET, folded to 0 by us_socket_raw_write) and - * this spill will never drain. Returning here would spin the - * re-armed writable poll on kqueue, since EPOLLERR is not delivered - * there. Mark the SSL fatal so us_internal_ssl_write returns 0 (the - * uWS layer's flushed==0-after-FIN guard, or hasFullyDrained() when - * nothing is buffered, then closes the connection on this dispatch) - * and dispatch directly, bypassing the is_shut_down gate below that - * ssl_fatal_error would otherwise trip. On the libuv backend zero - * progress does not prove death (a stale SEND completion can run - * after this loop turn refilled the buffer), so confirm with the - * kernel before declaring the spill undrainable. */ - if (s->ssl_end_delivered && loop_ssl_data->ssl_spill_off == spill_off_before && - us_socket_stalled_write_means_peer_gone(s)) { - ssl_release_spill(s->group->loop, s); - s->ssl_fatal_error = 1; - return ssl_close_if_fatal(us_dispatch_writable(s)); - } - return s; - } - if (loop_ssl_data->ssl_send_error) return ssl_close_after_rejected_send(s, loop_ssl_data->ssl_send_error); - if (s->ssl_shutdown_after_spill) { - s->ssl_shutdown_after_spill = 0; - us_internal_ssl_shutdown(s); - if (ssl_gone(s)) return s; - } - if (s->ssl_close_after_spill) { - s->ssl_close_after_spill = 0; - return us_internal_ssl_close(s, s->ssl_pending_close_code, NULL); - } + /* Ciphertext from a partial batch flush goes out before anything else; + * while it is pending nothing new may be written for this socket. */ + loop_ssl_data->ssl_send_error = 0; + if (!ssl_drain_spill(loop_ssl_data, s)) return s; + if (loop_ssl_data->ssl_send_error) return ssl_close_after_rejected_send(s, loop_ssl_data->ssl_send_error); + if (s->ssl_shutdown_after_spill) { + s->ssl_shutdown_after_spill = 0; + us_internal_ssl_shutdown(s); + if (ssl_gone(s)) return s; + } + if (s->ssl_close_after_spill) { + s->ssl_close_after_spill = 0; + return us_internal_ssl_close(s, s->ssl_pending_close_code, NULL); } ssl_update_handshake(s, 0); if (ssl_gone(s)) return s; From 74ab34859b7250201f25143cf361bee806eed29b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:01:32 +0000 Subject: [PATCH 081/174] Bun.serve: the idle timeout closes a TLS connection whatever its client does HttpContext::onTimeout says "force close" but ran the graceful close, which on TLS waits for unsent ciphertext to drain and then for the peer's close_notify, with no deadline: a client that stops reading or never answers kept its fd and its pending request for good. The timeout still sends the close_notify when it can, then closes without waiting. --- packages/bun-uws/src/HttpContext.h | 4 +++- test/js/bun/http/serve.test.ts | 31 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 9c041bdf611c..177b9ea558fb 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -1137,7 +1137,9 @@ struct HttpContext { if (httpResponseData->onTimeout) { httpResponseData->onTimeout((HttpResponse *)s, httpResponseData->userData); } - return asyncSocket->close(); + asyncSocket->close(); + /* On TLS that close waits for unsent ciphertext, then for the peer's close_notify. */ + return us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, nullptr); } /* Static .rodata vtables — one per (SSL, IsNodeHttp), shared by every diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 8dfdaa2ef59e..9a6bd96dbcb0 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3314,6 +3314,37 @@ it.concurrent("we should always send date", async () => { } }); +it.concurrent.each([ + ["never answers the close_notify", 1], + ["stopped reading the response", 64 * 1024 * 1024], +])( + "idle timeout closes a tls connection whose client %s", + async (_name, size) => { + const aborted = Promise.withResolvers(); + using server = Bun.serve({ + port: 0, + tls, + idleTimeout: 1, + async fetch(req) { + req.signal.addEventListener("abort", () => aborted.resolve()); + if (size > 1) return new Response(Buffer.alloc(size, "x")); + await aborted.promise; + return new Response(); + }, + }); + const client = nodeTls.connect({ port: server.port, host: "127.0.0.1", ca: tls.cert, allowHalfOpen: true }); + client.on("error", aborted.reject); + try { + await once(client, "secureConnect"); + client.write("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await aborted.promise; + } finally { + client.destroy(); + } + }, + 15_000, +); + it.concurrent( "should allow use of custom timeout", async () => { From 2a0a98ae57bc936a36ec3154666df4c3292d70aa Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:02:23 +0000 Subject: [PATCH 082/174] test(tls): a failing write of the outer socket inside fetch's proxy tunnel rejects cleanly ProxyTunnel drives its SSLWrapper with no ref guard, which is safe only while the outer socket's write never closes synchronously. Both failing writes (send() and SSL_write) close at the writable event. --- test/js/node/tls/tls-syscall-fault.test.ts | 71 +++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 88fa8cef511e..4c8dcda17c7c 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -3,7 +3,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test import { bunEnv, bunExe, tls as certs, isWindows } from "harness"; import { once } from "node:events"; import https from "node:https"; -import type { AddressInfo } from "node:net"; +import net, { type AddressInfo } from "node:net"; import { join } from "node:path"; import tls from "node:tls"; @@ -247,6 +247,75 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { expect(await response.catch(error => error.code)).toBe("ECONNRESET"); serverSock.destroy(); }); + + // The failing send() of the outer socket runs inside the tunnel's own TLS pass. + describe("fetch through a TLS proxy", () => { + let proxy: tls.Server; + let beforeEstablished: () => void = () => {}; + const sockets = new Set(); + beforeAll(async () => { + proxy = tls.createServer({ key: certs.key, cert: certs.cert, allowHalfOpen: true }, client => { + client.once("data", head => { + const [host, port] = head.toString().split(" ")[1].split(":"); + const upstream = net.connect({ host, port: Number(port), allowHalfOpen: true }, () => { + beforeEstablished(); + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + client.pipe(upstream, { end: false }); + upstream.pipe(client, { end: false }); + }); + for (const socket of [client, upstream]) sockets.add(socket.on("error", () => {})); + }); + }); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + }); + afterEach(() => { + beforeEstablished = () => {}; + for (const socket of sockets) socket.destroy(); + sockets.clear(); + }); + afterAll(() => proxy.close()); + const throughProxy = () => ({ + proxy: `https://127.0.0.1:${(proxy.address() as AddressInfo).port}`, + tls: { rejectUnauthorized: false }, + }); + + test.each(["send", "ssl_write"] as const)("rejects when %s fails for the tunnel's ClientHello", async syscall => { + // The one call that still goes through is for the proxy's own answer. + beforeEstablished = () => fault.set({ syscall, action: "errno", errno: "EPIPE", after: 1, repeat: -1 }); + const response = fetch(`https://127.0.0.1:${silentPort}/`, throughProxy()); + expect(await response.catch(error => error.code)).toBe("ECONNRESET"); + }); + + test.each(["send", "ssl_write"] as const)( + "rejects when %s fails for a chunk of its request body", + async syscall => { + const firstChunk = Promise.withResolvers(); + await using origin = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: { key: certs.key, cert: certs.cert }, + async fetch(req) { + await req.body!.getReader().read(); + firstChunk.resolve(); + await once(req.signal, "abort"); + return new Response(); + }, + }); + let body!: ReadableStreamDefaultController; + const response = fetch(origin.url, { + method: "POST", + body: new ReadableStream({ start: controller => void (body = controller) }), + ...throughProxy(), + }); + body.enqueue(Buffer.from("first")); + await firstChunk.promise; + fault.set({ syscall, action: "errno", errno: "EPIPE", repeat: -1 }); + body.enqueue(Buffer.alloc(1024, "x")); + expect(await response.catch(error => error.code)).toBe("ECONNRESET"); + fault.clear(); + }, + ); + }); }); test("recv → short reads (1 byte) still decrypt complete payload", async () => { From 990225ef1bcbd777556b3448707bc890269b8017 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:11:21 +0000 Subject: [PATCH 083/174] types: Socket.shutdown()'s JSDoc had the two halves reversed (#44192) shutdown() sends the FIN and shutdown(true) shuts down the read side, as it always has. The tests pin what each does for tcp/tls, connected/accepted, allowHalfOpen. --- packages/bun-types/bun.d.ts | 15 ++-- test/js/bun/net/socket.test.ts | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 1f5401b7fef8..51a24ab295ff 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6857,20 +6857,19 @@ declare module "bun" { terminate(): void; /** - * Shuts down the write-half or both halves of the connection. - * This allows the socket to enter a half-closed state where it can still receive data - * but can no longer send data (`halfClose = true`), or close both read and write - * (`halfClose = false`, similar to `end()` but potentially more immediate depending on OS). + * Shuts down one half of the connection. + * With no argument, sends a FIN: the socket can still receive data but can no longer send it. + * With `true`, stops receiving data. * Calls the `shutdown(2)` syscall internally. * - * @param halfClose If `true`, only shuts down the write side (allows receiving). If `false` or omitted, shuts down both read and write. Defaults to `false`. + * @param halfClose If `true`, shuts down the read side. If `false` or omitted, shuts down the write side. Defaults to `false`. * @example * ```ts * // Stop sending data, but allow receiving - * socket.shutdown(true); - * - * // Shutdown both reading and writing * socket.shutdown(); + * + * // Stop receiving data + * socket.shutdown(true); * ``` */ shutdown(halfClose?: boolean): void; diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index d119948b62e4..b15921fc40db 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -4497,6 +4497,135 @@ Reo= }); }); +// What a TLS socket does on Windows is not measured. +describe("shutdown(true)", () => { + // A connection that the peer answers: the events that were due before it + // have run by the time it resolves. + async function pendingEventsDone() { + const server = net.createServer(socket => socket.end("x")); + await once(server.listen(0, "127.0.0.1"), "listening"); + const socket = net.connect((server.address() as net.AddressInfo).port, "127.0.0.1"); + await once(socket, "data"); + socket.destroy(); + server.close(); + } + + type Role = "connected" | "accepted"; + type Subject = { role: Role; allowHalfOpen: boolean; secure: boolean; handshakeDone: boolean }; + + // The subject calls shutdown(true), then writes "W1" and later "W2". + async function callsAfterShutdown({ role, allowHalfOpen, secure, handshakeDone }: Subject) { + const calls: string[] = []; + const closed = Promise.withResolvers(); + const shutDown = Promise.withResolvers(); + let peerGot = ""; + const peerGotAll = { W1: Promise.withResolvers(), W1W2: Promise.withResolvers() }; + const fail = (error: unknown) => { + shutDown.reject(error); + closed.reject(error); + peerGotAll.W1.reject(error); + peerGotAll.W1W2.reject(error); + }; + const shutdownAndWrite = (socket: Socket) => { + socket.shutdown(true); + calls.push(`write ${socket.write("W1")}`); + shutDown.resolve(socket); + }; + const subject = { + open(socket: Socket) { + if (!secure || !handshakeDone) shutdownAndWrite(socket); + }, + handshake(socket: Socket, success: boolean, error: NodeJS.ErrnoException | null) { + if (!handshakeDone) calls.push(`handshake ${success} ${error?.code}`); + else if (success) shutdownAndWrite(socket); + else fail(error ?? new Error("the handshake failed")); + }, + data() { + calls.push("data"); + }, + end() { + calls.push("end"); + }, + close() { + calls.push("close"); + closed.resolve(); + }, + error(_socket: Socket, error: Error) { + fail(error); + }, + }; + const peer = { + data(_socket: Socket, data: Buffer) { + peerGot += data; + if (peerGot === "W1") peerGotAll.W1.resolve(); + if (peerGot === "W1W2") peerGotAll.W1W2.resolve(); + }, + error(_socket: Socket, error: Error) { + fail(error); + }, + }; + const accepts = role === "accepted"; + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + allowHalfOpen: accepts ? allowHalfOpen : true, + ...(secure ? { tls } : {}), + socket: accepts ? subject : peer, + }); + using _client = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + allowHalfOpen: accepts ? true : allowHalfOpen, + ...(secure ? { tls: { rejectUnauthorized: false } } : {}), + socket: { ...(accepts ? peer : subject), connectError: (_socket, error) => fail(error) }, + }); + const socket = await shutDown.promise; + if (!handshakeDone) { + await closed.promise; + return { calls, peerGot }; + } + await peerGotAll.W1.promise; + await pendingEventsDone(); + const stayedOpen = !calls.includes("close"); + calls.push(`write ${socket.write("W2")}`); + if (stayedOpen) { + await peerGotAll.W1W2.promise; + socket.end(); + await closed.promise; + } + return { calls, peerGot }; + } + + for (const secure of [false, true]) { + for (const role of ["connected", "accepted"] as const) { + for (const allowHalfOpen of [false, true]) { + const socket = `${role === "accepted" ? "an accepted" : "a connected"}${secure ? " TLS" : ""} socket`; + + it.skipIf(secure && isWindows)(`${socket} with allowHalfOpen: ${allowHalfOpen}`, async () => { + expect(await callsAfterShutdown({ role, allowHalfOpen, secure, handshakeDone: true })).toEqual( + isWindows + ? { calls: ["write 2", "write 2", "close"], peerGot: "W1W2" } + : allowHalfOpen + ? { calls: ["write 2", "end", "write 2", "close"], peerGot: "W1W2" } + : { calls: ["write 2", "end", "close", "write -1"], peerGot: "W1" }, + ); + }); + + if (!secure) continue; + it.skipIf(isWindows)( + `${socket} with allowHalfOpen: ${allowHalfOpen}, before its handshake completes`, + async () => { + expect(await callsAfterShutdown({ role, allowHalfOpen, secure, handshakeDone: false })).toEqual({ + calls: ["write 0", "handshake false ECONNRESET", "close"], + peerGot: "", + }); + }, + ); + } + } + } +}); + // Linux-only: uses /proc/self/fd to find and close the connected socket's fd // so getsockname()/getpeername() fail with EBADF. it.skipIf(!isLinux)( From 82eae1c2324c377e23970f2c65454dba21054d17 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:03:32 +0000 Subject: [PATCH 084/174] node:tls: a handshake that did not complete is never an established session (#44422) onClientHandshake tore the socket down only for a failure that carried a TLS reason. Every other failed handshake went on as `_secureEstablished`, so over a Duplex a peer with a trusted chain and a bad key proof got `secureConnect` with `authorized === true`. A failed handshake now ends there: the TLS reason if there is one, the X509 error of a chain this client refuses, ECONNRESET otherwise, and nothing after the client's own end(). --- src/js/node/net.ts | 34 ++++--- .../tls/node-tls-duplex-end-verify.test.ts | 89 +++++++++++++++++++ 2 files changed, 105 insertions(+), 18 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 616980ea0360..e12bbbfc4395 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -577,24 +577,22 @@ function onClientHandshake(self, socket, success, verifyError) { } // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1081-L1090 if (self.destroyed) return; - // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1203-L1213 - if (!success && verifyError == null && self.writableFinished) { - self.secureConnecting = false; - return; - } - // `success` says whether the handshake completed. The chain's verdict and the name check are applied below. - const isProtocolFailure = - !success && - verifyError?.code != null && - (verifyError.code === "EPROTO" || /^ERR_(SSL|OSSL)_/.test(verifyError.code)); - if (isProtocolFailure) { - // Surface the OpenSSL reason instead of letting the close path report a - // generic disconnect. - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L480-L488 - self._hadError = true; - self._closeAfterHandlingError = true; - self.destroy(tlsHandshakeError(verifyError)); - return; + if (!success) { + const code = verifyError?.code; + if (code === "EPROTO" || /^ERR_(SSL|OSSL)_/.test(code)) { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L480-L488 + self._hadError = true; + self._closeAfterHandlingError = true; + self.destroy(tlsHandshakeError(verifyError)); + return; + } + // Only a chain that this client refuses goes on: it gets its X509 error below. + if (code == null || !self._rejectUnauthorized) { + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1203-L1213 + if (self.writableFinished) self.secureConnecting = false; + else onConnectEnd.$call(self); + return; + } } self._securePending = false; diff --git a/test/js/node/tls/node-tls-duplex-end-verify.test.ts b/test/js/node/tls/node-tls-duplex-end-verify.test.ts index eca7faa3f358..5f6af719986e 100644 --- a/test/js/node/tls/node-tls-duplex-end-verify.test.ts +++ b/test/js/node/tls/node-tls-duplex-end-verify.test.ts @@ -1143,3 +1143,92 @@ test("end() after a second connect() of the same socket sends no ClientHello", a server.close(); } }); + +// Records the events of `client` in `events` and calls `closed` at 'close'. A client that was let through, or that got +// data with no report of its handshake, is destroyed, so that it does not keep its test waiting. +function recordClient(client, events, closed) { + client.on("secureConnect", () => { + events.push(`secureConnect authorized=${client.authorized} authError=${client.authorizationError}`); + setImmediate(() => client.destroy()); + }); + client.on("data", data => { + events.push(`data ${data}`); + if (!events.some(event => event.startsWith("secureConnect"))) client.destroy(); + }); + client.on("error", err => events.push(`error ${err.code}`)); + client.on("close", () => { + events.push("close"); + closed(); + }); +} + +// A handshake that did not complete is never an established session. The client below wraps a Duplex, does not call +// end(), and its peer makes the handshake fail. `peer(otherEnd)` plays the peer on the other end of the Duplex, and +// can return a function that closes what it opened. Returns the ordered events of the client. +async function failedHandshakeOverDuplex(rejectUnauthorized, peer) { + const events = []; + const { promise, resolve } = Promise.withResolvers(); + const [transport, otherEnd] = duplexPair(); + transport.on("error", () => {}); + otherEnd.on("error", () => {}); + const close = await peer(otherEnd); + const client = tls.connect({ socket: transport, servername: "agent1", ca: serverCA, rejectUnauthorized }); + recordClient(client, events, resolve); + await promise; + close?.(); + return events; +} + +const HANDSHAKE = 0x16; +const SERVER_KEY_EXCHANGE = 12; + +// A TLS 1.2 server with a trusted certificate for "agent1". The relay in front of it changes the last byte of the +// signature in ServerKeyExchange, so the peer proves no possession of the key. The chain is already verified then. +async function trustedChainWithBadKeyProof(otherEnd) { + const server = tls.createServer({ key, cert, maxVersion: "TLSv1.2" }, socket => socket.on("error", () => {})); + server.on("tlsClientError", () => {}); + await new Promise(listening => server.listen(0, "127.0.0.1", listening)); + const upstream = net.connect(server.address().port, "127.0.0.1"); + upstream.on("error", () => {}); + otherEnd.on("data", chunk => upstream.write(chunk)); + eachRecord(upstream, record => { + if (record[0] === HANDSHAKE) { + // A record can carry several handshake messages: type (1 byte), length (3 bytes), body. + for (let at = 5; at + 4 <= record.length; ) { + const end = at + 4 + record.readUIntBE(at + 1, 3); + if (record[at] === SERVER_KEY_EXCHANGE && end <= record.length) record[end - 1] ^= 0xff; + at = end; + } + } + otherEnd.write(record); + }); + upstream.on("close", () => otherEnd.destroy()); + return () => { + upstream.destroy(); + server.close(); + }; +} + +for (const [failure, peer] of [ + [ + "a fatal alert", + otherEnd => + void otherEnd.once("data", () => otherEnd.write(Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x28]))), + ], + [ + "bytes that are not TLS", + otherEnd => void otherEnd.once("data", () => otherEnd.write("this is not a TLS record\n")), + ], + ["a trusted chain with a bad key proof", trustedChainWithBadKeyProof], +]) { + for (const rejectUnauthorized of [true, false]) { + test(`over a Duplex: ${failure} is an error, not a secureConnect, with rejectUnauthorized: ${rejectUnauthorized}`, async () => { + const events = await failedHandshakeOverDuplex(rejectUnauthorized, peer); + assert.match(events[0], /^error /, events.join(", ")); + assert.deepStrictEqual( + events.filter(event => !event.startsWith("error ")), + ["close"], + ); + }); + } +} From 960d3c20762700046839b38f9dd01d7f4546d538 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:12:03 +0000 Subject: [PATCH 085/174] tls: report why a handshake over a Duplex failed, and send our alert (#32929) SSLWrapper cleared the error queue and reported a failed SSL_do_handshake with the X509 verdict, so a fatal alert, bytes that are not TLS or an ALPN mismatch came out as UNABLE_TO_GET_ISSUER_CERT. It also never flushed the alert that BoringSSL queued, so the peer saw a bare end of stream. The wrapper now reports {EPROTO, oldest queued reason}, the report openssl.c gives, and writes the pending alert before it tells the owner. tlsHandshakeError moves to internal/tls so the http2 raw upgrade shares it, and builds ERR_SSL_ whatever library queued the reason, as Node does. --- src/boringssl_sys/boringssl.rs | 1 + src/js/internal/tls.ts | 33 +++++++ src/js/node/_http2_upgrade.ts | 2 +- src/js/node/net.ts | 38 +------- src/uws/lib.rs | 71 ++++++++++---- src/uws_sys/lib.rs | 7 +- .../js/node/http2/node-http2-upgrade.test.mts | 46 +++++++++ test/js/node/tls/node-tls-connect.test.ts | 96 ++++++++++++++++++- .../tls/node-tls-duplex-end-verify.test.ts | 59 +++++++++++- 9 files changed, 286 insertions(+), 67 deletions(-) diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index 8dd78f0040d3..9dfb069ab30a 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -987,6 +987,7 @@ unsafe extern "C" { // Thread-local error queue — no pointer args, no preconditions. pub safe fn ERR_clear_error(); pub safe fn ERR_get_error() -> u32; + pub safe fn ERR_peek_error() -> u32; pub safe fn ERR_peek_last_error() -> u32; pub fn ERR_error_string(packed_error: u32, buf: *mut c_char) -> *mut c_char; // `ERR_error_string_n` declared once in the crypto/err block above. diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index ec693aa1b548..26e92bef7b92 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -388,6 +388,38 @@ function nodeClientTlsToNative(options) { return tls && unsealPfxForNative(tls); } +/** + * Build the Error for a handshake that failed before completing. A fatal SSL + * protocol error (wrong version number, bad record, ...) carries the OpenSSL + * error string in `verifyError.reason`; everything else is the peer + * disconnecting mid-handshake, which Node reports as ECONNRESET. + */ +function tlsHandshakeError(verifyError) { + const verifyErrorCode = verifyError ? verifyError.code : undefined; + if (verifyErrorCode && verifyErrorCode !== "ECONNRESET") { + const reason = verifyError.reason || verifyError.message || "TLS handshake failed"; + const err = new Error(reason) as Error & { + code?: string; + library?: string; + function?: string; + reason?: string; + }; + // "error:0a00042e:SSL routines:OPENSSL_internal:TLSV1_ALERT_PROTOCOL_VERSION". ERR_SSL_ whatever the library: + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L876-L891 + const match = /^error:[0-9a-f]+:([^:]*):([^:]*):(.+)$/.exec(reason); + if (match) { + err.library = match[1]; + err.function = match[2]; + err.reason = match[3]; + err.code = `ERR_SSL_${match[3]}`; + } else { + err.code = verifyErrorCode; + } + return err; + } + return new (require("internal/shared").ConnResetException)("socket hang up"); +} + export { SSL_OP_CIPHER_SERVER_PREFERENCE, nodeClientTlsToNative, @@ -395,6 +427,7 @@ export { processPfxOptions, secureProtocolToVersionRange, throwOnInvalidTLSArray, + tlsHandshakeError, tlsStringToProtocolVersion, unsealPfxForNative, validateSecureContextOptions, diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index 121e63e6d343..b4ec4eb18f59 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -204,7 +204,7 @@ function socketHandshake( const ctx = tlsSocket._ctx; if (!success) { - const err = verifyError || new Error("TLS handshake failed"); + const err = require("internal/tls").tlsHandshakeError(verifyError); ctx.server.emit("tlsClientError", err, tlsSocket); tlsSocket.destroy(err); return; diff --git a/src/js/node/net.ts b/src/js/node/net.ts index e12bbbfc4395..6077abb6dd47 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -583,7 +583,7 @@ function onClientHandshake(self, socket, success, verifyError) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L480-L488 self._hadError = true; self._closeAfterHandlingError = true; - self.destroy(tlsHandshakeError(verifyError)); + self.destroy(require("internal/tls").tlsHandshakeError(verifyError)); return; } // Only a chain that this client refuses goes on: it gets its X509 error below. @@ -696,40 +696,6 @@ function onConnectEnd() { } } -/** - * Build the Error for a handshake that failed before completing. A fatal SSL - * protocol error (wrong version number, bad record, ...) carries the OpenSSL - * error string in `verifyError.reason`; everything else is the peer - * disconnecting mid-handshake, which Node reports as ECONNRESET. - */ -function tlsHandshakeError(verifyError) { - const verifyErrorCode = verifyError ? verifyError.code : undefined; - if (verifyErrorCode && verifyErrorCode !== "ECONNRESET") { - const reason = verifyError.reason || verifyError.message || "TLS handshake failed"; - const err = new Error(reason) as Error & { - code?: string; - library?: string; - function?: string; - reason?: string; - }; - // A fatal SSL-library error carries the full OpenSSL error string - // ("error:0a00042e:SSL routines:OPENSSL_internal:TLSV1_ALERT_PROTOCOL_VERSION"). - // Decompose it into Node's library/function/reason properties and the - // ERR_SSL_ code the way ThrowCryptoError does. - const match = /^error:[0-9a-f]+:SSL routines:([^:]*):(.+)$/.exec(reason); - if (match) { - err.library = "SSL routines"; - err.function = match[1]; - err.reason = match[2]; - err.code = `ERR_SSL_${match[2]}`; - } else { - err.code = verifyErrorCode; - } - return err; - } - return new ConnResetException("socket hang up"); -} - // Node reports a throwing 'data' listener as uncaughtException and keeps reading. function pushDataToSocket(self, socket, buffer) { // TLS took over the fd; the wrapped socket reads nothing: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 @@ -1261,7 +1227,7 @@ const ServerHandlers = { err = self[kALPNError]; self[kALPNError] = undefined; } else { - err = tlsHandshakeError(verifyError); + err = require("internal/tls").tlsHandshakeError(verifyError); } self.servername = socket.getServername(); self._hadError = true; diff --git a/src/uws/lib.rs b/src/uws/lib.rs index b1552520cfbf..390a619fc363 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -162,15 +162,16 @@ pub mod ssl_wrapper { mod boring_sys { pub(super) use bun_boringssl::c::{ BIO_ctrl_pending, BIO_free, BIO_new, BIO_read, BIO_reset, BIO_s_mem, - BIO_set_mem_eof_return, BIO_write, ERR_clear_error, OwnedSslCtx, SSL, SSL_CTX, - SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, - SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_SESSION, - SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, SSL_VERIFY_NONE, SSL_VERIFY_PEER, - SSL_do_handshake, SSL_free, SSL_get_error, SSL_get_rbio, SSL_get_shutdown, - SSL_get_verify_result, SSL_get_wbio, SSL_is_init_finished, SSL_new, SSL_pending, - SSL_read, SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, - SSL_set_renegotiate_mode, SSL_set_session_id_context, SSL_set_verify, SSL_shutdown, - SSL_write, X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never, + BIO_set_mem_eof_return, BIO_write, ERR_clear_error, ERR_error_string_n, ERR_peek_error, + OwnedSslCtx, SSL, SSL_CTX, SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, + SSL_ERROR_WANT_RENEGOTIATE, SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, + SSL_RECEIVED_SHUTDOWN, SSL_SESSION, SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, + SSL_VERIFY_NONE, SSL_VERIFY_PEER, SSL_do_handshake, SSL_free, SSL_get_error, + SSL_get_rbio, SSL_get_shutdown, SSL_get_verify_result, SSL_get_wbio, + SSL_is_init_finished, SSL_new, SSL_pending, SSL_read, SSL_renegotiate, + SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, SSL_set_renegotiate_mode, + SSL_set_session_id_context, SSL_set_verify, SSL_shutdown, SSL_write, X509_STORE_CTX, + ssl_renegotiate_explicit, ssl_renegotiate_never, }; } @@ -218,6 +219,9 @@ pub mod ssl_wrapper { /// See [`MAX_RENEGOTIATIONS`]. const MAX_RENEGOTIATION_WINDOW: core::time::Duration = core::time::Duration::from_secs(600); + /// `US_SSL_FATAL_ERROR_REASON_MAX` in openssl.c. + const FATAL_ERROR_REASON_MAX: usize = 256; + /// What BoringSSL's callbacks read and write. The `SSL` points at it: see `us_ssl_set_wrapper`. struct CallbackState { inline_reject: Cell, @@ -388,13 +392,13 @@ pub mod ssl_wrapper { /// What `trigger_handshake_callback` reports. #[derive(Clone, Copy)] - enum HandshakeOutcome { + enum HandshakeOutcome<'a> { /// A handshake or a renegotiation finished. Established, /// `set_inline_reject` stopped the handshake on the peer's chain. InlineRejected, - /// `SSL_do_handshake` failed. - HandshakeError, + /// `SSL_do_handshake` failed. Carries the queued reason of a fatal failure. + HandshakeError(Option<&'a core::ffi::CStr>), /// Closed before the handshake finished, or a renegotiation was refused. Aborted, } @@ -902,15 +906,24 @@ pub mod ssl_wrapper { } } - fn trigger_handshake_callback(&self, outcome: HandshakeOutcome) { + fn trigger_handshake_callback(&self, outcome: HandshakeOutcome<'_>) { if self.flags.closed_notified() { return; } let (success, result) = match outcome { HandshakeOutcome::Established => (true, self.verify_error()), - HandshakeOutcome::InlineRejected | HandshakeOutcome::HandshakeError => { + HandshakeOutcome::InlineRejected | HandshakeOutcome::HandshakeError(None) => { (false, self.verify_error()) } + // The callback below copies `reason` before this borrow ends. + HandshakeOutcome::HandshakeError(Some(reason)) => ( + false, + us_bun_verify_error_t { + error_no: -71, + code: c"EPROTO".as_ptr(), + reason: reason.as_ptr(), + }, + ), // node:tls reads a failure with no error after end() as its own close. HandshakeOutcome::Aborted if self.is_shutdown() => { (false, us_bun_verify_error_t::default()) @@ -960,6 +973,20 @@ pub mod ssl_wrapper { unsafe { us_ssl_socket_verify_error_from_ssl(ssl.as_ptr()) } } + /// Formats the oldest queued error, like `ssl_park_fatal_reason` in openssl.c, and clears the queue. + fn take_queued_error(buf: &mut [u8; FATAL_ERROR_REASON_MAX]) -> Option<&core::ffi::CStr> { + let packed = boring_sys::ERR_peek_error(); + boring_sys::ERR_clear_error(); + if packed == 0 { + return None; + } + // SAFETY: buf is a valid mutable buffer for `buf.len()` bytes. + unsafe { + boring_sys::ERR_error_string_n(packed, buf.as_mut_ptr().cast(), buf.len()); + } + core::ffi::CStr::from_bytes_until_nul(buf).ok() + } + /// Update the handshake state. Returns true if we can call handle_reading. fn update_handshake_state(&self) -> bool { if self.flags.closed_notified() { @@ -997,6 +1024,8 @@ pub mod ssl_wrapper { return true; } + // The queue is per thread: what another SSL left there is not this handshake's reason. + boring_sys::ERR_clear_error(); // SAFETY: ssl is a live SSL*. let result = unsafe { boring_sys::SSL_do_handshake(ssl.as_ptr()) }; @@ -1029,7 +1058,10 @@ pub mod ssl_wrapper { if result <= 0 { // SAFETY: ssl is still valid. let err = unsafe { boring_sys::SSL_get_error(ssl.as_ptr(), result) }; - boring_sys::ERR_clear_error(); + let is_fatal = + err == boring_sys::SSL_ERROR_SSL || err == boring_sys::SSL_ERROR_SYSCALL; + let mut reason_buf = [0u8; FATAL_ERROR_REASON_MAX]; + let fatal_reason = Self::take_queued_error(&mut reason_buf).filter(|_| is_fatal); if err == boring_sys::SSL_ERROR_ZERO_RETURN { // Remotely-Initiated Shutdown // See: https://www.openssl.org/docs/manmaster/man3/SSL_shutdown.html @@ -1042,14 +1074,13 @@ pub mod ssl_wrapper { // as far as I know these are the only errors we want to handle if err != boring_sys::SSL_ERROR_WANT_READ && err != boring_sys::SSL_ERROR_WANT_WRITE { - // clear per thread error queue if it may contain something - self.flags.set_fatal_error( - err == boring_sys::SSL_ERROR_SSL || err == boring_sys::SSL_ERROR_SYSCALL, - ); + self.flags.set_fatal_error(is_fatal); self.flags .set_handshake_state(HandshakeState::HandshakeCompleted); - self.trigger_handshake_callback(HandshakeOutcome::HandshakeError); + // Our fatal alert leaves before the owner tears the transport down. + self.handle_writing(&mut IoBuffer::uninit()); + self.trigger_handshake_callback(HandshakeOutcome::HandshakeError(fatal_reason)); if self.flags.fatal_error() { self.trigger_close_callback(); diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 4aafd42201c7..71a21d858b33 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -63,7 +63,7 @@ impl Default for us_bun_verify_error_t { impl us_bun_verify_error_t { /// Borrow the BoringSSL verify-error `code` as a `CStr`, or `None` if null. /// - /// uSockets populates `code`/`reason` from BoringSSL's static error-string + /// uSockets populates `code` from BoringSSL's static error-string /// table (`X509_verify_cert_error_string` and friends), so the pointee is /// `'static` in practice; the borrow is conservatively tied to `&self` so /// the accessor is sound even if a future caller heap-allocates the struct. @@ -79,14 +79,13 @@ impl us_bun_verify_error_t { } /// Borrow the BoringSSL verify-error `reason` as a `CStr`, or `None` if null. - /// See [`Self::code`] for the safety argument. + /// An `EPROTO` reason is a stack buffer of the reporter: copy it before the handshake callback returns. #[inline] pub fn reason(&self) -> Option<&core::ffi::CStr> { if self.reason.is_null() { return None; } - // SAFETY: same invariant as `code()` — non-null `reason` is a valid - // NUL-terminated C string from BoringSSL's static error table. + // SAFETY: a non-null `reason` is a valid NUL-terminated C string for the duration of the report. Some(unsafe { core::ffi::CStr::from_ptr(self.reason) }) } diff --git a/test/js/node/http2/node-http2-upgrade.test.mts b/test/js/node/http2/node-http2-upgrade.test.mts index 966dbb14cce9..1faa88d67a73 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -587,6 +587,52 @@ describe("HTTP/2 upgrade — the accepted socket is released when the server sid }); }); +describe("HTTP/2 upgrade — failed handshake", () => { + type Outcome = { event: string; code?: string; library?: string; message?: string }; + + async function handshakeOutcome(onConnect: (client: net.Socket) => void): Promise { + const { promise, resolve } = Promise.withResolvers(); + const h2Server = http2.createSecureServer(TLS); + h2Server.on("error", () => {}); + h2Server.on("tlsClientError", (err: NodeJS.ErrnoException & { library?: string }) => + resolve({ event: "tlsClientError", code: err.code, library: err.library, message: err.message }), + ); + h2Server.on("secureConnection", () => resolve({ event: "secureConnection" })); + + const netServer = net.createServer(socket => { + socket.on("error", () => {}); + h2Server.emit("connection", socket); + }); + await once(netServer.listen(0, "127.0.0.1"), "listening"); + + const client = net.connect((netServer.address() as net.AddressInfo).port, "127.0.0.1", () => onConnect(client)); + client.on("error", () => {}); + client.on("close", () => resolve({ event: "close" })); + try { + return await promise; + } finally { + client.destroy(); + netServer.close(); + } + } + + test("a first record that is not TLS is reported as ERR_SSL_*", async () => { + const { event, code, library } = await handshakeOutcome(client => client.write("not a TLS record\r\n\r\n")); + assert.deepStrictEqual( + { event, code, library }, + { event: "tlsClientError", code: "ERR_SSL_WRONG_VERSION_NUMBER", library: "SSL routines" }, + ); + }); + + test("a client that hangs up before the handshake is reported as ECONNRESET", async () => { + const { event, code, message } = await handshakeOutcome(client => client.end()); + assert.deepStrictEqual( + { event, code, message }, + { event: "tlsClientError", code: "ECONNRESET", message: "socket hang up" }, + ); + }); +}); + if (typeof Bun !== "undefined") { describe("Node.js compatibility", () => { test("tests should run on node.js", async () => { diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 91b48137b6b2..a8a0504f3e54 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -1,5 +1,5 @@ import { heapStats } from "bun:jsc"; -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, jest } from "bun:test"; import { once } from "events"; import { readFileSync, writeFileSync } from "fs"; import { @@ -249,6 +249,75 @@ for (const { name, connect } of tests) { }); const COMMON_CERT: { cert: string; key: string; passphrase?: string } = { ...COMMON_CERT_ }; + it("surfaces the fatal TLS alert when ALPN has no overlap", async () => { + await using server = tls.createServer({ + key: COMMON_CERT.key, + cert: COMMON_CERT.cert, + ALPNProtocols: ["h2"], + }); + server.on("tlsClientError", () => {}); + server.on("secureConnection", s => { + s.on("error", () => {}); + s.end(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + + const identityCheck = jest.fn(tls.checkServerIdentity); + const result = await new Promise<{ kind: string; code?: string; library?: string }>(resolve => { + const socket = connect({ + host: "127.0.0.1", + port, + servername: "localhost", + ca: COMMON_CERT.cert, + ALPNProtocols: ["xyz"], + checkServerIdentity: identityCheck, + }); + socket.on("secureConnect", () => { + resolve({ kind: "secureConnect" }); + socket.destroy(); + }); + socket.on("error", (err: NodeJS.ErrnoException & { library?: string }) => { + resolve({ kind: "error", code: err.code, library: err.library }); + }); + }); + + expect(result).toEqual({ + kind: "error", + code: "ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL", + library: "SSL routines", + }); + expect(identityCheck).not.toHaveBeenCalled(); + }); + + it("emits error (not secureConnect) on a handshake_failure alert with rejectUnauthorized: false", async () => { + await using server = net.createServer(s => { + s.resume(); + // TLS alert record: level fatal (2), description handshake_failure (40). + s.end(Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x02, 0x28])); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + + const result = await new Promise<{ kind: string; code?: string }>(resolve => { + const socket = connect({ host: "127.0.0.1", port, servername: "localhost", rejectUnauthorized: false }); + socket.on("secureConnect", () => { + resolve({ kind: "secureConnect" }); + socket.destroy(); + }); + socket.on("error", (err: NodeJS.ErrnoException) => { + resolve({ kind: "error", code: err.code }); + }); + }); + + expect(result).toEqual({ + kind: "error", + code: process.features.openssl_is_boringssl + ? "ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE" + : "ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE", + }); + }); + it("Bun.serve() should work with tls and Bun.file()", async () => { using server = Bun.serve({ port: 0, @@ -1099,6 +1168,31 @@ describe("a TLS socket over a Duplex transport reads it with backpressure", () = }); }); +it("a server TLSSocket over a duplex reports a first record that is not TLS as ERR_SSL_WRONG_VERSION_NUMBER", async () => { + const transport = new Duplex({ + read() {}, + write(_chunk, _encoding, callback) { + callback(); + }, + }); + const server = new TLSSocket(transport, { isServer: true, secureContext: tls.createSecureContext(COMMON_CERT_) }); + const { promise, resolve } = Promise.withResolvers<{ event: string; code?: string; library?: string }>(); + server.on("error", (err: NodeJS.ErrnoException & { library?: string }) => + resolve({ event: "error", code: err.code, library: err.library }), + ); + server.on("secure", () => resolve({ event: "secure" })); + server.on("close", () => resolve({ event: "close" })); + transport.push("not a TLS record\r\n\r\n"); + + const outcome = await promise; + transport.destroy(); + expect(outcome).toEqual({ + event: "error", + code: "ERR_SSL_WRONG_VERSION_NUMBER", + library: "SSL routines", + }); +}); + it("the last 'data' event fires before the close_notify reply is written to a duplex transport (tls.connect({ socket }))", async () => { // The peer's last application data and its close_notify reach the engine in // one chunk. The engine used to answer the close_notify before it emitted diff --git a/test/js/node/tls/node-tls-duplex-end-verify.test.ts b/test/js/node/tls/node-tls-duplex-end-verify.test.ts index 5f6af719986e..f15713f2673b 100644 --- a/test/js/node/tls/node-tls-duplex-end-verify.test.ts +++ b/test/js/node/tls/node-tls-duplex-end-verify.test.ts @@ -1224,11 +1224,60 @@ for (const [failure, peer] of [ for (const rejectUnauthorized of [true, false]) { test(`over a Duplex: ${failure} is an error, not a secureConnect, with rejectUnauthorized: ${rejectUnauthorized}`, async () => { const events = await failedHandshakeOverDuplex(rejectUnauthorized, peer); - assert.match(events[0], /^error /, events.join(", ")); - assert.deepStrictEqual( - events.filter(event => !event.startsWith("error ")), - ["close"], - ); + assert.match(events[0], /^error ERR_SSL_/, events.join(", ")); + assert.deepStrictEqual(events.slice(1), ["close"]); }); } } + +// A TLS 1.2 server with a trusted certificate that requires a client certificate. The client has none, so the server's +// alert arrives after the client verified the chain. +async function serverThatRequiresClientCert(otherEnd) { + const server = tls.createServer({ key, cert, ca: clientCA, requestCert: true, maxVersion: "TLSv1.2" }); + server.on("tlsClientError", () => {}); + await new Promise(listening => server.listen(0, "127.0.0.1", listening)); + const upstream = net.connect(server.address().port, "127.0.0.1"); + upstream.on("error", () => {}); + otherEnd.pipe(upstream).pipe(otherEnd); + return () => { + upstream.destroy(); + server.close(); + }; +} + +for (const rejectUnauthorized of [true, false]) { + test(`over a Duplex: an alert behind a verified chain is reported by its name, with rejectUnauthorized: ${rejectUnauthorized}`, async () => { + const events = await failedHandshakeOverDuplex(rejectUnauthorized, serverThatRequiresClientCert); + assert.match(events[0], /^error ERR_SSL_.*ALERT_HANDSHAKE_FAILURE$/, events.join(", ")); + assert.deepStrictEqual(events.slice(1), ["close"]); + }); +} + +const ALERT = 0x15; +const FATAL = 2; + +test("over a Duplex: a client that refuses the server's key proof sends its fatal alert", async () => { + const sent = []; + await failedHandshakeOverDuplex(true, async otherEnd => { + eachRecord(otherEnd, record => sent.push([record[0], record[5]])); + return trustedChainWithBadKeyProof(otherEnd); + }); + assert.deepStrictEqual(sent, [ + [HANDSHAKE, 1], + [ALERT, FATAL], + ]); +}); + +test("over a Duplex: a server that shares no cipher with the client sends its fatal alert", async () => { + const [transport, otherEnd] = duplexPair(); + const server = new tls.TLSSocket(transport, { + isServer: true, + secureContext: tls.createSecureContext({ key, cert }), + }); + const serverFailed = new Promise(resolve => server.once("error", resolve)); + const client = tls.connect({ socket: otherEnd, maxVersion: "TLSv1.2", ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256" }); + const clientFailed = new Promise(resolve => client.once("error", resolve)); + assert.strictEqual((await serverFailed).code, "ERR_SSL_NO_SHARED_CIPHER"); + assert.match((await clientFailed).code, /^ERR_SSL_.*ALERT_HANDSHAKE_FAILURE$/); + server.destroy(); +}); From 9a1360cf3543d80b6bd2384e97df504e21c1018a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:20:43 +0000 Subject: [PATCH 086/174] tls: a close_notify that ends the first handshake is a peer that left (#44516, #44517) A peer that answers the ClientHello with close_notify and keeps the stream open: - SSLWrapper (Duplex, named pipe, proxy tunnel) ran no callback at all for SSL_ERROR_ZERO_RETURN in a first handshake, so nothing was ever reported; - openssl.c drives the handshake from SSL_read, which turns SSL_do_handshake() == 0 into SSL_R_SSL_HANDSHAKE_FAILURE, so the fd engine reported ERR_SSL_SSL_HANDSHAKE_FAILURE where Node reports ECONNRESET. Both now give the report of a FIN mid-handshake, (0, ECONNRESET), then close. The fetch tunnel maps that report like a direct connection does. --- packages/bun-usockets/src/crypto/openssl.c | 7 + src/http/ProxyTunnel.rs | 15 +- src/http/lib.rs | 2 +- src/uws/lib.rs | 15 +- src/uws_sys/lib.rs | 13 ++ test/js/bun/http/proxy.test.ts | 40 ++++ .../js/node/http2/node-http2-upgrade.test.mts | 9 + test/js/node/tls/node-tls-connect.test.ts | 75 ++++++++ .../tls/node-tls-duplex-end-verify.test.ts | 172 ++++++++++++++++++ test/js/node/tls/node-tls-namedpipes.test.ts | 58 ++++++ test/js/web/fetch/fetch.tls.test.ts | 14 ++ test/js/web/websocket/websocket-proxy.test.ts | 24 ++- 12 files changed, 434 insertions(+), 10 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 5e9c02757c94..28250389a0e8 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2617,6 +2617,13 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i return s; } + /* SSL_read's name for a close_notify in place of a handshake message: https://github.com/oven-sh/bun/issues/44517 */ + if (s->ssl_handshake_state == HANDSHAKE_PENDING && + ERR_peek_error() == ERR_PACK(ERR_LIB_SSL, SSL_R_SSL_HANDSHAKE_FAILURE)) { + ERR_clear_error(); + ssl_trigger_handshake_econnreset(s); + if (ssl_gone(s)) return NULL; + } if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) { ssl_park_fatal_reason(s); } diff --git a/src/http/ProxyTunnel.rs b/src/http/ProxyTunnel.rs index de48ac6b2e81..b80a0407ae68 100644 --- a/src/http/ProxyTunnel.rs +++ b/src/http/ProxyTunnel.rs @@ -426,14 +426,15 @@ fn on_handshake( // SAFETY: the live SSL handle of the tunnel's wrapper; the chain is borrowed. !unsafe { bun_boringssl_sys::SSL_get_peer_cert_chain(ssl.as_ptr()) }.is_null() }); - if this.flags.reject_unauthorized && peer_sent_certificate && handshake_error.error_no > 0 { - let err = crate::get_cert_error_from_no(handshake_error.error_no); - // SAFETY: `this` dead (NLL); reenter via raw ptr. - ProxyTunnel::close_from_callback(proxy_nn, err); - return; - } + let err = if handshake_error.error_no <= 0 + || (this.flags.reject_unauthorized && peer_sent_certificate) + { + crate::handshake_failure(handshake_error.error_no) + } else { + crate::Error::TLSHandshakeFailed + }; // SAFETY: `this` dead (NLL); reenter via raw ptr. - ProxyTunnel::close_from_callback(proxy_nn, crate::Error::TLSHandshakeFailed); + ProxyTunnel::close_from_callback(proxy_nn, err); return; } } diff --git a/src/http/lib.rs b/src/http/lib.rs index 60b4f3402540..9c2525502805 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1456,7 +1456,7 @@ fn write_to_socket_with_buffer_fallback( /// than the certificate (`packages/bun-usockets/src/crypto/openssl.c`) when the /// peer went away. The other one, -71, is a fatal protocol error such as a peer /// that does not speak TLS. Certificate problems are the positive `X509_V_ERR_*`. -const US_HANDSHAKE_ECONNRESET: i32 = -46; +const US_HANDSHAKE_ECONNRESET: i32 = uws::us_bun_verify_error_t::PEER_DISCONNECTED; /// Why a TLS handshake that reported failure failed. pub(crate) fn handshake_failure(error_no: i32) -> crate::Error { diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 390a619fc363..31edac3352f0 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -401,6 +401,8 @@ pub mod ssl_wrapper { HandshakeError(Option<&'a core::ffi::CStr>), /// Closed before the handshake finished, or a renegotiation was refused. Aborted, + /// The peer's close_notify ended the first handshake. + PeerClosed, } #[derive(Clone, Copy)] @@ -929,6 +931,7 @@ pub mod ssl_wrapper { (false, us_bun_verify_error_t::default()) } HandshakeOutcome::Aborted => (false, self.verify_error()), + HandshakeOutcome::PeerClosed => (false, us_bun_verify_error_t::peer_disconnected()), }; self.flags.set_authorized(success); // trigger the handshake callback @@ -1068,7 +1071,12 @@ pub mod ssl_wrapper { self.flags.set_received_ssl_shutdown(true); // 2-step shutdown let _ = self.shutdown(false); - self.handle_end_of_renegotiation(); + if self.flags.handshake_state() == HandshakeState::HandshakePending { + self.flags + .set_handshake_state(HandshakeState::HandshakeCompleted); + self.trigger_handshake_callback(HandshakeOutcome::PeerClosed); + } + self.trigger_close_callback(); return false; } // as far as I know these are the only errors we want to handle @@ -1351,6 +1359,11 @@ pub mod ssl_wrapper { // ssl_flush_pending_session: handshake/data callbacks first, // then sessions. self.flush_pending_events(); + } else { + debug_assert!( + self.flags.closed_notified() || self.ssl.get().is_none(), + "update_handshake_state stopped the pass and left the wrapper open" + ); } } diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 71a21d858b33..b9dcf45b7fe7 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -49,6 +49,19 @@ pub struct us_bun_verify_error_t { impl us_bun_verify_error_t { /// `X509_V_ERR_HOSTNAME_MISMATCH`, from the in-handshake server identity check (`ERR_TLS_CERT_ALTNAME_INVALID`). pub const HOSTNAME_MISMATCH: core::ffi::c_int = 62; + /// `error` of [`Self::peer_disconnected`]. Not an X509 code. + pub const PEER_DISCONNECTED: core::ffi::c_int = -46; + + /// The peer left before the handshake finished, like `ssl_trigger_handshake_econnreset` in openssl.c. + pub const fn peer_disconnected() -> Self { + Self { + error_no: Self::PEER_DISCONNECTED, + code: c"ECONNRESET".as_ptr(), + reason: + c"Client network socket disconnected before secure TLS connection was established" + .as_ptr(), + } + } } impl Default for us_bun_verify_error_t { diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index c4eddd71d8af..c3f8e4061e6b 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -3334,6 +3334,46 @@ test("a proxy's own reply to CONNECT never resolves as the https origin's respon ]); }); +test("an https origin that ends the TLS handshake with close_notify fails the tunneled request", async () => { + // The proxy opens the tunnel and keeps it open. The origin answers the ClientHello with a close_notify alert, + // so only the alert says that no TLS session will come. + const closeNotify = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); + const established = Buffer.from("HTTP/1.1 200 Connection Established\r\n\r\n"); + const outcomes = []; + // "with the reply": the alert is in the same write as the reply to CONNECT, ahead of the ClientHello. + for (const alert of ["after the ClientHello", "with the reply"]) { + const sockets: net.Socket[] = []; + const proxy = net.createServer(socket => { + sockets.push(socket); + socket.on("error", () => {}); + let chunks = 0; + socket.on("data", () => { + chunks++; + if (chunks === 1) + socket.write(alert === "with the reply" ? Buffer.concat([established, closeNotify]) : established); + else if (chunks === 2 && alert === "after the ClientHello") socket.write(closeNotify); + }); + }); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + try { + outcomes.push( + await fetch("https://origin.invalid/", { + proxy: `http://127.0.0.1:${(proxy.address() as net.AddressInfo).port}`, + keepalive: false, + }).then( + response => ({ resolved: response.status }), + e => ({ code: e.code }), + ), + ); + } finally { + for (const socket of sockets) socket.destroy(); + proxy.close(); + } + } + // What a direct connection reports for a peer that leaves mid-handshake. + expect(outcomes).toEqual([{ code: "ECONNRESET" }, { code: "ECONNRESET" }]); +}); + test("invalid TLS options are reported the same through a proxy as directly", async () => { // Says the tunnel is up; the TLS options are what fails next. const proxy = net.createServer(socket => { diff --git a/test/js/node/http2/node-http2-upgrade.test.mts b/test/js/node/http2/node-http2-upgrade.test.mts index 1faa88d67a73..6cdb2b3b9755 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -631,6 +631,15 @@ describe("HTTP/2 upgrade — failed handshake", () => { { event: "tlsClientError", code: "ECONNRESET", message: "socket hang up" }, ); }); + + test("a client that ends the handshake with close_notify and stays connected is reported", async () => { + const { event, code } = await handshakeOutcome(client => + client.write(Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00])), + ); + // BoringSSL reads the alert as the peer's close. OpenSSL refuses an alert ahead of the ClientHello. + const expected = process.features.openssl_is_boringssl ? "ECONNRESET" : "ERR_SSL_UNEXPECTED_MESSAGE"; + assert.deepStrictEqual({ event, code }, { event: "tlsClientError", code: expected }); + }); }); if (typeof Bun !== "undefined") { diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index a8a0504f3e54..772d3365b866 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -2501,6 +2501,81 @@ it("verifies against the CAs of its context when the TLS socket rides on a Duple } }); +describe("a TLS server wrap over a Duplex transport whose peer ends the handshake with close_notify", () => { + // The peer keeps the transport open, so only the alert says that no handshake will come. BoringSSL reads the alert + // as the peer's close at every point of the handshake. OpenSSL refuses one ahead of the ClientHello and reads one + // behind it as the end of the stream. The client side runs in node-tls-duplex-end-verify.test.ts. + const closeNotify = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); + const isBoringSSL = process.features.openssl_is_boringssl; + const aheadOfClientHello = isBoringSSL ? "ECONNRESET" : "ERR_SSL_UNEXPECTED_MESSAGE"; + const serverContext = (options: tls.SecureContextOptions = {}) => ({ + isServer: true, + secureContext: tls.createSecureContext({ ...COMMON_CERT_, ...options }), + }); + // `onWrite` gets each chunk that the TLS socket writes to the transport. + const makeTransport = (onWrite: (chunk: Buffer) => void = () => {}) => + new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + callback(); + onWrite(chunk); + }, + }); + const firstEvent = (socket: TLSSocket) => { + const { promise, resolve } = Promise.withResolvers(); + socket.on("error", (err: NodeJS.ErrnoException) => resolve(`error ${err.code}`)); + socket.on("end", () => resolve("end")); + socket.on("secure", () => resolve("secure")); + socket.resume(); + return promise; + }; + + it("the alert is the first record", async () => { + const transport = makeTransport(); + const wrapped = new TLSSocket(transport, serverContext()); + const reported = firstEvent(wrapped); + setImmediate(() => transport.push(closeNotify)); + expect(await reported).toBe(`error ${aheadOfClientHello}`); + expect(wrapped.destroyed).toBe(true); + transport.destroy(); + }); + + it("the alert follows a ClientHello", async () => { + // A client that is thrown away writes the ClientHello. + const hello = Promise.withResolvers(); + const donor = tls.connect({ socket: makeTransport(hello.resolve), rejectUnauthorized: false }); + donor.on("error", () => {}); + const clientHello = await hello.promise; + donor.destroy(); + + // The alert answers the wrap's first flight. In TLS 1.2 the client's next records are still plaintext. + let answered = false; + const transport: Duplex = makeTransport(() => { + if (answered) return; + answered = true; + setImmediate(() => transport.push(closeNotify)); + }); + const wrapped = new TLSSocket(transport, serverContext({ maxVersion: "TLSv1.2" })); + const reported = firstEvent(wrapped); + setImmediate(() => transport.push(clientHello)); + expect(await reported).toBe(isBoringSSL ? "error ECONNRESET" : "end"); + expect(answered).toBe(true); + wrapped.destroy(); + transport.destroy(); + }); + + it("a tls.Server reports it as 'tlsClientError'", async () => { + const server = tls.createServer(COMMON_CERT_); + const transport = makeTransport(); + const reported = once(server, "tlsClientError"); + server.emit("connection", transport); + setImmediate(() => transport.push(closeNotify)); + const [err, socket] = await reported; + expect({ code: err.code, destroyed: socket.destroyed }).toEqual({ code: aheadOfClientHello, destroyed: true }); + transport.destroy(); + }); +}); + it("delivers 'session' even when the data handler destroys the socket immediately", async () => { // The TLS1.3 NewSessionTickets ride in the same read pass as the response // bytes. If the parked session were only flushed after the data dispatch, diff --git a/test/js/node/tls/node-tls-duplex-end-verify.test.ts b/test/js/node/tls/node-tls-duplex-end-verify.test.ts index f15713f2673b..883d9baea611 100644 --- a/test/js/node/tls/node-tls-duplex-end-verify.test.ts +++ b/test/js/node/tls/node-tls-duplex-end-verify.test.ts @@ -1281,3 +1281,175 @@ test("over a Duplex: a server that shares no cipher with the client sends its fa assert.match((await clientFailed).code, /^ERR_SSL_.*ALERT_HANDSHAKE_FAILURE$/); server.destroy(); }); + +// A handshake that the peer ends with a close_notify alert. The peer keeps the transport open, so only the alert tells +// this side that no session will come. +const CLOSE_NOTIFY = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); + +// A transport with no file descriptor. `peer(chunk, transport)` gets each chunk that the TLS socket writes to it. +function transportWithPeer(peer = () => {}) { + const transport = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + callback(); + peer(chunk, transport); + }, + }); + return transport; +} + +// A peer that calls `answer(transport)` once: on a later turn of the event loop than the first flight of the TLS +// socket, or inside the write() of that flight with `inWrite`. +function answerFirstFlight(answer, inWrite = false) { + let answered = false; + return (chunk, transport) => { + if (answered) return; + answered = true; + if (inWrite) answer(transport); + else setImmediate(answer, transport); + }; +} + +// The events of `socket` in order. Resolves at 'close'. +function eventsUntilClose(socket) { + const events = []; + const { promise, resolve } = Promise.withResolvers(); + socket.on("secureConnect", () => events.push("secureConnect")); + socket.on("end", () => events.push("end")); + socket.on("error", err => events.push(`error ${err.code}: ${err.message}`)); + socket.on("close", hadError => { + events.push(`close ${hadError}`); + resolve(events); + }); + return promise; +} + +const DISCONNECTED_IN_HANDSHAKE = [ + "end", + "error ECONNRESET: Client network socket disconnected before secure TLS connection was established", + "close true", +]; + +for (const rejectUnauthorized of [true, false]) { + test(`over a Duplex: a close_notify in answer to the ClientHello fails the connection, rejectUnauthorized ${rejectUnauthorized}`, async () => { + const transport = transportWithPeer(answerFirstFlight(transport => transport.push(CLOSE_NOTIFY))); + const client = tls.connect({ socket: transport, servername: "agent1", rejectUnauthorized }); + assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); + }); +} + +test("over a Duplex: a close_notify that the transport delivers inside its write() fails the connection", async () => { + const transport = transportWithPeer(answerFirstFlight(transport => transport.push(CLOSE_NOTIFY), true)); + const client = tls.connect({ socket: transport, servername: "agent1" }); + assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); +}); + +test("over a Duplex: a close_notify that is readable before tls.connect() fails the connection", async () => { + const transport = transportWithPeer(); + transport.push(CLOSE_NOTIFY); + const client = tls.connect({ socket: transport, servername: "agent1" }); + assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); +}); + +test("over a Duplex: a close_notify that arrives in two reads fails the connection", async () => { + const transport = transportWithPeer( + answerFirstFlight(transport => { + transport.push(CLOSE_NOTIFY.subarray(0, 3)); + setImmediate(() => transport.push(CLOSE_NOTIFY.subarray(3))); + }), + ); + const client = tls.connect({ socket: transport, servername: "agent1" }); + assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); +}); + +test("over a Duplex: a close_notify after this side called end() fails the connection", async () => { + const transport = transportWithPeer( + answerFirstFlight(transport => { + client.end(); + setImmediate(() => transport.push(CLOSE_NOTIFY)); + }), + ); + const client = tls.connect({ socket: transport, servername: "agent1" }); + assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); +}); + +test("over a Duplex: a close_notify in place of the server's last TLS 1.2 flight fails the connection", async () => { + // A real server answers the ClientHello, so the client has checked a trusted certificate when the alert arrives. + let clientWrites = 0; + const serverTransport = transportWithPeer(chunk => { + if (clientWrites < 2) clientTransport.push(chunk); + }); + const clientTransport = transportWithPeer((chunk, transport) => { + clientWrites++; + if (clientWrites === 1) serverTransport.push(chunk); + // The client sent its last flight. It now waits for the server's ChangeCipherSpec. + else if (clientWrites === 2) setImmediate(() => transport.push(CLOSE_NOTIFY)); + }); + const server = new tls.TLSSocket(serverTransport, { + isServer: true, + secureContext: tls.createSecureContext({ key, cert, maxVersion: "TLSv1.2" }), + }); + server.on("error", () => {}); + try { + const client = tls.connect({ socket: clientTransport, servername: "agent1", ca: serverCA }); + assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); + } finally { + server.destroy(); + serverTransport.destroy(); + } +}); + +test("over a TLS socket: a close_notify in answer to the inner ClientHello fails the inner connection", async () => { + // The outer session carries the inner handshake. The outer server answers the inner ClientHello itself. + const server = tls.createServer({ key, cert }, socket => { + socket.on("error", () => {}); + socket.once("data", () => socket.write(CLOSE_NOTIFY)); + }); + await new Promise(listening => server.listen(0, "127.0.0.1", listening)); + const outer = tls.connect({ port: server.address().port, host: "127.0.0.1", rejectUnauthorized: false }); + outer.on("error", () => {}); + try { + await new Promise(secured => outer.once("secureConnect", secured)); + const inner = tls.connect({ socket: outer, servername: "agent1" }); + assert.deepStrictEqual(await eventsUntilClose(inner), DISCONNECTED_IN_HANDSHAKE); + } finally { + outer.destroy(); + server.close(); + } +}); + +// The same alert on a TCP socket: https://github.com/oven-sh/bun/issues/44517 +for (const rejectUnauthorized of [true, false]) { + test(`on a TCP socket: a close_notify in answer to the ClientHello fails the connection, rejectUnauthorized ${rejectUnauthorized}`, async () => { + const peer = net.createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => socket.write(CLOSE_NOTIFY)); + }); + await new Promise(listening => peer.listen(0, "127.0.0.1", listening)); + try { + const client = tls.connect({ port: peer.address().port, host: "127.0.0.1", rejectUnauthorized }); + assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); + } finally { + peer.close(); + } + }); +} + +test("on a TCP socket: a close_notify in place of the client's last TLS 1.2 flight is a 'tlsClientError'", async () => { + const server = tls.createServer({ key, cert, maxVersion: "TLSv1.2" }); + const reported = new Promise(resolve => server.once("tlsClientError", resolve)); + // The proxy forwards the ClientHello and answers the server's flight itself. + const { port, close } = await behindProxy(server, (downstream, upstream) => { + downstream.once("data", clientHello => upstream.write(clientHello)); + upstream.once("data", () => upstream.write(CLOSE_NOTIFY)); + }); + const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + client.on("error", () => {}); + try { + const err = await reported; + assert.deepStrictEqual({ code: err.code, message: err.message }, { code: "ECONNRESET", message: "socket hang up" }); + } finally { + client.destroy(); + close(); + } +}); diff --git a/test/js/node/tls/node-tls-namedpipes.test.ts b/test/js/node/tls/node-tls-namedpipes.test.ts index 2df3f88e1cf5..38862d0a8ee6 100644 --- a/test/js/node/tls/node-tls-namedpipes.test.ts +++ b/test/js/node/tls/node-tls-namedpipes.test.ts @@ -140,6 +140,64 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)( }, ); +describe("a peer that ends the handshake with close_notify over a named pipe", () => { + // Same contract as the Duplex transport tests in node-tls-duplex-end-verify.test.ts and node-tls-connect.test.ts. + // The peer keeps the pipe open behind the alert, so only the alert says that no session will come. + const closeNotify = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); + + it.if(isWindows)("fails the client's connection with ECONNRESET", async () => { + const peer = net.createServer(socket => { + socket.on("error", () => {}); + // The alert is the answer to the ClientHello. + socket.once("data", () => socket.write(closeNotify)); + }); + let client: ReturnType | null = null; + try { + const pipeName = `\\\\.\\pipe\\test\\${randomUUID()}`; + peer.listen(pipeName); + await once(peer, "listening"); + + const socket = connect({ path: pipeName, rejectUnauthorized: false }); + client = socket; + const events: string[] = []; + const closed = Promise.withResolvers(); + socket.on("secureConnect", () => events.push("secureConnect")); + socket.on("end", () => events.push("end")); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}`)); + socket.on("close", hadError => { + events.push(`close ${hadError}`); + closed.resolve(); + }); + await closed.promise; + expect(events).toEqual(["end", "error ECONNRESET", "close true"]); + } finally { + client?.destroy(); + peer.close(); + } + }); + + it.if(isWindows)("is reported by a tls.Server as 'tlsClientError'", async () => { + const server = createServer(tls); + const reported = once(server, "tlsClientError"); + let client: ReturnType | null = null; + try { + const pipeName = `\\\\.\\pipe\\test\\${randomUUID()}`; + server.listen(pipeName); + await once(server, "listening"); + + // The alert is the first record. No ClientHello comes. + const socket = net.connect(pipeName, () => socket.write(closeNotify)); + client = socket; + socket.on("error", () => {}); + const [err] = await reported; + expect({ code: err.code, message: err.message }).toEqual({ code: "ECONNRESET", message: "socket hang up" }); + } finally { + client?.destroy(); + server.close(); + } + }); +}); + it.if(isWindows)("setSecureContext() rotates the certificate of a server listening on a named pipe", async () => { const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); const agent1 = { key: fixture("agent1-key.pem"), cert: fixture("agent1-cert.pem") }; diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index bf132838b35a..77924a6125c6 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1751,3 +1751,17 @@ describe.concurrent("fetch-tls", () => { expect(exitCode).toBe(0); }); }); + +it("a server that ends the TLS handshake with close_notify and stays connected fails the request as a disconnect", async () => { + await using server = net.createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => socket.write(Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]))); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as net.AddressInfo; + const outcome = await fetch(`https://127.0.0.1:${port}/`, { keepalive: false, proxy: false }).then( + response => `status ${response.status}`, + e => e.code, + ); + expect(outcome).toBe("ECONNRESET"); +}); diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 6510888d7387..3a3e6072ae97 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -5,7 +5,7 @@ import { tls as tlsCerts } from "harness"; import type { HttpsProxyAgent as HttpsProxyAgentType } from "https-proxy-agent"; import { createHash } from "node:crypto"; import { once } from "node:events"; -import type { AddressInfo } from "node:net"; +import net, { type AddressInfo } from "node:net"; import tls from "node:tls"; import { type ClientEvent, @@ -291,6 +291,28 @@ describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => { gc(); }); + test("an origin that ends the TLS handshake with close_notify fails the connection", async () => { + // The origin answers the ClientHello with a close_notify alert and keeps the tunnel open, so only the alert + // says that no TLS session will come. + await using origin = net.createServer(socket => { + socket.on("error", () => {}); + socket.once("data", () => socket.write(Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]))); + }); + origin.listen(0, "127.0.0.1"); + await once(origin, "listening"); + const originPort = (origin.address() as AddressInfo).port; + using recorded = await startRecordingProxy(); + const url = `wss://127.0.0.1:${originPort}`; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${recorded.port}`, + tls: { rejectUnauthorized: false }, + }); + expect({ events: await failingSession(ws), requests: recorded.requests }).toEqual({ + events: failed(url, "TLS handshake failed", 1015), + requests: [connectRequest(originPort)], + }); + }); + test("server-initiated ping survives through TLS tunnel proxy", async () => { // Regression test: sendPong checked socket.isClosed() on the detached tcp // field instead of using hasTCP(). For wss:// through HTTP proxy, the From b288de2ed46c325d17b47652e04165070a113677 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:32:15 +0000 Subject: [PATCH 087/174] tls: report a renegotiation when it ends, and the first handshake it can hide (#37094) Both engines reported a finished renegotiation only when application data or a close_notify came behind it, so the state stayed RENEGOTIATION_PENDING: no second 'secureConnect', no drain dispatch, and a close reported as "disconnected before secure TLS connection was established". On a socket, a HelloRequest in the same read as the server's Finished overwrote the state of a first handshake that nobody had reported yet. openssl.c gets one ssl_report_finished_handshake(), called from every arm of the read loop in place of the two inlined copies. A close reports ECONNRESET for a first handshake only. SSLWrapper reports at the WANT_READ exit. Owners now see a repeat success at once, so the three that restarted their request on it stop doing that, from state they already have: the fetch tunnel re-sent the request headers, fetch with a checkServerIdentity function parked a request that was waiting for its response (UnexpectedData), and the WebSocket upgrade client failed with FailedToWrite. --- packages/bun-usockets/src/crypto/openssl.c | 100 +++--- src/http/HTTPContext.rs | 8 + src/http/ProxyTunnel.rs | 13 +- .../WebSocketUpgradeClient.rs | 11 +- src/uws/lib.rs | 8 +- test/js/node/tls/renegotiation.test.ts | 295 ++++++++++++++++++ 6 files changed, 371 insertions(+), 64 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 28250389a0e8..9d211296fb26 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2158,11 +2158,16 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void ssl_update_handshake(s, 1); if (ssl_gone(s)) return s; - if (s->ssl_handshake_state != HANDSHAKE_COMPLETED) { + if (s->ssl_handshake_state == HANDSHAKE_PENDING) { /* Surface ECONNRESET-style handshake failure exactly once so callers * (fetch, sockets) don't each have to check on_close themselves. */ ssl_trigger_handshake_econnreset(s); if (ssl_gone(s)) return s; + } else if (s->ssl_handshake_state == HANDSHAKE_RENEGOTIATION_PENDING) { + /* The session was established: only a protocol failure of the renegotiation is reported. */ + s->ssl_handshake_state = HANDSHAKE_COMPLETED; + ssl_dispatch_parked_reason(s); + if (ssl_gone(s)) return s; } /* code == 2 (forceful — `_destroy()` / `_handle.close()`): raw-close now, @@ -2460,7 +2465,7 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { * direction. */ if (ssl_is_uws_http_tls(s) && us_internal_ssl_is_shut_down(s)) return s; - if (s->ssl_handshake_state == HANDSHAKE_COMPLETED) { + if (s->ssl_handshake_state != HANDSHAKE_PENDING) { s->ssl_write_parked = 0; /* loop.c drops the writable interest of a shut-down socket after this dispatch. */ s = ssl_close_if_fatal(us_dispatch_writable(s)); @@ -2468,6 +2473,30 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { return s; } +/* Reports a handshake that SSL_read finished, with the flight it sealed still + * held: what the owner writes from the callback leaves in the same segment. + * The read cursor of the shared BIO survives that JS. NULL once `s` is gone. */ +static struct us_socket_t *ssl_report_finished_handshake(struct us_socket_t *s, struct loop_ssl_data *loop_ssl_data, + int retry_parked_write) { + if (s->ssl_handshake_state == HANDSHAKE_COMPLETED || !SSL_is_init_finished(s_ssl(s))) return s; + char *saved_input = loop_ssl_data->ssl_read_input; + unsigned int saved_length = loop_ssl_data->ssl_read_input_length; + unsigned int saved_offset = loop_ssl_data->ssl_read_input_offset; + ssl_trigger_handshake(s, 1); + if (ssl_gone(s)) return NULL; + /* node:https queues its request before the handshake. */ + if (retry_parked_write && s->ssl_write_parked) { + s = ssl_retry_parked_write(s); + if (!s || ssl_gone(s)) return NULL; + } + loop_ssl_data->ssl_read_input = saved_input; + loop_ssl_data->ssl_read_input_length = saved_length; + loop_ssl_data->ssl_read_input_offset = saved_offset; + loop_ssl_data->ssl_socket = s; + ssl_flush_write_batch(loop_ssl_data, s); + return s; +} + struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, int length) { /* See ssl_update_handshake: start this socket's SSL processing with a clean * per-thread error queue so a captured reason cannot belong to another @@ -2565,10 +2594,16 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE && err != SSL_ERROR_PENDING_CERTIFICATE) { if (err == SSL_ERROR_WANT_RENEGOTIATE) { + /* The HelloRequest can share a read with the server's Finished. */ + s = ssl_report_finished_handshake(s, loop_ssl_data, 0); + if (!s) return NULL; if (ssl_renegotiate(s)) continue; if (ssl_gone(s)) return NULL; err = SSL_ERROR_SSL; } else if (err == SSL_ERROR_ZERO_RETURN) { + /* The close_notify can share a read with the peer's Finished: the owner hears of the handshake before the close below. */ + s = ssl_report_finished_handshake(s, loop_ssl_data, 0); + if (!s) return NULL; /* Remote close_notify. A NewSessionTicket that rode in ahead of the * close_notify was parked by the new-session callback; deliver it * first (wire order - the ticket preceded these bytes, and Node's @@ -2600,13 +2635,7 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i * finishes immediately). With SENT_SHUTDOWN both directions are * closed and nothing is left to hold open: fall through so the * deferred graceful close (code 0, no FIN sent) completes here - * instead of waiting for a FIN that may never come. A flight - * held by this read's handshake batching must not be parked - * past this return. */ - if (loop_ssl_data->ssl_write_batch_len && - loop_ssl_data->ssl_write_batch_owner == s) { - ssl_flush_write_batch(loop_ssl_data, s); - } + * instead of waiting for a FIN that may never come. */ return s; } } @@ -2643,27 +2672,9 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i * deferring to the on_writable tail-call lets the low-prio queue * (SSL_in_init throttles to 5/tick) reorder the server's * secureConnection event past the client's close under fan-out - * loads. The save/restore below makes this safe even if the JS - * callback writes; with read==0 the buffer is empty anyway. */ - if (s->ssl_handshake_state == HANDSHAKE_PENDING && SSL_is_init_finished(s_ssl(s))) { - ssl_trigger_handshake(s, 1); - if (ssl_gone(s)) return NULL; - /* A write parked before the handshake (node:https queues its request - * that way) is retried with the flight still held, so both leave in - * one segment, like a write from the callback. */ - if (s->ssl_write_parked) { - s = ssl_retry_parked_write(s); - if (!s || ssl_gone(s)) return NULL; - } - loop_ssl_data->ssl_socket = s; - /* The callback and the retry ran with the flight held: a write they - * issued already flushed flight + data together; send whatever is - * still held. */ - if (loop_ssl_data->ssl_write_batch_len && - loop_ssl_data->ssl_write_batch_owner == s) { - ssl_flush_write_batch(loop_ssl_data, s); - } - } + * loads. */ + s = ssl_report_finished_handshake(s, loop_ssl_data, 1); + if (!s) return NULL; if (!read) break; /* Deliver any parked session/keylog payloads BEFORE the data: the @@ -2681,31 +2692,14 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i if (!s || ssl_gone(s)) return NULL; break; } - } else if (s->ssl_handshake_state != HANDSHAKE_COMPLETED) { + } else { /* SSL_read returned application data with the handshake having * finished inside it. Fire on_handshake before delivering data so the - * caller can inspect ALPN and re-tag the socket. The save/restore lets - * the JS callback write() without clobbering the BIO buffer that may - * still hold ciphertext for the next SSL_read. (PR #25946 gated this to - * clients because the server-side fire reordered node:http2/grpc-js - * session setup; the save/restore here is what was missing — those - * suites are re-verified below.) */ - char *saved_input = loop_ssl_data->ssl_read_input; - unsigned int saved_length = loop_ssl_data->ssl_read_input_length; - unsigned int saved_offset = loop_ssl_data->ssl_read_input_offset; - ssl_trigger_handshake(s, 1); - if (ssl_gone(s)) return NULL; - loop_ssl_data->ssl_read_input = saved_input; - loop_ssl_data->ssl_read_input_length = saved_length; - loop_ssl_data->ssl_read_input_offset = saved_offset; - loop_ssl_data->ssl_socket = s; - /* Send what the callback's own write did not already flush of the held - * flight. No parked-write retry here: its writable dispatch would run - * before the data this read decrypted reaches the caller. */ - if (loop_ssl_data->ssl_write_batch_len && - loop_ssl_data->ssl_write_batch_owner == s) { - ssl_flush_write_batch(loop_ssl_data, s); - } + * caller can inspect ALPN and re-tag the socket. No parked-write retry: + * its writable dispatch would run before the data this read decrypted + * reaches the caller. */ + s = ssl_report_finished_handshake(s, loop_ssl_data, 0); + if (!s) return NULL; } read += just_read; diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index 4f00a0c20699..f608aa0c6c08 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -1369,6 +1369,14 @@ impl Handler { ); return; } + // A renegotiation that finished: the request is already on its way. + use crate::internal_state::HTTPStage; + if !matches!( + client.state.request_stage, + HTTPStage::Pending | HTTPStage::Opened + ) { + return; + } if client.wants_server_identity_check() { // if checkServerIdentity returns false, we dont call firstCall — the connection was rejected // SAFETY: the native handle on a TLS socket is `*mut SSL`, diff --git a/src/http/ProxyTunnel.rs b/src/http/ProxyTunnel.rs index b80a0407ae68..b6ea15bf7be0 100644 --- a/src/http/ProxyTunnel.rs +++ b/src/http/ProxyTunnel.rs @@ -346,9 +346,13 @@ fn on_handshake( scoped_log!(http_proxy_tunnel, "ProxyTunnel onHandshake"); // Do NOT form `&mut ProxyTunnel` (see ALIASING NOTE). let _guard = ProxyTunnel::ref_guard(proxy_nn); - this.state.response_stage = HTTPStage::ProxyHeaders; - this.state.request_stage = HTTPStage::ProxyHeaders; - this.state.request_sent_len = 0; + // Not the first report: a renegotiation, with the request already on its way. + let first_report = this.state.request_stage == HTTPStage::ProxyHandshake; + if first_report { + this.state.response_stage = HTTPStage::ProxyHeaders; + this.state.request_stage = HTTPStage::ProxyHeaders; + this.state.request_sent_len = 0; + } let handshake_error = HTTPCertError::from_verify_error(ssl_error); if handshake_success { scoped_log!(http_proxy_tunnel, "ProxyTunnel onHandshake success"); @@ -362,6 +366,9 @@ fn on_handshake( ProxyTunnel::close_from_callback(proxy_nn, err); return; } + if !first_report { + return; + } if this.wants_server_identity_check() { // if checkServerIdentity returns false, we dont call open this means that the connection was rejected // Assert the wrapper is Some, then silently return diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 4aa437f8f1ef..39761c6c17ca 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -1025,11 +1025,6 @@ where pub(crate) fn on_proxy_tls_handshake_complete(this: ThisPtr) { log!("onProxyTLSHandshakeComplete"); - // TLS handshake done - free the CONNECT request buffer and send the - // WebSocket upgrade request through the tunnel. - this.state.set(State::Reading); - this.clear_input(); - // Take the WebSocket upgrade request from proxy state (transfers // ownership) along with the tunnel to send it through. let step = this.proxy.with_mut(|proxy| { @@ -1041,10 +1036,14 @@ where Self::terminate(this, ErrorCode::ProxyTunnelFailed); return; }; + // A renegotiation that finished: the first report took the request. if request_buf.is_empty() { - Self::terminate(this, ErrorCode::FailedToWrite); return; } + // TLS handshake done - free the CONNECT request buffer and send the + // WebSocket upgrade request through the tunnel. + this.state.set(State::Reading); + this.clear_input(); // Store it in input_body_buf so handle_writable can retry on drain. this.to_send_len.set(request_buf.len()); this.input_body_buf.set(request_buf); diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 31edac3352f0..3f9314459098 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -728,8 +728,9 @@ pub mod ssl_wrapper { let _ = boring_sys::SSL_shutdown(ssl.as_ptr()); } self.flags.set_received_ssl_shutdown(true); + self.handle_end_of_renegotiation(); // Reset pending handshake because we are closed for sure now - if self.flags.handshake_state() != HandshakeState::HandshakeCompleted { + if self.flags.handshake_state() == HandshakeState::HandshakePending { self.flags .set_handshake_state(HandshakeState::HandshakeCompleted); self.trigger_handshake_callback(HandshakeOutcome::Aborted); @@ -1227,7 +1228,10 @@ pub mod ssl_wrapper { return false; } else { log!("wanna read/write just break"); - // we wanna read/write just break + self.handle_end_of_renegotiation(); + if self.ssl.get().is_none() || self.flags.closed_notified() { + return false; + } break; } } diff --git a/test/js/node/tls/renegotiation.test.ts b/test/js/node/tls/renegotiation.test.ts index 3ed0c9560f47..8aa245a4c80e 100644 --- a/test/js/node/tls/renegotiation.test.ts +++ b/test/js/node/tls/renegotiation.test.ts @@ -673,3 +673,298 @@ it("Bun.connect sends the client certificate a renegotiation asks for", async () socket.end(); } }); + +// TLS 1.2 servers that renegotiate at a moment when no application data follows, so only the end of the renegotiation +// itself can report it. The process prints the ports of: +// 1. "ends": renegotiates right after the handshake, then ends the connection. +// 2. "stays open": the same, then sends nothing and keeps the connection open. +// 3, 4. A relay to 1 and to 2 that gives the client the server's Finished and the HelloRequest in one write. +// 5. A relay to 1 that gives the client the Finished of the renegotiation and the close_notify in one write. +// 6. A relay to 2 that answers the ClientHello of the renegotiation with a FIN. +// 7. An HTTP origin that renegotiates when a request arrived, then answers it. For an Upgrade request it answers 101, a +// text frame and a Close frame. When a connection closes it prints the path and how many request lines it got. +const quietRenegotiationServers = /* js */ ` + const crypto = require("crypto"); + const net = require("net"); + const tls = require("tls"); + const options = { + cert: process.env.SERVER_CERT, + key: process.env.SERVER_KEY, + minVersion: "TLSv1.2", + maxVersion: "TLSv1.2", + // A full handshake each time, so the server's Finished is the last message for every client. + secureOptions: crypto.constants.SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION, + }; + const quiet = then => + tls.createServer(options, socket => { + socket.on("error", () => {}); + socket.resume(); + socket.renegotiate({ rejectUnauthorized: false }, err => err || then(socket)); + }); + const ends = quiet(socket => socket.end()); + const staysOpen = quiet(() => {}); + + const CHANGE_CIPHER_SPEC = 20; + // Calls shape(record, from, to) for each whole TLS record, in both directions. + const relay = (target, shape) => + net.createServer(client => { + const upstream = net.connect(target.address().port, "127.0.0.1"); + for (const [from, to] of [[client, upstream], [upstream, client]]) { + let pending = Buffer.alloc(0); + from.on("error", () => {}); + from.on("close", () => to.destroy()); + from.on("end", () => to.end()); + const state = {}; + from.on("data", chunk => { + pending = Buffer.concat([pending, chunk]); + while (pending.length >= 5 && pending.length >= 5 + pending.readUInt16BE(3)) { + const record = pending.subarray(0, 5 + pending.readUInt16BE(3)); + pending = pending.subarray(record.length); + shape(record, from === client, to, state, client); + } + }); + } + }); + // Gives the client the server's ChangeCipherSpec number \`nth\` and the two records behind it in one write. + const coalesce = nth => (record, fromClient, to, state) => { + if (fromClient || state.released) return void to.write(record); + if (!state.held && (record[0] !== CHANGE_CIPHER_SPEC || (state.seen = (state.seen ?? 0) + 1) !== nth)) { + return void to.write(record); + } + (state.held ??= []).push(record); + if (state.held.length < 3) return; + state.released = true; + to.write(Buffer.concat(state.held)); + }; + // Behind the client's first ChangeCipherSpec come its Finished and the ClientHello of the renegotiation. + const cut = (record, fromClient, to, state, client) => { + if (!fromClient) return void to.write(record); + if (state.behind === 1) return void client.end(); + if (state.behind === 0 || record[0] === CHANGE_CIPHER_SPEC) state.behind = state.behind === 0 ? 1 : 0; + to.write(record); + }; + + const origin = tls.createServer(options, socket => { + let received = ""; + let asked = false; + socket.on("error", () => {}); + socket.on("data", chunk => { + received += chunk.toString("latin1"); + if (asked || !received.includes("\\r\\n\\r\\n")) return; + asked = true; + socket.renegotiate({ rejectUnauthorized: false }, err => { + if (err) return socket.destroy(); + const key = /sec-websocket-key:\\s*(\\S+)/i.exec(received)?.[1]; + if (!key) return socket.write("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nok"); + const accept = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\n" + + "Sec-WebSocket-Accept: " + accept + "\\r\\n\\r\\n", + ); + socket.end(Buffer.from([0x81, 0x02, 0x68, 0x69, 0x88, 0x02, 0x03, 0xe8])); + }); + }); + socket.on("close", () => { + const lines = received.match(/(GET|POST) \\S+ HTTP\\/1\\.1\\r\\n/g) ?? []; + console.log(lines[0]?.split(" ")[1] + " " + lines.length); + }); + }); + + const servers = [ + ends, + staysOpen, + relay(ends, coalesce(1)), + relay(staysOpen, coalesce(1)), + relay(ends, coalesce(2)), + relay(staysOpen, cut), + origin, + ]; + let listening = 0; + for (const server of servers) { + server.listen(0, "127.0.0.1", () => { + if (++listening === servers.length) console.log(servers.map(server => server.address().port).join(" ")); + }); + } +`; + +let quiet: Record<"ends" | "staysOpen" | "endsCoalesced" | "staysOpenCoalesced" | "closeCoalesced" | "cut", number>; +let renegotiatingOrigin: number; +let quietServers: Subprocess<"ignore", "pipe", "inherit">; +// Resolves with the number of request lines that the origin got on the connection that asked for `path`. +const requestLines = new Map>(); +function requestLinesFor(path: string) { + if (!requestLines.has(path)) requestLines.set(path, Promise.withResolvers()); + return requestLines.get(path)!; +} +beforeAll(async () => { + quietServers = Bun.spawn({ + cmd: ["node", "-e", quietRenegotiationServers], + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + env: { ...bunEnv, SERVER_CERT: tls.cert, SERVER_KEY: tls.key }, + }); + const ports = Promise.withResolvers(); + (async () => { + let output = ""; + for await (const chunk of quietServers.stdout.pipeThrough(new TextDecoderStream())) { + output += chunk; + const lines = output.split("\n"); + output = lines.pop()!; + for (const line of lines) { + const [path, count] = line.split(" "); + if (path.startsWith("/")) requestLinesFor(path).resolve(Number(count)); + else ports.resolve(line.split(" ").map(Number)); + } + } + ports.reject(new Error("the servers exited before they printed their ports")); + })(); + const [ends, staysOpen, endsCoalesced, staysOpenCoalesced, closeCoalesced, cut, origin] = await ports.promise; + quiet = { ends, staysOpen, endsCoalesced, staysOpenCoalesced, closeCoalesced, cut }; + renegotiatingOrigin = origin; +}); +afterAll(() => quietServers?.kill()); + +// A node:tls client over TCP, or over a Duplex in front of a TCP socket. Resolves with its events at 'close', or at +// handshake report number `until`. +async function nodeTlsEvents(transport: "TCP" | "a Duplex", port: number, until = Infinity) { + const events: string[] = []; + const done = Promise.withResolvers(); + let raw: ReturnType | undefined; + let duplex: Duplex | undefined; + if (transport === "a Duplex") { + const tcp = (raw = netConnect(port, "127.0.0.1")); + tcp.on("error", () => {}); + duplex = new Duplex({ + read() {}, + write(chunk: Buffer, _encoding: string, callback: () => void) { + tcp.write(chunk, callback); + }, + final(callback: () => void) { + tcp.end(); + callback(); + }, + }); + tcp.on("data", (chunk: Buffer) => duplex!.push(chunk)); + tcp.on("end", () => duplex!.push(null)); + tcp.on("close", () => duplex!.destroy()); + } + const socket = tlsConnect({ + ca: tls.cert, + servername: "localhost", + ...(duplex ? { socket: duplex } : { port, host: "127.0.0.1" }), + }); + socket.on("secureConnect", () => events.push("secureConnect")); + socket.on("secure", () => events.push("secure") === until * 2 && done.resolve()); + socket.on("data", (chunk: Buffer) => events.push(`data ${chunk}`)); + socket.on("end", () => events.push("end")); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}`)); + socket.on("close", (hadError: boolean) => { + events.push(`close ${hadError}`); + done.resolve(); + }); + try { + await done.promise; + return events; + } finally { + socket.destroy(); + raw?.destroy(); + } +} + +const twoHandshakes = ["secureConnect", "secure", "secureConnect", "secure"]; + +it.concurrent.each(["TCP", "a Duplex"] as const)( + "a renegotiation with no data behind it is reported when it ends, over %s", + async transport => { + expect(await nodeTlsEvents(transport, quiet.staysOpen, 2)).toEqual(twoHandshakes); + }, +); + +it.concurrent.each(["ends", "closeCoalesced"] as const)( + "a renegotiation that the server's close follows is reported before the close (%s)", + async server => { + expect(await nodeTlsEvents("TCP", quiet[server])).toEqual([...twoHandshakes, "end", "close false"]); + }, +); + +it.concurrent( + "a HelloRequest in the same read as the server's Finished does not hide the first handshake", + async () => { + expect(await nodeTlsEvents("TCP", quiet.staysOpenCoalesced, 2)).toEqual(twoHandshakes); + expect(await nodeTlsEvents("TCP", quiet.endsCoalesced)).toEqual([...twoHandshakes, "end", "close false"]); + }, +); + +it.concurrent.each([ + { server: "ends", reports: ["handshake true null", "handshake true null"] }, + { server: "cut", reports: ["handshake true null"] }, +] as const)("Bun.connect gets one report for each handshake that finished ($server)", async ({ server, reports }) => { + const events: string[] = []; + const closed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: quiet[server], + tls: { ca: tls.cert, serverName: "localhost" }, + socket: { + data() {}, + handshake: (_socket, success, error) => void events.push(`handshake ${success} ${error && (error as any).code}`), + error: (_socket, error) => void events.push(`error ${(error as any).code}`), + close: () => closed.resolve(), + }, + }); + await closed.promise; + expect(events).toEqual([...reports]); +}); + +// The repeat report must not restart the request: the tunnel rewound to the request headers, and a JS +// checkServerIdentity parked a request that was already waiting for its response. +it.concurrent.each([ + { route: "direct", checkServerIdentity: true }, + { route: "a CONNECT proxy", checkServerIdentity: false }, + { route: "a CONNECT proxy", checkServerIdentity: true }, +])( + "fetch over $route sends its request once when the origin renegotiates (checkServerIdentity: $checkServerIdentity)", + async ({ route, checkServerIdentity }) => { + using proxy = route === "direct" ? undefined : await startRecordingProxy(); + const path = `/${route === "direct" ? "direct" : "proxied"}-${checkServerIdentity}`; + const outcome = await fetch(`https://localhost:${renegotiatingOrigin}${path}`, { + method: "POST", + body: "0123456789", + keepalive: false, + tls: { ca: tls.cert, ...(checkServerIdentity && { checkServerIdentity: () => undefined }) }, + proxy: proxy ? { url: `http://127.0.0.1:${proxy.port}`, respectNoProxy: false } : false, + }).then( + async response => `${response.status} ${await response.text()}`, + e => e.code, + ); + expect({ outcome, requestLines: await requestLinesFor(path).promise }).toEqual({ + outcome: "200 ok", + requestLines: 1, + }); + }, +); + +it.concurrent( + "WebSocket through a CONNECT proxy opens when the origin renegotiates before it answers 101", + async () => { + using proxy = await startRecordingProxy(); + const events: string[] = []; + const closed = Promise.withResolvers(); + const ws = new WebSocket(`wss://localhost:${renegotiatingOrigin}/upgrade`, { + tls: { ca: tls.cert }, + proxy: `http://127.0.0.1:${proxy.port}`, + }); + ws.onopen = () => events.push("open"); + ws.onmessage = event => events.push(`message: ${event.data}`); + ws.onclose = event => { + events.push(`close ${event.code}`); + closed.resolve(); + }; + await closed.promise; + expect({ events, requestLines: await requestLinesFor("/upgrade").promise }).toEqual({ + events: ["open", "message: hi", "close 1000"], + requestLines: 1, + }); + }, +); From f34170831ec6a5ff4169c44caa83f94f0db55896 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:46:39 +0000 Subject: [PATCH 088/174] tls: report a fatal TLS error on an established session, then close (#41272, #42324, #44223) A record that does not decrypt, a fatal alert that arrives after the handshake (TLS 1.3 certificate_required) and a refused renegotiation all ended as a clean 'end' with no error. The fd engine also dropped the data it had decrypted earlier in the same read, SSLWrapper never sent its own alert, and a refused renegotiation was reported as one more handshake with the session's X509 verdict, which node:tls read as another 'secureConnect'. Both engines keep their one report. on_handshake(0, EPROTO + reason) now means a TLS protocol failure at any time, not only in the first handshake. At the fatal exit of the read loop each engine takes the reason before any JS runs, reports a handshake that this read finished, delivers parked events and the data decrypted so far, tells the owner, and then closes the connection itself: no owner has to react for the session to end. - A refusal falls into that exit. Past the limit the engine turns renegotiation off and lets BoringSSL refuse, so there is one identity, NO_RENEGOTIATION. After our own close_notify a request has no answer and is not an error. - us_ssl_take_error_reason() is the one place that picks the reason, for both engines and for the first handshake too. - NewSocket: once a handshake was reported, a failure goes to the `error` handler and never to `handshake` or `open`. With no `error` handler the socket only closes: a peer cannot raise an uncaught exception. - node:tls emits it like Node's onerror and keeps the socket, so the native close gives 'error', 'end', 'close' and a paused reader keeps its data. --- packages/bun-types/bun.d.ts | 5 + packages/bun-usockets/src/crypto/openssl.c | 87 +-- packages/bun-usockets/src/libusockets.h | 2 + src/boringssl_sys/boringssl.rs | 1 - src/js/node/_http2_upgrade.ts | 1 + src/js/node/net.ts | 15 + src/runtime/socket/socket_body.rs | 16 +- src/uws/lib.rs | 109 ++-- test/js/bun/net/socket.test.ts | 50 ++ .../js/node/http2/node-http2-upgrade.test.mts | 49 ++ test/js/node/tls/node-tls-cert.test.ts | 47 +- test/js/node/tls/node-tls-connect.test.ts | 205 +++++++ test/js/node/tls/node-tls-namedpipes.test.ts | 75 +++ test/js/node/tls/node-tls-server.test.ts | 153 +++++- test/js/node/tls/renegotiation.test.ts | 513 +++++++++++++++++- 15 files changed, 1239 insertions(+), 89 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 51a24ab295ff..806b972bf1cb 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7289,6 +7289,11 @@ declare module "bun" { */ open?(socket: Socket): void | Promise; close?(socket: Socket, error?: Error): void | Promise; + /** + * Called when a handler throws, and when an established TLS session fails + * (a record that does not decrypt, a fatal alert from the peer). After a + * TLS failure the socket closes, with or without this handler. + */ error?(socket: Socket, error: Error): void | Promise; data?(socket: Socket, data: BinaryTypeList[DataBinaryType]): void | Promise; drain?(socket: Socket): void | Promise; diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 9d211296fb26..c4ca55467d91 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1912,24 +1912,32 @@ static struct us_bun_verify_error_t ssl_failed_handshake_verify_error(struct us_ /* ── Handshake state machine ─────────────────────────────────────────────── */ +/* The oldest entry, like node (crypto_tls.cc#L860), but the SSL library's first: BoringSSL queues the cipher's BAD_DECRYPT ahead of node's name for a bad record. */ +int us_ssl_take_error_reason(char *reason, size_t length) { + uint32_t picked = ERR_peek_error(); + for (uint32_t queued; (queued = ERR_get_error()) != 0;) { + if (ERR_GET_LIB(queued) == ERR_LIB_SSL) { + picked = queued; + break; + } + } + ERR_clear_error(); + if (!picked) return 0; + ERR_error_string_n(picked, reason, length); + return 1; +} + /* Park the fatal OpenSSL reason behind a failed SSL_* call where the * handshake-failure dispatch can find it, then drain the queue and mark the - * socket fatal. Only parks while the handshake is unfinished: that dispatch is + * socket fatal. Only parks while the first handshake is unfinished: that dispatch is * the sole consumer, so a later reason would linger and be misreported as some * other socket's handshake failure. */ static void ssl_park_fatal_reason(struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *) s->group->loop->data.ssl_data; - if (loop_ssl_data && s->ssl_handshake_state != HANDSHAKE_COMPLETED) { - /* The OLDEST queued entry is the root cause and is what node reports - * (https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L860); - * later entries wrap it or belong to another socket on this thread. */ - unsigned long ssl_queue_err = ERR_peek_error(); - if (ssl_queue_err != 0) { - ERR_error_string_n(ssl_queue_err, loop_ssl_data->ssl_last_fatal_error, - sizeof(loop_ssl_data->ssl_last_fatal_error)); - loop_ssl_data->ssl_last_fatal_error_owner = s; - } + if (loop_ssl_data && s->ssl_handshake_state == HANDSHAKE_PENDING && + us_ssl_take_error_reason(loop_ssl_data->ssl_last_fatal_error, sizeof(loop_ssl_data->ssl_last_fatal_error))) { + loop_ssl_data->ssl_last_fatal_error_owner = s; } ERR_clear_error(); s->ssl_fatal_error = 1; @@ -2037,7 +2045,6 @@ static int ssl_renegotiate(struct us_socket_t *s) { uint32_t limit, window; us_reneg_policy(s_ssl(s), &limit, &window); struct us_ssl_rare_t *st = us_ssl_rare_ensure(s_ssl(s)); - s->ssl_handshake_state = HANDSHAKE_RENEGOTIATION_PENDING; /* Wall-clock time can step backwards (NTP, manual adjustment); the * unsigned subtraction below would underflow and reset the window every * time. Only treat the window as elapsed when time has moved forward. */ @@ -2048,15 +2055,11 @@ static int ssl_renegotiate(struct us_socket_t *s) { st->reneg_window_start_ms = now_ms; st->reneg_count = 0; } - if (st->reneg_count >= limit) { - ssl_trigger_handshake(s, 0); - return 0; - } + /* Over the limit BoringSSL refuses, so every refusal queues the same SSL_R_NO_RENEGOTIATION. */ + if (st->reneg_count >= limit) SSL_set_renegotiate_mode(s_ssl(s), ssl_renegotiate_never); st->reneg_count++; - if (!SSL_renegotiate(s_ssl(s))) { - ssl_trigger_handshake(s, 0); - return 0; - } + if (!SSL_renegotiate(s_ssl(s))) return 0; + s->ssl_handshake_state = HANDSHAKE_RENEGOTIATION_PENDING; return 1; } @@ -2163,11 +2166,6 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void * (fetch, sockets) don't each have to check on_close themselves. */ ssl_trigger_handshake_econnreset(s); if (ssl_gone(s)) return s; - } else if (s->ssl_handshake_state == HANDSHAKE_RENEGOTIATION_PENDING) { - /* The session was established: only a protocol failure of the renegotiation is reported. */ - s->ssl_handshake_state = HANDSHAKE_COMPLETED; - ssl_dispatch_parked_reason(s); - if (ssl_gone(s)) return s; } /* code == 2 (forceful — `_destroy()` / `_handle.close()`): raw-close now, @@ -2473,9 +2471,7 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { return s; } -/* Reports a handshake that SSL_read finished, with the flight it sealed still - * held: what the owner writes from the callback leaves in the same segment. - * The read cursor of the shared BIO survives that JS. NULL once `s` is gone. */ +/* The flight that SSL_read sealed stays held across the report, so what the owner writes there leaves with it. NULL once `s` is gone. */ static struct us_socket_t *ssl_report_finished_handshake(struct us_socket_t *s, struct loop_ssl_data *loop_ssl_data, int retry_parked_write) { if (s->ssl_handshake_state == HANDSHAKE_COMPLETED || !SSL_is_init_finished(s_ssl(s))) return s; @@ -2497,6 +2493,30 @@ static struct us_socket_t *ssl_report_finished_handshake(struct us_socket_t *s, return s; } +/* What came before the failure goes first, like node's ClearOut. Whatever the owner does with the report, the connection closes here. */ +static struct us_socket_t *ssl_fail_established_session(struct us_socket_t *s, struct loop_ssl_data *loop_ssl_data, + int read) { + /* Taken now: the JS below can change the thread's queue. */ + char reason[US_SSL_FATAL_ERROR_REASON_MAX]; + int has_reason = us_ssl_take_error_reason(reason, sizeof(reason)); + s = ssl_report_finished_handshake(s, loop_ssl_data, 0); + if (!s) return NULL; + s->ssl_handshake_state = HANDSHAKE_COMPLETED; + ssl_flush_pending_events(s); + if (ssl_gone(s)) return NULL; + if (read) { + s = us_dispatch_data(s, loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING, read); + if (!s || ssl_gone(s)) return NULL; + } + s->ssl_fatal_error = 1; + if (has_reason) { + us_dispatch_handshake(s, 0, (struct us_bun_verify_error_t){.error = -71, .code = "EPROTO", .reason = reason}); + if (ssl_gone(s)) return NULL; + } + ssl_close(s, 0, NULL); + return NULL; +} + struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, int length) { /* See ssl_update_handshake: start this socket's SSL processing with a clean * per-thread error queue so a captured reason cannot belong to another @@ -2598,8 +2618,9 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i s = ssl_report_finished_handshake(s, loop_ssl_data, 0); if (!s) return NULL; if (ssl_renegotiate(s)) continue; - if (ssl_gone(s)) return NULL; - err = SSL_ERROR_SSL; + /* After our own close_notify or FIN the request has no answer: that is the end of our close. */ + if (us_internal_ssl_is_shut_down(s)) ERR_clear_error(); + return ssl_fail_established_session(s, loop_ssl_data, read); } else if (err == SSL_ERROR_ZERO_RETURN) { /* The close_notify can share a read with the peer's Finished: the owner hears of the handshake before the close below. */ s = ssl_report_finished_handshake(s, loop_ssl_data, 0); @@ -2646,9 +2667,11 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i return s; } + if (s->ssl_handshake_state != HANDSHAKE_PENDING || SSL_is_init_finished(s_ssl(s))) { + return ssl_fail_established_session(s, loop_ssl_data, read); + } /* SSL_read's name for a close_notify in place of a handshake message: https://github.com/oven-sh/bun/issues/44517 */ - if (s->ssl_handshake_state == HANDSHAKE_PENDING && - ERR_peek_error() == ERR_PACK(ERR_LIB_SSL, SSL_R_SSL_HANDSHAKE_FAILURE)) { + if (ERR_peek_error() == ERR_PACK(ERR_LIB_SSL, SSL_R_SSL_HANDSHAKE_FAILURE)) { ERR_clear_error(); ssl_trigger_handshake_econnreset(s); if (ssl_gone(s)) return NULL; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 1307c7cbcfb1..0a67e8df6b29 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -577,6 +577,8 @@ int us_ssl_ctx_add_ca_cert(struct ssl_ctx_st *ctx, const char *content); void us_internal_ssl_client_verify_defaults(struct ssl_st *ssl, struct ssl_ctx_st *ctx); /* 1 when the verify step of this handshake asked the owner for the server's name. */ int us_ssl_identity_checked(struct ssl_st *ssl); +/* Why the SSL_* call that just returned failed, NUL-terminated. Clears the thread's queue. 0 when nothing is queued. */ +int us_ssl_take_error_reason(char *reason, size_t length); /* For an SSL that no us_socket_t drives: its callbacks go to `wrapper`, which must outlive `ssl`. */ void us_ssl_set_wrapper(struct ssl_st *ssl, void *wrapper); /* `ctx` is the X509_STORE_CTX of a verify callback. */ diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index 9dfb069ab30a..8dd78f0040d3 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -987,7 +987,6 @@ unsafe extern "C" { // Thread-local error queue — no pointer args, no preconditions. pub safe fn ERR_clear_error(); pub safe fn ERR_get_error() -> u32; - pub safe fn ERR_peek_error() -> u32; pub safe fn ERR_peek_last_error() -> u32; pub fn ERR_error_string(packed_error: u32, buf: *mut c_char) -> *mut c_char; // `ERR_error_string_n` declared once in the crypto/err block above. diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index b4ec4eb18f59..d249bc955efe 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -178,6 +178,7 @@ function socketError(this: TLSProxySocket, _socket: NativeHandle, err: NodeJS.Er if (!ctx.server._requestCert && err?.code === "UNABLE_TO_GET_ISSUER_CERT") { return; } + if (this._secureEstablished && err?.code === "EPROTO") err = require("internal/tls").tlsHandshakeError(err); this.destroy(err); } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 6077abb6dd47..6dde39c5d957 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -696,6 +696,11 @@ function onConnectEnd() { } } +// The native layer hands a fatal TLS error on an established session to `error`, then closes. +function establishedTLSError(self, error) { + if (self._secureEstablished && error?.code === "EPROTO") return require("internal/tls").tlsHandshakeError(error); +} + // Node reports a throwing 'data' listener as uncaughtException and keeps reading. function pushDataToSocket(self, socket, buffer) { // TLS took over the fd; the wrapped socket reads nothing: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 @@ -1319,6 +1324,8 @@ const ServerHandlers = { const bunTLS = data[bunTlsSymbol]; if (typeof bunTLS === "function") { + const tlsError = establishedTLSError(data, error); + if (tlsError) error = tlsError; const callback = data[kwriteCallback]; if (callback) { data[kwriteCallback] = null; @@ -1339,6 +1346,8 @@ const ServerHandlers = { ) { // Ignore server's authorization errors data.destroy(); + } else if (tlsError && !callback) { + data._emitTLSError(error); } else { // Node emits through _emitTLSError and leaves the socket alive. Bun // still destroys here: its tls.Server completes the handshake for a @@ -1669,11 +1678,17 @@ const SocketHandlers2 = { const { self } = socket.data; if (self._hadError) return; self._hadError = true; + const tlsError = establishedTLSError(self, error); + if (tlsError) error = tlsError; const callback = self[kwriteCallback]; if (callback) { self[kwriteCallback] = null; callback(error); + } else if (tlsError) { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 + self._emitTLSError(error); + return; } if (!self.destroyed) process.nextTick(destroyNT, self, error); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 34b2aa65e849..64dcf36154cb 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1808,6 +1808,7 @@ impl NewSocket { if !this.has_handlers() || this.flags.get().contains(Flags::FINALIZING) { return Ok(()); } + let first_report = !this.flags.get().contains(Flags::HANDSHAKE_COMPLETE); this.update_flags(|f| f.insert(Flags::HANDSHAKE_COMPLETE)); this.socket.set(s); if this.socket.get().is_detached() { @@ -1927,9 +1928,17 @@ impl NewSocket { return Ok(()); } + // A TLS error on the established session. With no `error` handler it stays silent: the peer chose it. + let is_error = !first_report && success == 0; + if is_error { + callback = handlers.on_error(); + } + // Use open callback when handshake is not provided if callback.is_empty() { - callback = handlers.on_open(); + if first_report { + callback = handlers.on_open(); + } if callback.is_empty() { if reject_unauthorized { this.reject_unauthorized_connection(); @@ -1952,7 +1961,10 @@ impl NewSocket { let result: JSValue; // open callback only have 1 parameters and its the socket // you should use getAuthorizationError and authorized getter to get those values in this case - if is_open { + if is_error { + // `call_error_handler` below takes it. + result = super::uws_jsc::verify_error_to_js(&ssl_error, &global); + } else if is_open { result = match callback.call(&global, this_value, &[this_value]) { Ok(v) => v, Err(err) => global.take_exception(err), diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 3f9314459098..165343e80df8 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -162,16 +162,15 @@ pub mod ssl_wrapper { mod boring_sys { pub(super) use bun_boringssl::c::{ BIO_ctrl_pending, BIO_free, BIO_new, BIO_read, BIO_reset, BIO_s_mem, - BIO_set_mem_eof_return, BIO_write, ERR_clear_error, ERR_error_string_n, ERR_peek_error, - OwnedSslCtx, SSL, SSL_CTX, SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, - SSL_ERROR_WANT_RENEGOTIATE, SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, - SSL_RECEIVED_SHUTDOWN, SSL_SESSION, SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, - SSL_VERIFY_NONE, SSL_VERIFY_PEER, SSL_do_handshake, SSL_free, SSL_get_error, - SSL_get_rbio, SSL_get_shutdown, SSL_get_verify_result, SSL_get_wbio, - SSL_is_init_finished, SSL_new, SSL_pending, SSL_read, SSL_renegotiate, - SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, SSL_set_renegotiate_mode, - SSL_set_session_id_context, SSL_set_verify, SSL_shutdown, SSL_write, X509_STORE_CTX, - ssl_renegotiate_explicit, ssl_renegotiate_never, + BIO_set_mem_eof_return, BIO_write, ERR_clear_error, OwnedSslCtx, SSL, SSL_CTX, + SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, + SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_SESSION, + SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, SSL_VERIFY_NONE, SSL_VERIFY_PEER, + SSL_do_handshake, SSL_free, SSL_get_error, SSL_get_rbio, SSL_get_shutdown, + SSL_get_verify_result, SSL_get_wbio, SSL_is_init_finished, SSL_new, SSL_pending, + SSL_read, SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, + SSL_set_renegotiate_mode, SSL_set_session_id_context, SSL_set_verify, SSL_shutdown, + SSL_write, X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never, }; } @@ -397,9 +396,9 @@ pub mod ssl_wrapper { Established, /// `set_inline_reject` stopped the handshake on the peer's chain. InlineRejected, - /// `SSL_do_handshake` failed. Carries the queued reason of a fatal failure. + /// `SSL_do_handshake` failed, or `SSL_read` on the established session. Carries the queued reason of a fatal failure. HandshakeError(Option<&'a core::ffi::CStr>), - /// Closed before the handshake finished, or a renegotiation was refused. + /// We closed before the first handshake finished. Aborted, /// The peer's close_notify ended the first handshake. PeerClosed, @@ -928,10 +927,7 @@ pub mod ssl_wrapper { }, ), // node:tls reads a failure with no error after end() as its own close. - HandshakeOutcome::Aborted if self.is_shutdown() => { - (false, us_bun_verify_error_t::default()) - } - HandshakeOutcome::Aborted => (false, self.verify_error()), + HandshakeOutcome::Aborted => (false, us_bun_verify_error_t::default()), HandshakeOutcome::PeerClosed => (false, us_bun_verify_error_t::peer_disconnected()), }; self.flags.set_authorized(success); @@ -977,16 +973,11 @@ pub mod ssl_wrapper { unsafe { us_ssl_socket_verify_error_from_ssl(ssl.as_ptr()) } } - /// Formats the oldest queued error, like `ssl_park_fatal_reason` in openssl.c, and clears the queue. + /// Why the `SSL_*` call that just returned failed. Clears the thread's queue. fn take_queued_error(buf: &mut [u8; FATAL_ERROR_REASON_MAX]) -> Option<&core::ffi::CStr> { - let packed = boring_sys::ERR_peek_error(); - boring_sys::ERR_clear_error(); - if packed == 0 { - return None; - } // SAFETY: buf is a valid mutable buffer for `buf.len()` bytes. - unsafe { - boring_sys::ERR_error_string_n(packed, buf.as_mut_ptr().cast(), buf.len()); + if unsafe { us_ssl_take_error_reason(buf.as_mut_ptr().cast(), buf.len()) } == 0 { + return None; } core::ffi::CStr::from_bytes_until_nul(buf).ok() } @@ -1028,8 +1019,6 @@ pub mod ssl_wrapper { return true; } - // The queue is per thread: what another SSL left there is not this handshake's reason. - boring_sys::ERR_clear_error(); // SAFETY: ssl is a live SSL*. let result = unsafe { boring_sys::SSL_do_handshake(ssl.as_ptr()) }; @@ -1152,17 +1141,14 @@ pub mod ssl_wrapper { if just_read <= 0 { // SAFETY: ssl is still valid. let err = unsafe { boring_sys::SSL_get_error(ssl.as_ptr(), just_read) }; - boring_sys::ERR_clear_error(); if err != boring_sys::SSL_ERROR_WANT_READ && err != boring_sys::SSL_ERROR_WANT_WRITE { + let mut is_fatal = err == boring_sys::SSL_ERROR_SSL + || err == boring_sys::SSL_ERROR_SYSCALL; if err == boring_sys::SSL_ERROR_WANT_RENEGOTIATE { - self.flags - .set_handshake_state(HandshakeState::HandshakeRenegotiationPending); - // An over-limit renegotiation request is treated - // like a failed SSL_renegotiate(). The count - // resets each MAX_RENEGOTIATION_WINDOW, matching + // The count resets each MAX_RENEGOTIATION_WINDOW, matching // the C path's `us_reneg_policy`. let now = std::time::Instant::now(); match self.renegotiation_window_start.get() { @@ -1173,34 +1159,46 @@ pub mod ssl_wrapper { self.renegotiation_count.set(0); } } - let renegotiation_allowed = - self.renegotiation_count.get() < MAX_RENEGOTIATIONS; + let over_limit = self.renegotiation_count.get() >= MAX_RENEGOTIATIONS; self.renegotiation_count .set(self.renegotiation_count.get().saturating_add(1)); // SAFETY: ssl is still valid. - let renegotiated = renegotiation_allowed - && unsafe { boring_sys::SSL_renegotiate(ssl.as_ptr()) } != 0; - if !renegotiated { - self.flags - .set_handshake_state(HandshakeState::HandshakeCompleted); - // we failed to renegotiate - self.trigger_handshake_callback(HandshakeOutcome::Aborted); - self.trigger_close_callback(); - return false; + let renegotiated = unsafe { + // BoringSSL refuses, so every refusal queues the same error. + if over_limit { + boring_sys::SSL_set_renegotiate_mode( + ssl.as_ptr(), + boring_sys::ssl_renegotiate_never, + ); + } + boring_sys::SSL_renegotiate(ssl.as_ptr()) + } != 0; + if renegotiated { + self.flags.set_handshake_state( + HandshakeState::HandshakeRenegotiationPending, + ); + // SSL_read drives the renegotiation. + continue; + } + // After our own close_notify the request has no answer: that is the end of our close. + if self.is_shutdown() { + boring_sys::ERR_clear_error(); } - // ok, we are done here, we need to call SSL_read again - // this dont mean that we are done with the handshake renegotiation - // we need to call SSL_read again - continue; + is_fatal = true; } else if err == boring_sys::SSL_ERROR_ZERO_RETURN { // Remotely-Initiated Shutdown // See: https://www.openssl.org/docs/manmaster/man3/SSL_shutdown.html self.flags.set_received_ssl_shutdown(true); self.handle_end_of_renegotiation(); } - if err == boring_sys::SSL_ERROR_SSL || err == boring_sys::SSL_ERROR_SYSCALL - { + // Taken now: the callbacks below run JS, which can change the queue. + let mut reason_buf = [0u8; FATAL_ERROR_REASON_MAX]; + let reason = Self::take_queued_error(&mut reason_buf).filter(|_| is_fatal); + if is_fatal { + // Before the data callback: an owner that is done there must not keep this wrapper. self.flags.set_fatal_error(true); + self.flags + .set_handshake_state(HandshakeState::HandshakeCompleted); } // flush the reading @@ -1224,10 +1222,21 @@ pub mod ssl_wrapper { // 2-step shutdown, last: write_data fails once our close_notify is out. let _ = self.shutdown(false); } + if is_fatal { + // Our fatal alert leaves before the owner tears the transport down. + self.handle_writing(buffer); + } + if reason.is_some() { + self.trigger_handshake_callback(HandshakeOutcome::HandshakeError( + reason, + )); + } + // Whatever the owner did with the report. self.trigger_close_callback(); return false; } else { log!("wanna read/write just break"); + boring_sys::ERR_clear_error(); self.handle_end_of_renegotiation(); if self.ssl.get().is_none() || self.flags.closed_notified() { return false; @@ -1527,6 +1536,8 @@ pub mod ssl_wrapper { fn SSL_SESSION_up_ref(session: *mut boring_sys::SSL_SESSION) -> c_int; /// openssl.c: 1 when the verify step of this handshake asked the owner for the server's name. fn us_ssl_identity_checked(ssl: *mut boring_sys::SSL) -> c_int; + /// openssl.c: formats the reason into `reason[..length]`, NUL-terminated. 0 when nothing is queued. + fn us_ssl_take_error_reason(reason: *mut c_char, length: usize) -> c_int; /// openssl.c: makes the callbacks of `ssl` go to `wrapper`. `wrapper` must outlive `ssl`. fn us_ssl_set_wrapper(ssl: *mut boring_sys::SSL, wrapper: *mut c_void); /// openssl.c: the pointer `us_ssl_set_wrapper` stored on the `SSL` that `ctx` verifies, or null. diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index b15921fc40db..db1372dc76ad 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -5886,3 +5886,53 @@ describe.concurrent("write() that is the first to observe the peer's reset retur expect(exitCode).toBe(0); }); }); + +describe("a fatal TLS alert that arrives after the handshake", () => { + // A TLS 1.3 server checks the client certificate after the client's + // handshake is done, so its certificate_required alert arrives after the + // client's handshake callback. Returns the client's events up to 'close'. + async function rejectedClientEvents(withErrorHandler: boolean) { + const events: string[] = []; + const closed = Promise.withResolvers(); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: tls.key, cert: tls.cert, ca: tls.cert, requestCert: true, rejectUnauthorized: true }, + socket: { + data() {}, + error() {}, + }, + }); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + socket: { + handshake: (socket, success) => events.push(`handshake ${success} ${socket.getTLSVersion()}`), + data: () => events.push("data"), + error: withErrorHandler + ? (_socket, err) => events.push(`error ${(err as Error & { code?: string }).code}: ${err.message}`) + : undefined, + close: (_socket, err) => { + events.push(`close ${err}`); + closed.resolve(); + }, + }, + }); + await closed.promise; + return events; + } + + it("reaches the error handler before close", async () => { + expect(await rejectedClientEvents(true)).toEqual([ + "handshake true TLSv1.3", + "error EPROTO: error:1000045c:SSL routines:OPENSSL_internal:TLSV1_ALERT_CERTIFICATE_REQUIRED", + "close undefined", + ]); + }); + + it("only closes a socket that has no error handler", async () => { + // The peer's alert must not become an uncaught exception in this process. + expect(await rejectedClientEvents(false)).toEqual(["handshake true TLSv1.3", "close undefined"]); + }); +}); diff --git a/test/js/node/http2/node-http2-upgrade.test.mts b/test/js/node/http2/node-http2-upgrade.test.mts index 6cdb2b3b9755..b7baaa594557 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -642,6 +642,55 @@ describe("HTTP/2 upgrade — failed handshake", () => { }); }); +describe("HTTP/2 upgrade — fatal TLS error after the handshake", () => { + test("a record that fails to decrypt reaches the server as sessionError", async () => { + const h2Server = http2.createSecureServer(TLS, (_req, res) => { + res.writeHead(200); + res.end("ok"); + }); + h2Server.on("error", () => {}); + // A clean session 'close' with no error before it is the bug. + const outcome = new Promise<{ event: string; code?: string }>(resolve => { + h2Server.on("sessionError", (err: NodeJS.ErrnoException) => resolve({ event: "sessionError", code: err.code })); + h2Server.on("session", session => session.on("close", () => resolve({ event: "close" }))); + }); + const netServer = net.createServer(socket => { + socket.on("error", () => {}); + h2Server.emit("connection", socket); + }); + // A plain proxy in front of the net.Server, to inject bytes toward it. + let toServer: net.Socket | undefined; + const proxy = net.createServer(c => { + toServer = net.connect((netServer.address() as net.AddressInfo).port, "127.0.0.1"); + c.pipe(toServer); + toServer.pipe(c); + c.on("error", () => {}); + toServer.on("error", () => {}); + }); + let client: http2.ClientHttp2Session | undefined; + try { + await once(netServer.listen(0, "127.0.0.1"), "listening"); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + client = connectClient((proxy.address() as net.AddressInfo).port); + const first = await request(client, "GET", "/"); + assert.strictEqual(first.status, 200); + + // application_data, 32 bytes of ciphertext that cannot authenticate. + toServer!.write(Buffer.concat([Buffer.from([0x17, 0x03, 0x03, 0x00, 0x20]), Buffer.alloc(32, 0x42)])); + + assert.deepStrictEqual(await outcome, { + event: "sessionError", + code: "ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", + }); + } finally { + client?.destroy(); + toServer?.destroy(); + proxy.close(); + netServer.close(); + } + }); +}); + if (typeof Bun !== "undefined") { describe("Node.js compatibility", () => { test("tests should run on node.js", async () => { diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index 4baea2f83d76..71d4985b0e04 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { once } from "events"; import { readFileSync } from "fs"; -import { bunEnv, bunExe, invalidTls, tmpdirSync } from "harness"; +import { bunEnv, bunExe, tls as harnessCert, invalidTls, tmpdirSync } from "harness"; import type { AddressInfo } from "node:net"; import type { Server, TLSSocket } from "node:tls"; import { join } from "path"; @@ -160,6 +160,51 @@ it("Request cert from TLS1.2 client that doesn't have one.", async () => { } }); +it("Request cert from TLS1.3 client that doesn't have one.", async () => { + // TLS 1.3 finishes the client's handshake before the server checks the + // certificate, so the alert arrives after 'secureConnect'. + // The agent10 fixtures above have a 1024-bit CA, which OpenSSL rejects as too + // weak, so this case uses the 2048-bit harness certificate instead. + const server = tls.createServer({ + key: harnessCert.key, + cert: harnessCert.cert, + ca: [harnessCert.cert], + requestCert: true, + }); + const serverError = once(server, "tlsClientError"); + await once(server.listen(0, "127.0.0.1"), "listening"); + + try { + const events: string[] = []; + const { promise: closed, resolve: onClose } = Promise.withResolvers(); + const client = tls.connect({ + host: "127.0.0.1", + port: (server.address() as AddressInfo).port, + minVersion: "TLSv1.3", + ca: harnessCert.cert, + }); + client.on("secureConnect", () => events.push("secureConnect")); + client.on("data", () => events.push("data")); + client.on("error", (err: any) => events.push(`error ${err.code}`)); + client.on("end", () => events.push("end")); + client.on("close", (hadError: boolean) => { + events.push(`close ${hadError}`); + onClose(); + }); + await closed; + + // BoringSSL and OpenSSL name the same alert differently. + const alert = process.features.openssl_is_boringssl + ? "ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED" + : "ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED"; + expect(events).toEqual(["secureConnect", `error ${alert}`, "end", "close false"]); + const [err] = await serverError; + expect(err.code).toBe("ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE"); + } finally { + server.close(); + } +}); + it("Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer.", async () => { await connect({ client: { diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 772d3365b866..a3854a5f4b45 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -222,8 +222,167 @@ it("should be able to grab the JSStreamSocket constructor", () => { //@ts-ignore expect(socket._handle._parentWrap.constructor).toBeFunction(); }); +// A TLS record that fails after the handshake is a fatal protocol error. Node +// reports it as the socket's ERR_SSL_ 'error' and keeps the socket, so +// a clean 'end' follows when the connection goes away. The cases that use +// these helpers put a TCP proxy between the two TLS peers and inject a record +// that cannot authenticate once both handshakes are done. Every assertion also +// holds on Node.js; only the alert's code name depends on the SSL library. + +// application_data, legacy version TLS 1.2, 32 bytes of ciphertext that cannot +// authenticate: the receiver fails the AEAD open and alerts bad_record_mac. +const BAD_RECORD = Buffer.concat([Buffer.from([0x17, 0x03, 0x03, 0x00, 0x20]), Buffer.alloc(32, 0x42)]); +// BoringSSL and OpenSSL 3 name the bad_record_mac alert differently. +const ALERT_BAD_RECORD_MAC = (process.features as { openssl_is_boringssl?: boolean }).openssl_is_boringssl + ? "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC" + : "ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC"; + +type FaultedPeers = { + client: TLSSocket; + server: TLSSocket; + toClient: net.Socket; + toServer: net.Socket; + // Append `bytes` to the next chunk the proxy forwards from the server. + appendToNextServerChunk(bytes: Buffer): void; +}; + +async function withFaultProxy( + engines: { connect: typeof tlsConnect; serverOverDuplex?: boolean }, + body: (peers: FaultedPeers) => Promise, +): Promise { + const sockets: (net.Socket | undefined)[] = []; + const servers: net.Server[] = []; + try { + const server = tls.createServer(COMMON_CERT_); + servers.push(server); + const accepted = once(server, "secureConnection") as Promise<[TLSSocket]>; + let upstream: net.Server = server; + if (engines.serverOverDuplex) { + // This server never listens. A TCP server hands it each connection as a generic Duplex. + upstream = net.createServer(raw => { + sockets.push(raw); + server.emit("connection", new SocketProxy(raw)); + }); + servers.push(upstream); + } + await once(upstream.listen(0, "127.0.0.1"), "listening"); + + let toClient: net.Socket | undefined; + let toServer: net.Socket | undefined; + let appendOnce: Buffer | undefined; + const proxy = net.createServer(c => { + toClient = c; + toServer = net.connect((upstream.address() as AddressInfo).port, "127.0.0.1"); + sockets.push(c, toServer); + c.on("data", chunk => toServer!.write(chunk)); + toServer.on("data", chunk => { + if (appendOnce) { + chunk = Buffer.concat([chunk, appendOnce]); + appendOnce = undefined; + } + c.write(chunk); + }); + // However one side goes away, the other side gets a clean FIN. + c.on("close", () => toServer!.end()); + toServer.on("close", () => c.end()); + c.on("error", () => {}); + toServer.on("error", () => {}); + }); + servers.push(proxy); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + + const client = engines.connect({ + host: "127.0.0.1", + port: (proxy.address() as AddressInfo).port, + servername: "localhost", + rejectUnauthorized: false, + }); + sockets.push(client); + await once(client, "secureConnect"); + const [accepted_] = await accepted; + sockets.push(accepted_); + return await body({ + client, + server: accepted_, + toClient: toClient!, + toServer: toServer!, + appendToNextServerChunk: bytes => (appendOnce = bytes), + }); + } finally { + for (const socket of sockets) socket?.destroy(); + for (const server of servers) server.close(); + } +} + +// The 'error', 'end' and 'close' events of `socket`, in order, up to the first +// 'end' or 'close'. Node keeps `socket` open after the error, so `peer` ends +// then: its FIN is what ends `socket` there. +function faultEvents(socket: TLSSocket, peer: TLSSocket) { + const events: string[] = []; + let library: string | undefined; + const { promise: settled, resolve } = Promise.withResolvers(); + socket.on("error", (err: NodeJS.ErrnoException & { library?: string }) => { + events.push(`error ${err.code}`); + library = err.library; + peer.end(); + }); + socket.on("end", () => { + events.push("end"); + resolve(); + }); + socket.on("close", hadError => { + events.push(`close ${hadError}`); + resolve(); + }); + socket.resume(); + peer.on("error", () => {}); + peer.resume(); + return settled.then(() => ({ events: [...events], library })); +} + for (const { name, connect } of tests) { describe(name, () => { + it("a record that fails to decrypt after the handshake is the socket's ERR_SSL_* error", async () => { + const result = await withFaultProxy({ connect }, ({ client, server, toClient }) => { + const events = faultEvents(client, server); + toClient.write(BAD_RECORD); + return events; + }); + expect(result).toEqual({ + events: ["error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", "end"], + library: "SSL routines", + }); + }); + + it("a paused reader keeps the data that came before the record that fails to decrypt", async () => { + const received = await withFaultProxy({ connect }, async ({ client, server, appendToNextServerChunk }) => { + client.pause(); + const failed = once(client, "error"); + appendToNextServerChunk(BAD_RECORD); + server.write("before"); + server.on("error", () => {}); + await failed; + // Node keeps the socket open after the error: the peer's FIN is what ends it there. + server.end(); + return await new Response(stream.Readable.toWeb(client) as ReadableStream).text(); + }); + expect(received).toBe("before"); + }); + + it("the peer's fatal alert after the handshake is the socket's ERR_SSL_* error", async () => { + // The bad record goes to the server, whose SSL_read fails and sends a + // bad_record_mac alert back. The client's SSL_read fails on that alert. + const result = await withFaultProxy({ connect }, ({ client, server, toServer }) => { + const events = faultEvents(client, server); + toServer.write(BAD_RECORD); + return events; + }); + expect(result).toEqual({ + events: [`error ${ALERT_BAD_RECORD_MAC}`, "end"], + library: "SSL routines", + }); + }); + itNetwork("should work with alpnProtocols", done => { try { let socket: TLSSocket | null = connect({ @@ -697,6 +856,52 @@ it("setSession() after the handshake started is ignored on every node:tls door", expect(exitCode).toBe(0); }); +describe("a fatal post-handshake SSL error over a Duplex", () => { + it("is still reported when a 'data' listener writes back", async () => { + // Good data and the bad record arrive in one chunk. The engine delivers + // the data first, the listener's write hits the now-fatal SSL, and the + // read's error must still be the one that surfaces. + const result = await withFaultProxy({ connect: duplexProxy }, async peers => { + const { client, server, appendToNextServerChunk } = peers; + const events = faultEvents(client, server); + client.on("data", () => client.write("back")); + server.write("first"); + await once(client, "data"); + appendToNextServerChunk(BAD_RECORD); + server.write("second"); + return events; + }); + // What follows the error depends on what the runtime does with the write. + expect({ first: result.events[0], library: result.library }).toEqual({ + first: "error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", + library: "SSL routines", + }); + }); + + it("sends its own fatal alert to the peer", async () => { + const code = await withFaultProxy({ connect: duplexProxy }, async ({ client, server, toClient }) => { + client.on("error", () => {}); + const alerted = once(server, "error"); + toClient.write(BAD_RECORD); + return (await alerted)[0].code; + }); + expect(code).toBe(ALERT_BAD_RECORD_MAC); + }); + + it("is reported on a server socket", async () => { + const result = await withFaultProxy({ connect: tlsConnect, serverOverDuplex: true }, peers => { + const { client, server, toServer } = peers; + const events = faultEvents(server, client); + toServer.write(BAD_RECORD); + return events; + }); + expect(result).toEqual({ + events: ["error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", "end"], + library: "SSL routines", + }); + }); +}); + it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", async () => { // d2i_SSL_SESSION returns an owned SSL_SESSION; SSL_set_session takes its own // reference ("the caller retains ownership"), so the caller's reference must diff --git a/test/js/node/tls/node-tls-namedpipes.test.ts b/test/js/node/tls/node-tls-namedpipes.test.ts index 38862d0a8ee6..14b61c5a34f2 100644 --- a/test/js/node/tls/node-tls-namedpipes.test.ts +++ b/test/js/node/tls/node-tls-namedpipes.test.ts @@ -392,3 +392,78 @@ describe("tls.connect({ socket }) over a named pipe that is already connected", } }); }); + +// Same contract as "tls.connect over a Duplex reports a fatal post-handshake +// SSL error" in node-tls-connect.test.ts, with both TLS peers on a named pipe. +// A plain pipe proxy between them injects a record that cannot authenticate +// once the handshake has completed on both sides. +describe("a fatal post-handshake SSL error over a named pipe", () => { + const BAD_RECORD = Buffer.concat([Buffer.from([0x17, 0x03, 0x03, 0x00, 0x20]), Buffer.alloc(32, 0x42)]); + // BoringSSL and OpenSSL 3 name the bad_record_mac alert differently. + const ALERT_BAD_RECORD_MAC = (process.features as { openssl_is_boringssl?: boolean }).openssl_is_boringssl + ? "ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC" + : "ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC"; + + type Outcome = { event: string; code?: string; library?: string }; + // Settles on the 'error' (expected), or on a 'close' with no 'error' before it (the bug). + function firstErrorOrClose(socket: net.Socket): Promise { + return new Promise(resolve => { + socket.once("error", (err: NodeJS.ErrnoException & { library?: string }) => + resolve({ event: "error", code: err.code, library: err.library }), + ); + socket.once("close", () => resolve({ event: "close" })); + }); + } + + async function run(inject: (toClient: net.Socket, toServer: net.Socket) => void) { + let toClient: net.Socket | undefined; + let toServer: net.Socket | undefined; + let client: ReturnType | undefined; + let serverSocket: ReturnType | undefined; + const serverPipe = `\\\\.\\pipe\\test\\${randomUUID()}`; + const proxyPipe = `\\\\.\\pipe\\test\\${randomUUID()}`; + const server = createServer(tls); + const serverOutcome = Promise.withResolvers(); + server.on("secureConnection", s => firstErrorOrClose(s).then(serverOutcome.resolve)); + const proxy = net.createServer(c => { + toClient = c; + toServer = net.connect(serverPipe); + c.pipe(toServer); + toServer.pipe(c); + c.on("error", () => {}); + toServer.on("error", () => {}); + }); + try { + await once(server.listen(serverPipe), "listening"); + const serverSecure = once(server, "secureConnection"); + await once(proxy.listen(proxyPipe), "listening"); + + client = connect({ path: proxyPipe, rejectUnauthorized: false }); + const clientOutcome = firstErrorOrClose(client); + await once(client, "secureConnect"); + [serverSocket] = await serverSecure; + + inject(toClient!, toServer!); + return { client: await clientOutcome, server: await serverOutcome.promise }; + } finally { + for (const s of [client, serverSocket, toClient, toServer]) s?.destroy(); + proxy.close(); + server.close(); + } + } + + it.if(isWindows)("a record that fails to decrypt on the client", async () => { + expect(await run(toClient => void toClient.write(BAD_RECORD))).toEqual({ + client: { event: "error", code: "ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", library: "SSL routines" }, + // The client's bad_record_mac alert reaches the server as its own error. + server: { event: "error", code: ALERT_BAD_RECORD_MAC, library: "SSL routines" }, + }); + }); + + it.if(isWindows)("a record that fails to decrypt on the server", async () => { + expect(await run((_toClient, toServer) => void toServer.write(BAD_RECORD))).toEqual({ + client: { event: "error", code: ALERT_BAD_RECORD_MAC, library: "SSL routines" }, + server: { event: "error", code: "ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", library: "SSL routines" }, + }); + }); +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index a222c77ddd0a..f346ab9520d4 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1527,7 +1527,8 @@ describe("setSecureContext() on a listening server", () => { let saved: Buffer | undefined; socket.on("session", s => (saved = s)); socket.resume(); - await once(socket, "close"); + // Not once(): it rejects on the 'error' of a client that a TLS 1.3 server refuses after the handshake. + await new Promise(closed => socket.once("close", closed)); expect(verdicts.length).toBe(seen + 1); return { verdict: verdicts[seen], session: saved }; }; @@ -4537,3 +4538,153 @@ describe("an accepted socket whose stream state changes before or during its han await once(client, "close"); }); }); + +describe("fatal TLS error after the handshake", () => { + // A TLS 1.3 application_data record whose payload does not authenticate. + function badRecord() { + const payload = Buffer.alloc(32, 0xab); + return Buffer.concat([Buffer.from([0x17, 0x03, 0x03, 0x00, payload.length]), payload]); + } + + // Each client below sends its bad record and the FIN together, the way a peer + // that gives up does. Node keeps an errored socket open until that FIN, so + // both runtimes reach 'end' and close(false) after the error. + function recordEvents(socket: TLSSocket, events: string[], closed: PromiseWithResolvers) { + socket.on("data", chunk => events.push(`data ${chunk}`)); + socket.on("error", (err: Error & { code?: string }) => events.push(`error ${err.code}`)); + socket.on("end", () => events.push("end")); + socket.on("close", hadError => { + events.push(`close ${hadError}`); + closed.resolve(); + }); + } + + // A TCP relay from the client to the server. `tamper` gets each chunk that + // the client sends, with its index, and returns the bytes to forward and + // whether to send the FIN with them. + async function startRelay(serverPort: number, tamper: (chunk: Buffer, index: number) => [Buffer, boolean]) { + const relay = net.createServer(downstream => { + const upstream = net.connect(serverPort, "127.0.0.1"); + upstream.pipe(downstream); + let index = 0; + downstream.on("data", chunk => { + const [bytes, fin] = tamper(chunk, index++); + if (fin) upstream.end(bytes); + else upstream.write(bytes); + }); + downstream.on("close", () => upstream.destroy()); + downstream.on("error", () => {}); + upstream.on("error", () => {}); + }); + await once(relay.listen(0, "127.0.0.1"), "listening"); + return relay; + } + + it("reports a record that does not decrypt as an 'error' on the accepted socket", async () => { + const events: string[] = []; + const closed = Promise.withResolvers(); + let error: (Error & { library?: string; reason?: string }) | undefined; + const server = createServer(COMMON_CERT, socket => { + recordEvents(socket, events, closed); + socket.once("error", err => (error = err)); + // The client sends the bad record when this arrives, so the server's + // handshake is complete by then. + socket.write("hello"); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + raw.on("error", () => {}); + const client = connect({ socket: raw, rejectUnauthorized: false }); + client.on("error", () => {}); + client.once("data", () => raw.end(badRecord())); + try { + await closed.promise; + expect(events).toEqual(["error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", "end", "close false"]); + // BoringSSL names the reason DECRYPTION_FAILED_OR_BAD_RECORD_MAC, OpenSSL + // spells the same reason in words. + expect({ library: error?.library, reason: error?.reason?.toUpperCase().replaceAll(" ", "_") }).toEqual({ + library: "SSL routines", + reason: "DECRYPTION_FAILED_OR_BAD_RECORD_MAC", + }); + } finally { + client.destroy(); + raw.destroy(); + server.close(); + } + }); + + it("delivers the data that arrives with the bad record before the 'error'", async () => { + const events: string[] = []; + const closed = Promise.withResolvers(); + const server = createServer(COMMON_CERT, socket => { + recordEvents(socket, events, closed); + socket.write("hello"); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + // The relay appends the bad record to the client's next write, so the + // server reads both in one read. + let appendBadRecord = false; + const relay = await startRelay((server.address() as AddressInfo).port, chunk => { + if (!appendBadRecord) return [chunk, false]; + appendBadRecord = false; + return [Buffer.concat([chunk, badRecord()]), true]; + }); + const client = connect({ + port: (relay.address() as AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + }); + client.on("error", () => {}); + try { + await once(client, "data"); + appendBadRecord = true; + client.write("ping"); + await closed.promise; + expect(events).toEqual(["data ping", "error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", "end", "close false"]); + } finally { + client.destroy(); + relay.close(); + server.close(); + } + }); + + it("reports the handshake and then the 'error' when one read holds the Finished and the bad record", async () => { + const events: string[] = []; + const closed = Promise.withResolvers(); + const server = createServer({ ...COMMON_CERT, minVersion: "TLSv1.3" }, socket => { + events.push("secureConnection"); + recordEvents(socket, events, closed); + }); + server.on("tlsClientError", (err: Error & { code?: string }) => { + events.push(`tlsClientError ${err.code}`); + closed.resolve(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + // The client's second write is the flight that ends with its Finished. The + // relay appends the bad record to it, so the server's handshake completes + // in the same read that fails. + const relay = await startRelay((server.address() as AddressInfo).port, (chunk, index) => + index === 1 ? [Buffer.concat([chunk, badRecord()]), true] : [chunk, false], + ); + const client = connect({ + port: (relay.address() as AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + minVersion: "TLSv1.3", + }); + client.on("error", () => {}); + try { + await closed.promise; + expect(events).toEqual([ + "secureConnection", + "error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", + "end", + "close false", + ]); + } finally { + client.destroy(); + relay.close(); + server.close(); + } + }); +}); diff --git a/test/js/node/tls/renegotiation.test.ts b/test/js/node/tls/renegotiation.test.ts index 8aa245a4c80e..7fdde3ada6ba 100644 --- a/test/js/node/tls/renegotiation.test.ts +++ b/test/js/node/tls/renegotiation.test.ts @@ -350,6 +350,507 @@ it("should terminate the connection when the peer exceeds the renegotiation limi expect(await outcome).toBe("closed"); }); +// The server starts a renegotiation only when the client sends data, and writes "done N" when renegotiation N +// completed. So each handshake report sits between two pieces of data, and request 4 is the one that the client +// refuses (the limit is 3 in 600 s). For the message "data first" the server writes "before N" ahead of request N. +// Two TCP proxies in front of the server shape what each side reads. The process prints three ports: +// 1. The server. +// 2. A proxy that gives the client "before 4" and request 4 in one write, so one read holds both. +// 3. A proxy that gives the server the client's first message only after the client's close_notify, and nothing +// after that message. So the request reaches a client that already ended its write side. +const pingPongRenegotiationServer = /* js */ ` + const net = require("net"); + const tls = require("tls"); + // The server counts handshakes too. Only the limit of the client is under test. + tls.CLIENT_RENEG_LIMIT = 100; + const server = tls.createServer( + { cert: process.env.SERVER_CERT, key: process.env.SERVER_KEY, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" }, + socket => { + socket.on("error", () => {}); + let asked = 0; + socket.on("data", chunk => { + const n = ++asked; + if (String(chunk) === "data first") socket.write("before " + n); + socket.renegotiate({ rejectUnauthorized: false }, err => { + if (!err) socket.write("done " + n); + }); + }); + }, + ); + + const APPLICATION_DATA = 23; + const HANDSHAKE = 22; + const ALERT = 21; + // Takes the complete TLS records off the front of a buffer. + function takeRecords(buffer) { + const records = []; + while (buffer.length >= 5 && buffer.length >= 5 + buffer.readUInt16BE(3)) { + const length = 5 + buffer.readUInt16BE(3); + records.push(buffer.subarray(0, length)); + buffer = buffer.subarray(length); + } + return { records, rest: buffer }; + } + function proxy(allowHalfOpen, shape) { + return net.createServer({ allowHalfOpen }, client => { + const upstream = net.connect(server.address().port, "127.0.0.1"); + client.on("error", () => {}); + upstream.on("error", () => {}); + client.on("close", () => upstream.destroy()); + upstream.on("close", () => client.destroy()); + shape(client, upstream); + }); + } + + const sameRead = proxy(false, (client, upstream) => { + let fromClient = Buffer.alloc(0); + let messages = 0; + let held = Buffer.alloc(0); + let released = false; + client.on("data", chunk => { + const { records, rest } = takeRecords(Buffer.concat([fromClient, chunk])); + fromClient = rest; + messages += records.filter(record => record[0] === APPLICATION_DATA).length; + upstream.write(chunk); + }); + upstream.on("data", chunk => { + if (messages < 4 || released) return void client.write(chunk); + held = Buffer.concat([held, chunk]); + const { records, rest } = takeRecords(held); + const types = records.map(record => record[0]); + if (rest.length === 0 && types.length === 2 && types[0] === APPLICATION_DATA && types[1] === HANDSHAKE) { + released = true; + client.write(held); + } + }); + }); + + // Half-open: the FIN of the client must not come back to it ahead of the request. + const afterEnd = proxy(true, (client, upstream) => { + let fromClient = Buffer.alloc(0); + let firstMessage; + client.on("data", chunk => { + const { records, rest } = takeRecords(Buffer.concat([fromClient, chunk])); + fromClient = rest; + for (const record of records) { + if (!firstMessage) { + if (record[0] === APPLICATION_DATA) firstMessage = record; + else upstream.write(record); + } else if (record[0] === ALERT) { + upstream.write(firstMessage); + } + } + }); + upstream.on("data", chunk => client.write(chunk)); + }); + + server.listen(0, "127.0.0.1", () => + sameRead.listen(0, "127.0.0.1", () => + afterEnd.listen(0, "127.0.0.1", () => + console.log(server.address().port, sameRead.address().port, afterEnd.address().port), + ), + ), + ); +`; + +// Renegotiates 4 times in a row when the first request arrives, so the client refuses the last one while it waits +// for an answer. An HTTP request gets its response after the 4 renegotiations. A RESP command (Valkey) gets none. +// For "/response-first" a complete keep-alive response leaves ahead of request 4. The process prints two ports: +// 1. The server. +// 2. A TCP relay to the server. It forwards whole records, and it gives the client that response and request 4 in +// one write, so one read holds both. +const backToBackRenegotiationServer = /* js */ ` + const net = require("net"); + const tls = require("tls"); + tls.CLIENT_RENEG_LIMIT = 100; + const server = tls.createServer( + { cert: process.env.SERVER_CERT, key: process.env.SERVER_KEY, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" }, + socket => { + socket.on("error", () => {}); + let head = ""; + socket.on("data", function onHead(chunk) { + head += chunk.toString("latin1"); + const isHttp = !head.startsWith("*"); + if (isHttp && !head.includes("\\r\\n\\r\\n")) return; + socket.off("data", onHead); + socket.resume(); + const responseFirst = head.startsWith("GET /response-first "); + let asked = 0; + (function ask() { + if (asked === 3 && responseFirst) socket.write("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\n\\r\\nok"); + if (asked === 4) { + if (isHttp) socket.end("HTTP/1.1 200 OK\\r\\nContent-Length: 2\\r\\nConnection: close\\r\\n\\r\\nok"); + return; + } + asked++; + socket.renegotiate({ rejectUnauthorized: false }, err => { + if (!err) ask(); + }); + })(); + }); + }, + ); + + const APPLICATION_DATA = 23; + const CHANGE_CIPHER_SPEC = 20; + const relay = net.createServer(client => { + const upstream = net.connect(server.address().port, "127.0.0.1"); + client.on("error", () => {}); + upstream.on("error", () => {}); + client.on("close", () => upstream.destroy()); + upstream.on("close", () => client.destroy()); + client.on("data", chunk => upstream.write(chunk)); + // The server finished 4 handshakes when its 4th ChangeCipherSpec passed. The application data record after + // that is the response. It waits here for the record behind it. + let fromServer = Buffer.alloc(0); + let handshakes = 0; + let held; + upstream.on("data", chunk => { + fromServer = Buffer.concat([fromServer, chunk]); + while (fromServer.length >= 5 && fromServer.length >= 5 + fromServer.readUInt16BE(3)) { + const record = fromServer.subarray(0, 5 + fromServer.readUInt16BE(3)); + fromServer = fromServer.subarray(record.length); + if (record[0] === CHANGE_CIPHER_SPEC) handshakes++; + if (held) { + client.write(Buffer.concat([held, record])); + held = undefined; + handshakes = 0; + } else if (handshakes === 4 && record[0] === APPLICATION_DATA) { + held = record; + } else { + client.write(record); + } + } + }); + }); + + server.listen(0, "127.0.0.1", () => + relay.listen(0, "127.0.0.1", () => console.log(server.address().port, relay.address().port)), + ); +`; + +// Each server keeps its state for each connection, so every test below uses the same two processes. +let pingPongPort: number; +let sameReadPort: number; +let afterEndPort: number; +let backToBackPort: number; +let responseFirstRelayPort: number; +const refusalServers: Subprocess[] = []; +beforeAll(async () => { + [[pingPongPort, sameReadPort, afterEndPort], [backToBackPort, responseFirstRelayPort]] = await Promise.all( + [pingPongRenegotiationServer, backToBackRenegotiationServer].map(async source => { + const server = Bun.spawn({ + cmd: ["node", "-e", source], + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + env: { ...bunEnv, SERVER_CERT: tls.cert, SERVER_KEY: tls.key }, + }); + refusalServers.push(server); + const { value, done } = await server.stdout.getReader().read(); + if (done) throw new Error("the server exited before it printed its ports"); + return new TextDecoder().decode(value).trim().split(" ").map(Number); + }), + ); +}); +afterAll(() => { + for (const server of refusalServers) server.kill(); +}); + +// A node:tls client over TCP, or over a Duplex in front of a TCP socket. +function connectOver(transport: string, port: number, options: { rejectUnauthorized?: boolean; ca?: string }) { + if (transport === "TCP") { + return { socket: tlsConnect({ ...options, servername: "localhost", port, host: "127.0.0.1" }), raw: undefined }; + } + const raw = netConnect(port, "127.0.0.1"); + raw.on("error", () => {}); + const duplex = new Duplex({ + read() {}, + write(chunk: Buffer, encoding: string, callback: () => void) { + raw.write(chunk, callback); + }, + final(callback: () => void) { + raw.end(); + callback(); + }, + }); + raw.on("data", (chunk: Buffer) => duplex.push(chunk)); + raw.on("end", () => duplex.push(null)); + raw.on("close", () => duplex.destroy()); + return { socket: tlsConnect({ ...options, servername: "localhost", socket: duplex }), raw }; +} + +// Every refusal is BoringSSL's own, past the limit too. +const NO_RENEGOTIATION = "error:100000b6:SSL routines:OPENSSL_internal:NO_RENEGOTIATION"; + +// A refusal is not the result of a handshake: no 'secureConnect' reports it. With `sameRead`, one read of the client +// holds "before 4" and, behind it, the request that the client refuses. That data came first, so it arrives first, +// and its handler runs before the refusal: `call` is what the handler does to the socket. The request came before an +// end() from that handler, so it is still refused. After a destroy() there is no socket to report to. +it.concurrent.each([ + { transport: "TCP", trusted: false, sameRead: false, call: "nothing" }, + { transport: "TCP", trusted: true, sameRead: false, call: "nothing" }, + { transport: "a Duplex", trusted: false, sameRead: false, call: "nothing" }, + { transport: "a Duplex", trusted: true, sameRead: false, call: "nothing" }, + { transport: "TCP", trusted: false, sameRead: true, call: "nothing" }, + { transport: "a Duplex", trusted: false, sameRead: true, call: "nothing" }, + { transport: "TCP", trusted: false, sameRead: true, call: "end" }, + { transport: "a Duplex", trusted: false, sameRead: true, call: "end" }, + { transport: "TCP", trusted: false, sameRead: true, call: "destroy" }, + { transport: "a Duplex", trusted: false, sameRead: true, call: "destroy" }, +] as const)( + "a renegotiation that the client refuses is an 'error' and no 'secureConnect' over $transport (trusted chain: $trusted, data in the same read: $sameRead, its handler calls: $call)", + async ({ transport, trusted, sameRead, call }) => { + const { socket, raw } = connectOver(transport, sameRead ? sameReadPort : pingPongPort, { + rejectUnauthorized: false, + ...(trusted && { ca: tls.cert }), + }); + const events: string[] = []; + const closed = Promise.withResolvers(); + let sent = 0; + const send = () => socket.write(sameRead && ++sent === 4 ? "data first" : "go"); + socket.on("secureConnect", () => { + if (events.push(`secureConnect authorized=${socket.authorized}`) === 1) send(); + }); + socket.on("data", (chunk: Buffer) => { + events.push(`data ${chunk}`); + if (String(chunk).startsWith("done")) send(); + else if (call !== "nothing") socket[call](); + }); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}: ${err.message}`)); + socket.on("close", (hadError: boolean) => { + events.push(`close hadError=${hadError}`); + closed.resolve(); + }); + try { + await closed.promise; + expect(events).toEqual([ + `secureConnect authorized=${trusted}`, + `secureConnect authorized=${trusted}`, + "data done 1", + `secureConnect authorized=${trusted}`, + "data done 2", + `secureConnect authorized=${trusted}`, + "data done 3", + ...(sameRead ? ["data before 4"] : []), + ...(call === "destroy" + ? ["close hadError=false"] + : [`error ERR_SSL_NO_RENEGOTIATION: ${NO_RENEGOTIATION}`, "close hadError=false"]), + ]); + } finally { + socket.destroy(); + raw?.destroy(); + } + }, +); + +// After its own close_notify the client cannot answer the request. That is the end of the client's own close, not an +// error, and no second 'secureConnect'. +it.concurrent.each(["TCP", "a Duplex"])( + "a request for a renegotiation after the client's own end() is not an 'error' over %s", + async transport => { + const { socket, raw } = connectOver(transport, afterEndPort, { ca: tls.cert }); + const events: string[] = []; + const closed = Promise.withResolvers(); + socket.on("secureConnect", () => { + events.push(`secureConnect authorized=${socket.authorized}`); + socket.end("go"); + }); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}: ${err.message}`)); + socket.on("close", (hadError: boolean) => { + events.push(`close hadError=${hadError}`); + closed.resolve(); + }); + socket.resume(); + try { + await closed.promise; + expect(events).toEqual(["secureConnect authorized=true", "close hadError=false"]); + } finally { + socket.destroy(); + raw?.destroy(); + } + }, +); + +// The session was established, so the refusal is an `error` and no result of a handshake. With no `error` handler the +// socket only closes: what a peer sends must not raise an uncaught exception. +it.concurrent.each([ + { withErrorHandler: true, sameRead: false, call: "nothing" }, + { withErrorHandler: false, sameRead: false, call: "nothing" }, + { withErrorHandler: true, sameRead: true, call: "nothing" }, + { withErrorHandler: true, sameRead: true, call: "end" }, + { withErrorHandler: true, sameRead: true, call: "terminate" }, +] as const)( + "Bun.connect reports a renegotiation that the client refuses to its error handler (error handler: $withErrorHandler, data in the same read: $sameRead, its handler calls: $call)", + async ({ withErrorHandler, sameRead, call }) => { + const events: string[] = []; + const closed = Promise.withResolvers(); + const describe = (error: Error | null | undefined) => + error ? `${(error as NodeJS.ErrnoException).code}: ${error.message}` : `${error}`; + let sent = 0; + const send = (socket: Bun.Socket) => socket.write(sameRead && ++sent === 4 ? "data first" : "go"); + await Bun.connect({ + hostname: "127.0.0.1", + port: sameRead ? sameReadPort : pingPongPort, + tls: { ca: tls.cert, serverName: "localhost" }, + socket: { + data(socket, chunk) { + events.push(`data ${chunk}`); + if (String(chunk).startsWith("done")) send(socket); + else if (call !== "nothing") socket[call](); + }, + handshake(socket, success, error) { + if (events.push(`handshake ${success} ${describe(error)}`) === 1) send(socket); + }, + ...(withErrorHandler && { + error(_socket: Bun.Socket, error: Error) { + events.push(`error ${describe(error)}`); + }, + }), + close(socket, error) { + events.push(`close ${describe(error)} authorized=${socket.authorized}`); + closed.resolve(); + }, + }, + }); + await closed.promise; + + expect(events).toEqual([ + "handshake true null", + "handshake true null", + "data done 1", + "handshake true null", + "data done 2", + "handshake true null", + "data done 3", + ...(sameRead ? ["data before 4"] : []), + ...(call === "terminate" + ? ["close undefined authorized=true"] + : [...(withErrorHandler ? [`error EPROTO: ${NO_RENEGOTIATION}`] : []), "close undefined authorized=false"]), + ]); + }, +); + +it("https.request fails with the protocol error when the client refuses a renegotiation", async () => { + const outcome = Promise.withResolvers(); + const req = require("https").request( + { host: "localhost", port: backToBackPort, path: "/", agent: false, ca: tls.cert }, + (res: IncomingMessage) => { + res.resume(); + res.on("end", () => outcome.resolve(`status ${res.statusCode}`)); + }, + ); + req.on("error", (err: NodeJS.ErrnoException) => outcome.resolve(`${err.code}: ${err.message}`)); + req.end(); + expect(await outcome.promise).toBe(`ERR_SSL_NO_RENEGOTIATION: ${NO_RENEGOTIATION}`); +}); + +// The chain is not trusted and the client accepts that. The refusal must not come back as that certificate verdict. +it("fetch does not report a certificate error when the client refuses a renegotiation", async () => { + const outcome = await fetch(`https://localhost:${backToBackPort}/`, { + keepalive: false, + tls: { rejectUnauthorized: false }, + }).then( + res => `status ${res.status}`, + (err: NodeJS.ErrnoException) => `${err.name} ${err.code}`, + ); + expect(outcome).toBe("TypeError EPROTO"); +}); + +// The response of "/response-first" and request 4 arrive in one read. The response is complete, so each fetch +// succeeds. The refusal then closes the connection: it must not serve the next fetch from the pool, so the proxy sees +// one CONNECT for each fetch. Direct is the path of a socket, the proxy is the path of a tunnel. In a child process: +// a report to a tunnel in the pool uses freed memory, which only a sanitizer build shows. +it.concurrent.each(["direct", "through a CONNECT proxy"])( + "fetch (%s) gets the response that arrives in the same read as a refused renegotiation", + async route => { + using proxy = route === "direct" ? undefined : await startRecordingProxy(); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // The environment must not choose the route. + const proxy = process.env.PROXY_PORT + ? { url: "http://127.0.0.1:" + process.env.PROXY_PORT, respectNoProxy: false } + : false; + for (let i = 0; i < 2; i++) { + const res = await fetch("https://localhost:" + process.env.SERVER_PORT + "/response-first", { + tls: { ca: process.env.SERVER_CERT }, + proxy, + }); + console.log(res.status, await res.text()); + } + `, + ], + env: { + ...bunEnv, + SERVER_PORT: String(responseFirstRelayPort), + PROXY_PORT: proxy ? String(proxy.port) : "", + SERVER_CERT: tls.cert, + ASAN_OPTIONS: ((bunEnv.ASAN_OPTIONS ?? "") + ":symbolize=0").replace(/^:/, ""), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const connect = `CONNECT localhost:${responseFirstRelayPort} HTTP/1.1`; + expect({ + stdout, + exitCode, + signalCode: proc.signalCode, + tunnels: proxy?.requests.map(request => request.requestLine), + stderr, + }).toEqual({ + stdout: "200 ok\n200 ok\n", + exitCode: 0, + signalCode: null, + tunnels: proxy && [connect, connect], + stderr: expect.any(String), + }); + }, +); + +// In a child process: with no error to report, an assert-enabled build aborts when it makes an Error with no message. +it("a Valkey client fails its command with the protocol error when the client refuses a renegotiation", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const client = new Bun.RedisClient("rediss://localhost:" + process.env.SERVER_PORT, { + tls: { ca: process.env.SERVER_CERT }, + autoReconnect: false, + }); + const outcome = await client.get("key").then( + value => "value " + value, + err => err.code + ": " + err.message, + ); + console.log(outcome); + client.close(); + `, + ], + env: { + ...bunEnv, + SERVER_PORT: String(backToBackPort), + SERVER_CERT: tls.cert, + // An abort of an ASAN build must not spend the test's time on symbols. + ASAN_OPTIONS: ((bunEnv.ASAN_OPTIONS ?? "") + ":symbolize=0").replace(/^:/, ""), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode, signalCode: proc.signalCode, stderr }).toEqual({ + stdout: `EPROTO: ${NO_RENEGOTIATION}`, + exitCode: 0, + signalCode: null, + stderr: expect.any(String), + }); +}); + // A renegotiation reports the certificate check of its own handshake. The client ends its write side while the first // handshake still runs, which sends nothing but marks the TLS session as shut down. That state must not turn the // failed check of the renegotiated handshake into a pass. Runs the client over a Duplex, the SSLWrapper path. @@ -826,8 +1327,8 @@ beforeAll(async () => { afterAll(() => quietServers?.kill()); // A node:tls client over TCP, or over a Duplex in front of a TCP socket. Resolves with its events at 'close', or at -// handshake report number `until`. -async function nodeTlsEvents(transport: "TCP" | "a Duplex", port: number, until = Infinity) { +// handshake report number `until`. Without `forwardEnd` the Duplex only closes when the TCP socket does. +async function nodeTlsEvents(transport: "TCP" | "a Duplex", port: number, until = Infinity, forwardEnd = true) { const events: string[] = []; const done = Promise.withResolvers(); let raw: ReturnType | undefined; @@ -846,7 +1347,7 @@ async function nodeTlsEvents(transport: "TCP" | "a Duplex", port: number, until }, }); tcp.on("data", (chunk: Buffer) => duplex!.push(chunk)); - tcp.on("end", () => duplex!.push(null)); + tcp.on("end", () => forwardEnd && duplex!.push(null)); tcp.on("close", () => duplex!.destroy()); } const socket = tlsConnect({ @@ -896,6 +1397,12 @@ it.concurrent( }, ); +it.concurrent("a transport that closes in the middle of a renegotiation is no report over a Duplex", async () => { + const events = await nodeTlsEvents("a Duplex", quiet.cut, Infinity, false); + // Bun also emits 'end'. + expect(events.filter(event => event !== "end")).toEqual(["secureConnect", "secure", "close false"]); +}); + it.concurrent.each([ { server: "ends", reports: ["handshake true null", "handshake true null"] }, { server: "cut", reports: ["handshake true null"] }, From 8568b903be0276569098e91d4a8a5626c8a36b6d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 00:46:47 +0000 Subject: [PATCH 089/174] test(tls): every owner's connection closes on a bad record, and nothing behind it is delivered One row for each owner of on_handshake, TLS 1.2 and 1.3, through a TCP relay that puts a record that cannot authenticate in front of the peer's next valid message and never closes the owner's connection itself: node:tls client and server over a socket and over a Duplex, Bun.connect and Bun.listen with and without an `error` handler, Bun.listen with only `open`, fetch direct and through CONNECT, WebSocket (also with rejectUnauthorized: false, which ignores the report) and through CONNECT, Postgres, MySQL, Valkey, Bun.serve and node:https. --- .../js/bun/net/tls-fatal-error-closes.test.ts | 482 ++++++++++++++++++ 1 file changed, 482 insertions(+) create mode 100644 test/js/bun/net/tls-fatal-error-closes.test.ts diff --git a/test/js/bun/net/tls-fatal-error-closes.test.ts b/test/js/bun/net/tls-fatal-error-closes.test.ts new file mode 100644 index 000000000000..e94331885036 --- /dev/null +++ b/test/js/bun/net/tls-fatal-error-closes.test.ts @@ -0,0 +1,482 @@ +// A TLS session that gets a record it cannot authenticate is over. The TLS engine itself closes the connection, whatever +// the owner of the socket does with the report, and nothing that the peer sends behind that record is delivered. +// +// One row for each owner of a TLS connection. A TCP relay stands between the owner and its peer. Once the session carries +// application data, the relay puts a bad record in front of the peer's next, valid, message ("later"). The relay never +// closes the owner's connection itself, so `closedByOwner` only resolves when the owner's side sends a FIN or a RST. +// +// Two engines: openssl.c drives a socket, SSLWrapper drives TLS over a Duplex, a CONNECT tunnel or a named pipe. +import { RedisClient, SQL } from "bun"; +import { afterAll, describe, expect, test } from "bun:test"; +import { tls as cert } from "harness"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import https from "node:https"; +import net from "node:net"; +import { Duplex } from "node:stream"; +import tls from "node:tls"; +import { + listeningServer, + MYSQL_CLIENT_LONG_PASSWORD, + MYSQL_CLIENT_SSL, + MYSQL_DEFAULT_CAPABILITIES, + mysqlAckSessionSetup, + mysqlHandshakeV10, + mysqlOkPacket, + pgAuthenticationOk, + pgCommandComplete, + pgReadyForQuery, + pgSSLResponse, +} from "../../sql/wire-frames"; +import { startRecordingProxy } from "../../web/websocket/proxy-test-utils"; + +// The environment must not choose the route of the fetch and WebSocket rows. +const proxyEnvKeys = ["NO_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"].flatMap(key => [key, key.toLowerCase()]); +const savedProxyEnv = proxyEnvKeys.map(key => [key, process.env[key]] as const); +for (const key of proxyEnvKeys) process.env[key] = ""; +afterAll(() => { + for (const [key, value] of savedProxyEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +// application_data, 32 bytes that no key authenticates. +const BAD_RECORD = Buffer.concat([Buffer.from([0x17, 0x03, 0x03, 0x00, 0x20]), Buffer.alloc(32, 0x42)]); +const BAD_RECORD_REASON = "error:1000008b:SSL routines:OPENSSL_internal:DECRYPTION_FAILED_OR_BAD_RECORD_MAC"; + +// A relay to 127.0.0.1:`port`. `owner` says which side is under test. `corrupt()` sends the bad record to that side. +async function faultRelay(port: number, owner: "client" | "server") { + const closedByOwner = Promise.withResolvers(); + let toOwner: net.Socket | undefined; + const sockets: net.Socket[] = []; + const relay = await listeningServer(client => { + const upstream = net.connect(port, "127.0.0.1"); + sockets.push(client, upstream); + const [ownerSide, peerSide] = owner === "client" ? [client, upstream] : [upstream, client]; + toOwner = ownerSide; + for (const socket of [client, upstream]) socket.on("error", () => {}); + ownerSide.on("data", chunk => peerSide.write(chunk)); + peerSide.on("data", chunk => ownerSide.write(chunk)); + ownerSide.on("end", closedByOwner.resolve); + ownerSide.on("close", closedByOwner.resolve); + }); + return { + port: relay.port, + closedByOwner: closedByOwner.promise, + corrupt: () => void toOwner!.write(BAD_RECORD), + async [Symbol.asyncDispose]() { + for (const socket of sockets) socket.destroy(); + await new Promise(closed => relay.server.close(closed)); + }, + }; +} + +// A TLS server for the client rows. `plain` runs the cleartext prelude of the protocol. `onData(socket, chunk, fault)` +// plays the protocol. `fault(later)` sends the bad record and, behind it, `later` as a valid message. +async function serverBehindRelay( + maxVersion: tls.SecureVersion, + onData: (socket: tls.TLSSocket, chunk: Buffer, fault: (later: string | Buffer) => void) => void, + plain?: (socket: net.Socket) => Promise, +) { + let fault!: (later: string | Buffer) => void; + const backend = await listeningServer(async raw => { + raw.on("error", () => {}); + await plain?.(raw); + const socket = new tls.TLSSocket(raw, { isServer: true, cert: cert.cert, key: cert.key, maxVersion }); + fault = later => { + relay.corrupt(); + socket.write(later); + }; + socket.on("error", () => {}); + socket.on("close", () => raw.destroy()); + socket.on("data", chunk => onData(socket, chunk, fault)); + }); + const relay = await faultRelay(backend.port, "client"); + return { + port: relay.port, + closedByOwner: relay.closedByOwner, + fault: (later: string | Buffer) => fault(later), + async [Symbol.asyncDispose]() { + await relay[Symbol.asyncDispose](); + await new Promise(closed => backend.server.close(closed)); + }, + }; +} + +// Answers "go" with "first", and the next message with the fault. +const firstThenFault: Parameters[1] = (socket, chunk, fault) => + String(chunk) === "go" ? void socket.write("first") : fault("later"); + +// Answers a request with the head of a response and the first chunk of its body. +const httpBody: Parameters[1] = socket => + void socket.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nfirst\r\n"); + +const textFrame = (text: string) => Buffer.concat([Buffer.from([0x81, text.length]), Buffer.from(text)]); +// Answers the upgrade request with 101 and the frame "first". The client's answer to that frame gets the fault. +const webSocketThenFault: Parameters[1] = (socket, chunk, fault) => { + const key = /sec-websocket-key:\s*(\S+)/i.exec(chunk.toString("latin1"))?.[1]; + if (!key) return fault(textFrame("later")); + const accept = createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket.write(textFrame("first")); +}; + +// The fault is in place of the rest of the body, once the first chunk arrived. +async function fetchEvents(server: Awaited>, proxy: string | false) { + const events: string[] = []; + try { + const response = await fetch(`https://127.0.0.1:${server.port}/`, { + tls: { ca: cert.cert }, + proxy, + keepalive: false, + }); + for await (const chunk of response.body!) { + events.push(`data ${Buffer.from(chunk)}`); + server.fault("5\r\nlater\r\n0\r\n\r\n"); + } + events.push("end"); + } catch (e: any) { + events.push(`error ${e.code}`); + } + return events; +} + +function webSocketEvents(port: number, tlsOptions: object, proxy?: string) { + const events: string[] = []; + const closed = Promise.withResolvers(); + const ws = new WebSocket(`wss://127.0.0.1:${port}/`, { tls: tlsOptions, proxy } as any); + ws.onmessage = event => { + events.push(`message ${event.data}`); + ws.send("answer"); + }; + ws.onclose = event => { + events.push(`close ${event.code}`); + closed.resolve(events); + }; + return closed.promise; +} + +// The events of a node:tls socket up to 'close'. It answers "first", which asks the peer for the fault. +function nodeTlsEvents(socket: tls.TLSSocket) { + const events: string[] = []; + const closed = Promise.withResolvers(); + socket.on("data", chunk => { + events.push(`data ${chunk}`); + if (String(chunk) === "first") socket.write("fault"); + }); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}`)); + socket.on("end", () => events.push("end")); + socket.on("close", hadError => { + events.push(`close ${hadError}`); + closed.resolve(events); + }); + return closed.promise; +} +const nodeTlsFault = ["data first", "error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC", "end", "close false"]; + +function overDuplex(raw: net.Socket) { + raw.on("error", () => {}); + const duplex = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + raw.write(chunk, callback); + }, + final(callback) { + raw.end(callback); + }, + destroy(err, callback) { + raw.destroy(); + callback(err); + }, + }); + raw.on("data", chunk => duplex.push(chunk)); + raw.on("end", () => duplex.push(null)); + return duplex; +} + +const postgresPrelude = async (socket: net.Socket) => { + await once(socket, "data"); + socket.write(pgSSLResponse("S")); +}; + +// Sends a HandshakeV10 that offers TLS, reads the SSLRequest packet and leaves what follows it to the TLS engine. +const mysqlPrelude = (socket: net.Socket) => + new Promise(resolve => { + socket.write( + mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_LONG_PASSWORD | MYSQL_CLIENT_SSL }), + ); + let buffered = Buffer.alloc(0); + socket.on("data", function onData(chunk: Buffer) { + buffered = Buffer.concat([buffered, chunk]); + if (buffered.length < 4 || buffered.length < 4 + buffered.readUIntLE(0, 3)) return; + socket.removeListener("data", onData); + socket.pause(); + socket.unshift(buffered.subarray(4 + buffered.readUIntLE(0, 3))); + resolve(); + }); + }); + +describe.each(["TLSv1.3", "TLSv1.2"] as const)("a bad record on an established %s session", maxVersion => { + describe("closes the client", () => { + test.concurrent.each(["a socket", "a Duplex"])("node:tls over %s", async transport => { + await using server = await serverBehindRelay(maxVersion, firstThenFault); + const socket = tls.connect({ + ca: cert.cert, + servername: "localhost", + ...(transport === "a socket" + ? { port: server.port, host: "127.0.0.1" } + : { socket: overDuplex(net.connect(server.port, "127.0.0.1")) }), + }); + socket.write("go"); + expect(await nodeTlsEvents(socket)).toEqual(nodeTlsFault); + await server.closedByOwner; + }); + + test.concurrent.each([true, false])("Bun.connect (error handler: %p)", async withErrorHandler => { + await using server = await serverBehindRelay(maxVersion, firstThenFault); + const events: string[] = []; + const closed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: cert.cert, serverName: "localhost" }, + socket: { + handshake: (socket, success) => void (events.push(`handshake ${success}`), socket.write("go")), + data: (socket, chunk) => void (events.push(`data ${chunk}`), socket.write("fault")), + ...(withErrorHandler && { + error: (socket: Bun.Socket, error: Error) => + void events.push(`error ${(error as any).code}: ${error.message}, write ${socket.write("more")}`), + }), + close: socket => void (events.push(`close authorized=${socket.authorized}`), closed.resolve()), + }, + }); + await closed.promise; + expect(events).toEqual([ + "handshake true", + "data first", + ...(withErrorHandler ? [`error EPROTO: ${BAD_RECORD_REASON}, write -1`] : []), + "close authorized=false", + ]); + await server.closedByOwner; + }); + + test.concurrent("fetch", async () => { + await using server = await serverBehindRelay(maxVersion, httpBody); + expect(await fetchEvents(server, false)).toEqual(["data first", "error EPROTO"]); + await server.closedByOwner; + }); + + test.concurrent("fetch through a CONNECT proxy", async () => { + await using server = await serverBehindRelay(maxVersion, httpBody); + using proxy = await startRecordingProxy(); + expect(await fetchEvents(server, `http://127.0.0.1:${proxy.port}`)).toEqual(["data first", "error EPROTO"]); + await server.closedByOwner; + }); + + // With rejectUnauthorized: false the WebSocket client does nothing with the report. + test.concurrent.each([{ ca: cert.cert }, { rejectUnauthorized: false }])("WebSocket %p", async tlsOptions => { + await using server = await serverBehindRelay(maxVersion, webSocketThenFault); + expect(await webSocketEvents(server.port, tlsOptions)).toEqual(["message first", "close 1006"]); + await server.closedByOwner; + }); + + test.concurrent("WebSocket through a CONNECT proxy", async () => { + await using server = await serverBehindRelay(maxVersion, webSocketThenFault); + using proxy = await startRecordingProxy(); + const events = await webSocketEvents(server.port, { ca: cert.cert }, `http://127.0.0.1:${proxy.port}`); + expect(events).toEqual(["message first", "close 1006"]); + // The connection to the proxy stays open: https://github.com/oven-sh/bun/pull/37487 + }); + + test.concurrent("Bun.SQL postgres", async () => { + let started = false; + await using server = await serverBehindRelay( + maxVersion, + (socket, _chunk, fault) => { + if (started) return fault(Buffer.concat([pgCommandComplete("SELECT 0"), pgReadyForQuery()])); + started = true; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + }, + postgresPrelude, + ); + await using sql = new SQL({ + url: `postgres://user:pass@localhost:${server.port}/db?sslmode=verify-full`, + tls: { ca: cert.cert }, + max: 1, + }); + const outcome = await sql`SELECT 1`.simple().then( + () => "resolved", + e => `${e.code}: ${e.message}`, + ); + expect(outcome).toBe(`EPROTO: ${BAD_RECORD_REASON}`); + await server.closedByOwner; + }); + + test.concurrent("Bun.SQL mysql", async () => { + let authenticated = false; + await using server = await serverBehindRelay( + maxVersion, + (socket, chunk, fault) => { + if (!authenticated) { + authenticated = true; + return void socket.write(mysqlOkPacket(chunk[3] + 1)); + } + if (!mysqlAckSessionSetup(socket, chunk.subarray(4))) fault(mysqlOkPacket(1)); + }, + mysqlPrelude, + ); + await using sql = new SQL({ + url: `mysql://user:pass@localhost:${server.port}/db?sslmode=verify-full`, + tls: { ca: cert.cert }, + max: 1, + }); + const outcome = await sql`SELECT 1`.simple().then( + () => "resolved", + e => `${e.code}: ${e.message}`, + ); + expect(outcome).toBe(`EPROTO: ${BAD_RECORD_REASON}`); + await server.closedByOwner; + }); + + test.concurrent("Bun.RedisClient", async () => { + await using server = await serverBehindRelay(maxVersion, (socket, chunk, fault) => + String(chunk).includes("HELLO") ? void socket.write("%1\r\n$5\r\nproto\r\n:3\r\n") : fault("$5\r\nlater\r\n"), + ); + const client = new RedisClient(`rediss://localhost:${server.port}`, { + tls: { ca: cert.cert }, + autoReconnect: false, + }); + const outcome = await client.get("key").then( + value => `value ${value}`, + e => `${e.code}: ${e.message}`, + ); + client.close(); + expect(outcome).toBe(`EPROTO: ${BAD_RECORD_REASON}`); + await server.closedByOwner; + }); + }); + + describe("closes the server", () => { + // A node:tls client behind the relay. It sends "go". `fault()` sends the bad record and, behind it, "later". + async function clientBehindRelay(port: number) { + const relay = await faultRelay(port, "server"); + const client = tls.connect({ + port: relay.port, + host: "127.0.0.1", + ca: cert.cert, + servername: "localhost", + maxVersion, + }); + client.on("error", () => {}); + client.write("go"); + return { + client, + closedByOwner: relay.closedByOwner, + fault(later = "later") { + relay.corrupt(); + client.write(later); + }, + async [Symbol.asyncDispose]() { + client.destroy(); + await relay[Symbol.asyncDispose](); + }, + }; + } + + test.concurrent.each(["a socket", "a Duplex"])("node:tls over %s", async transport => { + const accepted = Promise.withResolvers(); + const server = tls.createServer(cert, socket => { + nodeTlsEvents(socket).then(accepted.resolve); + }); + // Over a Duplex this server never listens: a TCP server hands it each connection. + const listener = + transport === "a socket" ? server : net.createServer(raw => server.emit("connection", overDuplex(raw))); + await once(listener.listen(0, "127.0.0.1"), "listening"); + try { + await using peer = await clientBehindRelay((listener.address() as net.AddressInfo).port); + peer.client.once("data", () => peer.fault()); + server.once("secureConnection", socket => socket.once("data", () => socket.write("ready"))); + expect(await accepted.promise).toEqual(["data go", ...nodeTlsFault.slice(1)]); + await peer.closedByOwner; + } finally { + listener.close(); + } + }); + + // "open only": with no `handshake` handler `open` gets the report of the handshake, and no other report. + test.concurrent.each(["error", "no error", "open only"])("Bun.listen (%s handler)", async handlers => { + const events: string[] = []; + const closed = Promise.withResolvers(); + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: cert, + socket: { + ...(handlers === "open only" + ? { open: () => void events.push("open") } + : { handshake: (_socket: Bun.Socket, success: boolean) => void events.push(`handshake ${success}`) }), + data: (socket, chunk) => void (events.push(`data ${chunk}`), socket.write("ready")), + ...(handlers === "error" && { + error: (_socket: Bun.Socket, error: Error) => + void events.push(`error ${(error as any).code}: ${error.message}`), + }), + close: () => void (events.push("close"), closed.resolve()), + }, + }); + await using peer = await clientBehindRelay(listener.port); + peer.client.once("data", () => peer.fault()); + await closed.promise; + expect(events).toEqual([ + handlers === "open only" ? "open" : "handshake true", + "data go", + ...(handlers === "error" ? [`error EPROTO: ${BAD_RECORD_REASON}`] : []), + "close", + ]); + await peer.closedByOwner; + }); + + test.concurrent.each(["Bun.serve", "node:https"])("%s", async kind => { + const requests: string[] = []; + const bunServer = + kind === "Bun.serve" + ? Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: cert, + fetch: request => (requests.push(new URL(request.url).pathname), new Response("ok")), + }) + : undefined; + const nodeServer = bunServer + ? undefined + : https.createServer(cert, (request, response) => (requests.push(request.url!), response.end("ok"))); + if (nodeServer) await once(nodeServer.listen(0, "127.0.0.1"), "listening"); + try { + const relay = await faultRelay(bunServer?.port ?? (nodeServer!.address() as net.AddressInfo).port, "server"); + await using _ = relay; + const client = tls.connect({ + port: relay.port, + host: "127.0.0.1", + ca: cert.cert, + servername: "localhost", + maxVersion, + }); + client.on("error", () => {}); + client.write("GET /first HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await once(client, "data"); + relay.corrupt(); + client.write("GET /later HTTP/1.1\r\nHost: localhost\r\n\r\n"); + await relay.closedByOwner; + client.destroy(); + expect(requests).toEqual(["/first"]); + } finally { + bunServer?.stop(true); + nodeServer?.close(); + } + }); + }); +}); From 18dd88be8ac71008ff017fcdc44ab6ed6cc4063e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:06:38 +0000 Subject: [PATCH 090/174] node:tls: report an accepted socket that closes before its handshake finished (#44021) A server socket that called end() while the handshake ran, and whose peer then left, closed with no error and the server reported nothing. Node's onSocketClose emits 'tlsClientError' ECONNRESET for any accepted socket that closes without an error while the server still owns it. This is that listener. The relay of endMidHandshakeOverTcp now waits for the client's FIN per chunk, so its `pieces` rows split the server's flight whichever arrives first. --- src/js/node/net.ts | 10 +++ .../tls/node-tls-duplex-end-verify.test.ts | 85 ++++++++++++++++--- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 6dde39c5d957..fb84cddd897f 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -1404,6 +1404,15 @@ function onSocketTLSError(err) { } } +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1246-L1257 +function onSocketTLSClose(hadError) { + if (hadError) return; + if (!this._controlReleased && !this[kerrorEmitted]) { + this[kerrorEmitted] = true; + this.server?.emit("tlsClientError", new ConnResetException("socket hang up"), this); + } +} + function onHandshakeTimeout() { this._emitTLSError($ERR_TLS_HANDSHAKE_TIMEOUT()); } @@ -1426,6 +1435,7 @@ function initAcceptedTLSSocket(server, socket) { socket[kerrorEmitted] = false; socket.on("_tlsError", onSocketTLSError); socket.on("error", onSocketTLSError); + socket.on("close", onSocketTLSClose); const handshakeTimeout = server._handshakeTimeout; if (!(handshakeTimeout > 0)) return; // The deadline surfaces as the socket's own 'timeout' event with diff --git a/test/js/node/tls/node-tls-duplex-end-verify.test.ts b/test/js/node/tls/node-tls-duplex-end-verify.test.ts index 883d9baea611..172f9d521bfc 100644 --- a/test/js/node/tls/node-tls-duplex-end-verify.test.ts +++ b/test/js/node/tls/node-tls-duplex-end-verify.test.ts @@ -222,10 +222,10 @@ async function behindProxy(server, wire, proxyOptions = {}, overDuplex = false) }; } -// The same shape on a plain TCP socket. The client calls end() when its last handshake flight left. The proxy holds -// the server's final flight until the client's FIN arrived, so the handshake completes on a socket that is already -// shut down. The proxy never forwards the FIN, so the server keeps writing. The server's certificate is not trusted, -// unless `trusted`. Returns the ordered events of the client. +// The same shape on a plain TCP socket. The client calls end() when its last handshake flight left. The proxy +// delivers what the server sends from then on after the client's FIN arrived, in `pieces` parts, so the handshake +// completes on a socket that is already shut down. The proxy never forwards the FIN, so the server keeps writing. +// The server's certificate is not trusted, unless `trusted`. Returns the ordered events of the client. async function endMidHandshakeOverTcp( maxVersion, rejectUnauthorized, @@ -245,8 +245,8 @@ async function endMidHandshakeOverTcp( (downstream, upstream) => { let sawChangeCipherSpec = false; let ending = false; - let clientEnded = false; - const held: Buffer[] = []; + const clientEnded = Promise.withResolvers(); + let delivered = clientEnded.promise; eachRecord(downstream, record => { upstream.write(record); if (ending) return; @@ -258,14 +258,11 @@ async function endMidHandshakeOverTcp( } sawChangeCipherSpec ||= record[0] === CHANGE_CIPHER_SPEC; }); - downstream.on("end", async () => { - await inPieces(part => downstream.write(part), Buffer.concat(held.splice(0)), pieces); - clientEnded = true; - for (const chunk of held.splice(0)) downstream.write(chunk); - }); + downstream.on("end", clientEnded.resolve); upstream.on("data", chunk => { - if (ending && !clientEnded) held.push(chunk); - else downstream.write(chunk); + if (!ending) return void downstream.write(chunk); + // The FIN can arrive before the server's answer does: each chunk waits for it, and for the chunk before. + delivered = delivered.then(() => inPieces(part => downstream.write(part), chunk, pieces)); }); }, { allowHalfOpen: true }, @@ -1453,3 +1450,65 @@ test("on a TCP socket: a close_notify in place of the client's last TLS 1.2 flig close(); } }); + +// The peer can go away while the handshake of a socket that called end() still runs. The socket closes with no error, +// and the server reports the handshake that never finished. `half`: the peer sends the first half of its last flight +// before its FIN. +for (const maxVersion of ["TLSv1.2", "TLSv1.3"]) { + for (const half of [false, true]) { + test(`${maxVersion}: a server that end()s reports the handshake that its peer left ${half ? "in the middle of a flight" : "before its last flight"}`, async () => { + const events = []; + const refused = Promise.withResolvers(); + const serverClosed = Promise.withResolvers(); + let serverSocket; + const server = tls.createServer({ key, cert, maxVersion }, () => events.push("secureConnection")); + server.on("tlsClientError", err => { + events.push(`tlsClientError ${err.code}`); + refused.resolve(); + }); + server.on("connection", socket => { + serverSocket = socket; + socket.on("error", () => {}); + socket.on("close", serverClosed.resolve); + }); + const { port, close } = await behindProxy( + server, + (downstream, upstream) => { + let chunks = 0; + const held = []; + downstream.on("data", chunk => { + // The ClientHello goes through. The proxy holds the client's last flight, and the server calls end(). + if (++chunks === 1) return void upstream.write(chunk); + held.push(chunk); + if (held.length === 1) serverSocket.end(); + }); + upstream.on("data", chunk => downstream.write(chunk)); + upstream.on("end", async () => { + const flight = Buffer.concat(held.splice(0)); + if (half) { + upstream.write(flight.subarray(0, Math.ceil(flight.length / 2))); + await pendingReadsDone(); + } + upstream.end(); + }); + }, + { allowHalfOpen: true }, + ); + const client = tls.connect({ + port, + host: "127.0.0.1", + servername: "agent1", + rejectUnauthorized: false, + maxVersion, + }); + client.on("error", () => {}); + try { + await Promise.all([refused.promise, serverClosed.promise]); + assert.deepStrictEqual(events, ["tlsClientError ECONNRESET"]); + } finally { + client.destroy(); + close(); + } + }); + } +} From c7e6f8f6155784d07275f089d92d0521287ab824 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:07:45 +0000 Subject: [PATCH 091/174] tls: over a Duplex, deliver parked sessions before the data decrypted with them (#37472) SSLWrapper handed over the sessions and keylog lines that SSL_read parked only after the data of that read, so a 'data' listener that destroys the socket (an https.Agent without keepAlive) lost the 'session' event and never resumed. openssl.c and Node deliver them first, in wire order. handle_reading's three "data callback, then re-check" sites become one dispatch_read(), which flushes the parked events first. Both flush loops also stop once the wrapper closed. --- src/uws/lib.rs | 55 +++------ test/js/node/tls/node-tls-connect.test.ts | 139 ++++++++++++++++++++++ 2 files changed, 155 insertions(+), 39 deletions(-) diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 165343e80df8..fde89d2ccae3 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -1201,21 +1201,8 @@ pub mod ssl_wrapper { .set_handshake_state(HandshakeState::HandshakeCompleted); } - // flush the reading - if read > 0 { - log!("triggering data callback (read {})", read); - // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. - self.trigger_data_callback(unsafe { buffer.filled(read) }); - // The data callback may have closed the connection - if self.ssl.get().is_none() || self.flags.closed_notified() { - return false; - } - } - // A NewSessionTicket/keylog line that rode in ahead of the - // peer's close_notify is still parked; deliver it before the - // close tears the wrapper down (mirrors the C ZERO_RETURN path). - self.flush_pending_events(); - if self.ssl.get().is_none() || self.flags.closed_notified() { + // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. + if !self.dispatch_read(unsafe { buffer.filled(read) }) { return false; } if err == boring_sys::SSL_ERROR_ZERO_RETURN { @@ -1255,27 +1242,23 @@ pub mod ssl_wrapper { ); // we filled the buffer // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. - self.trigger_data_callback(unsafe { buffer.filled(read) }); - // The callback may have closed the connection - check before continuing - // Check ssl first as a proxy for whether we were deinited - if self.ssl.get().is_none() || self.flags.closed_notified() { + if !self.dispatch_read(unsafe { buffer.filled(read) }) { return false; } read = 0; } } - // we finished reading - if read > 0 { - log!("triggering data callback (read {})", read); - // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. - self.trigger_data_callback(unsafe { buffer.filled(read) }); - // The callback may have closed the connection - // Check ssl first as a proxy for whether we were deinited - if self.ssl.get().is_none() || self.flags.closed_notified() { - return false; - } + // SAFETY: the SSL_read calls above wrote `[0..read]` contiguously. + self.dispatch_read(unsafe { buffer.filled(read) }) + } + + /// Wire order: what `SSL_read` parked came before these bytes. False once a callback closed the wrapper. + fn dispatch_read(&self, data: &[u8]) -> bool { + self.flush_pending_events(); + if !data.is_empty() && self.ssl.get().is_some() { + self.trigger_data_callback(data); } - true + self.ssl.get().is_some() && !self.flags.closed_notified() } fn handle_writing(&self, buffer: &mut IoBuffer) { @@ -1364,13 +1347,7 @@ pub mod ssl_wrapper { return; } - // The SSL_do_handshake/SSL_read calls above may have parked - // new-session tickets / keylog lines (BoringSSL surfaces them - // mid-read, where dispatching JS could free the SSL out from - // under the caller). The stack has unwound here, so hand them - // to the owner - same ordering as the C path's - // ssl_flush_pending_session: handshake/data callbacks first, - // then sessions. + // What SSL_do_handshake parked. `dispatch_read` hands over what SSL_read parks. self.flush_pending_events(); } else { debug_assert!( @@ -1382,7 +1359,7 @@ pub mod ssl_wrapper { /// Hand the parked sessions and keylog lines to the owner. The callbacks run JS, which may close the wrapper. fn flush_pending_events(&self) { - while self.ssl.get().is_some() { + while self.ssl.get().is_some() && !self.flags.closed_notified() { let Some(entry) = self.callbacks.sessions.borrow_mut().pop_front() else { break; }; @@ -1391,7 +1368,7 @@ pub mod ssl_wrapper { on_session(handlers.ctx, &entry); } } - while self.ssl.get().is_some() { + while self.ssl.get().is_some() && !self.flags.closed_notified() { let Some(entry) = self.callbacks.keylog.borrow_mut().pop_front() else { break; }; diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index a3854a5f4b45..a2c3d9cf4ae2 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -5871,3 +5871,142 @@ describe.concurrent("a write that is the first to observe the peer's reset fails } }); }); + +describe("over a duplex, 'session' is emitted before the data that followed it on the wire", () => { + // Same contract as the tls.connect() test above, on the tls.connect({ socket }) + // path: there the TLS engine is the SSLWrapper rather than usockets, and it + // has to hand over the sessions parked during a read pass before the + // application data decrypted in that same pass. A 'data' handler that + // destroys the socket tears the engine down, together with anything still + // parked in it, so delivering the sessions afterwards is too late. Node and + // the plain tls.connect() path both emit 'session' first in every variant + // below. + + // Transport for the TLSSocket. With `holdUntilEnd` set, everything the raw + // socket receives from then on is delivered as one chunk once the peer has + // ended, so the engine sees the whole response in a single read pass. + class Transport extends Duplex { + held: Buffer[] | null = null; + constructor(readonly raw: net.Socket) { + super(); + raw.on("data", chunk => { + if (this.held) this.held.push(chunk); + else this.push(chunk); + }); + raw.on("end", () => { + if (this.held) this.push(Buffer.concat(this.held)); + this.push(null); + }); + } + holdUntilEnd() { + this.held = []; + } + _read() {} + _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void) { + this.raw.write(chunk, encoding, callback); + } + _final(callback: (error?: Error | null) => void) { + this.raw.end(); + callback(); + } + } + + async function eventOrder( + serverOptions: tls.TlsOptions, + onConnection: (socket: TLSSocket) => void, + { holdResponse = false, request = true, destroyOn = "data" } = {}, + ): Promise { + await using server = tls.createServer({ ...COMMON_CERT_, ...serverOptions }, socket => { + // The client tears its side down without a close_notify; whatever the + // server side makes of that is not what is being observed here. + socket.on("error", () => {}); + onConnection(socket); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address() as AddressInfo; + + const raw = net.connect(port, "127.0.0.1"); + try { + await once(raw, "connect"); + const transport = new Transport(raw); + const client = tls.connect({ socket: transport, rejectUnauthorized: false }); + const events: string[] = []; + client.on("secureConnect", () => { + events.push("secureConnect"); + if (holdResponse) transport.holdUntilEnd(); + if (request) client.write("x"); + }); + for (const event of ["session", "data"]) { + client.on(event, () => { + events.push(event); + if (event === destroyOn) client.destroy(); + }); + } + await once(client, "close"); + return events; + } finally { + raw.destroy(); + } + } + + // BoringSSL issues more than one TLS 1.3 ticket per connection and each is + // its own 'session' event, so the expected sequence is "every session, then + // the data" for however many tickets arrived. Nothing may follow the data: + // the handler destroyed the socket. + function expectSessionsThenData(events: string[]) { + const sessions = events.filter(event => event === "session").length; + expect(sessions).toBeGreaterThan(0); + expect(events).toEqual(["secureConnect", ...Array(sessions).fill("session"), "data"]); + } + + it("response written after the request", async () => { + const events = await eventOrder({}, socket => { + socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")); + }); + expectSessionsThenData(events); + }); + + it("response and close_notify in the same flight", async () => { + const events = await eventOrder({}, socket => { + socket.on("data", () => socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")); + }); + expectSessionsThenData(events); + }); + + it("response larger than the engine's 64 KiB read buffer, delivered in one pass", async () => { + // The first 64 KiB are dispatched from inside the read loop to make room + // for the rest; destroying the socket there must not lose the tickets. + const events = await eventOrder( + {}, + socket => { + socket.on("data", () => socket.end(Buffer.alloc(160 * 1024, "r"))); + }, + { holdResponse: true }, + ); + expectSessionsThenData(events); + }); + + it("TLS 1.2 session established by the same flight as a server banner", async () => { + // TLS 1.2 hands the session over while the handshake completes; the + // server's Finished and its banner arrive in one read pass. + const events = await eventOrder( + { maxVersion: "TLSv1.2" }, + socket => { + socket.write("220 ready\r\n"); + socket.on("data", () => {}); + }, + { request: false }, + ); + expectSessionsThenData(events); + }); + + it("a 'session' listener that destroys the socket gets no data", async () => { + const events = await eventOrder( + {}, + socket => void socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")), + { destroyOn: "session" }, + ); + // Node still emits the second ticket of the read. + expect([...new Set(events)]).toEqual(["secureConnect", "session"]); + }); +}); From edc438bbed4064449004964722f1014ef23c92d8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:15:51 +0000 Subject: [PATCH 092/174] node:tls: a client that refuses the server in its handshake callback sends no certificate (#43946) A TLS 1.3 client finishes its handshake before it sends its final flight, which carries the client certificate. openssl.c holds that flight across on_handshake, but us_internal_ssl_close starts by flushing it, so the refusal itself sent the certificate: every verdict that JS gives (the default name check of tls.connect, https.request and http2.connect, a checkServerIdentity function, a destroy() in 'secureConnect') handed the client's identity to the server it turned down. 1. net.Socket.prototype._destroy drops the held flight first, as Node's TLSWrap::Destroy drops its pending output. Bun.connect and Bun.listen keep their behaviour. 2. The server could choose the outcome: a junk record, a close_notify or a HelloRequest behind its Finished sent the read loop to an arm that closed, and flushed, before JS was asked. Since the two commits above every arm reports the finished handshake first, through the one ssl_report_finished_handshake(), so this needs no code here. The fixture holds a row for each. 3. A write to another TLS socket from the callback is written through instead of flushing the held flight. The same rule from the other side: a TLS 1.2 server that destroys the socket in 'secureConnection' no longer sends its Finished, so the client gets ECONNRESET as with Node and not 'secureConnect'. The fixtures assert on wire bytes through a relay and run the same rows in Node. --- packages/bun-usockets/src/crypto/openssl.c | 24 +- packages/bun-usockets/src/libusockets.h | 2 + src/js/node/net.ts | 6 + src/runtime/socket/mod.rs | 4 +- src/runtime/socket/socket_body.rs | 17 + src/uws_sys/socket.rs | 7 + src/uws_sys/us_socket_t.rs | 6 + .../net/tls-reject-before-client-cert.test.ts | 51 +- test/js/node/tls/node-tls-connect.test.ts | 232 ++++++++++ test/js/node/tls/tls-client-close-fixture.mjs | 438 ++++++++++++++++++ .../js/node/tls/tls-server-refuse-fixture.mjs | 109 +++++ 11 files changed, 882 insertions(+), 14 deletions(-) create mode 100644 test/js/node/tls/tls-client-close-fixture.mjs create mode 100644 test/js/node/tls/tls-server-refuse-fixture.mjs diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index c4ca55467d91..9c27705a0566 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -555,17 +555,11 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { return length; } - if (loop_ssl_data->ssl_write_batching && - loop_ssl_data->ssl_write_batch_len && - loop_ssl_data->ssl_write_batch_owner != loop_ssl_data->ssl_socket) { - /* The batch holds another socket's records (a JS callback in this - * dispatch wrote to a second TLS socket on the same loop while the - * first socket's flight was held). Deliver them to their owner first so - * each socket's records stay in order. */ - ssl_flush_write_batch(loop_ssl_data, loop_ssl_data->ssl_write_batch_owner); - } + /* A JS callback wrote to this socket while another socket's flight is held. That flight stays: its owner can still refuse the peer. */ + int batch_is_foreign = loop_ssl_data->ssl_write_batch_len && + loop_ssl_data->ssl_write_batch_owner != loop_ssl_data->ssl_socket; - if (loop_ssl_data->ssl_write_batching && loop_ssl_data->ssl_spill_owner == NULL) { + if (loop_ssl_data->ssl_write_batching && !batch_is_foreign && loop_ssl_data->ssl_spill_owner == NULL) { /* Append the sealed record; the batch hits the kernel once, after * SSL_write returns. Reporting the full length keeps BoringSSL sealing * the next record instead of parking a partial one. Skipped while a @@ -1222,6 +1216,16 @@ void us_socket_set_inline_reject(struct us_socket_t *s) { SSL_set_verify(s_ssl(s), SSL_VERIFY_PEER, us_inline_reject_verify_callback); } +/* node drops its pending output in destroy(): https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1310-L1331 */ +void us_socket_release_held_flight(struct us_socket_t *s) { + if (!s->ssl || us_socket_is_closed(s)) return; + struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; + if (!loop_ssl_data || !loop_ssl_data->ssl_write_batch_len || loop_ssl_data->ssl_write_batch_owner != s) return; + ssl_release_batch(s->group->loop, s); + /* The peer never gets our Finished, so it cannot read a close_notify. */ + s->ssl_fatal_error = 1; +} + void us_socket_set_first_flight_before_fin(struct us_socket_t *s) { s->ssl_first_flight_before_fin = 1; } diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 0a67e8df6b29..f499d1b89e1f 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -389,6 +389,8 @@ void us_socket_start_tls_handshake(us_socket_r s) nonnull_fn_decl; * server that fails verification. Must run before the handshake is driven * (on_open, or between adopt_tls and start_tls_handshake). No-op otherwise. */ void us_socket_set_inline_reject(us_socket_r s) nonnull_fn_decl; +/* For an owner that refuses the peer in its handshake callback: drops the flight held across that callback. */ +void us_socket_release_held_flight(us_socket_r s) nonnull_fn_decl; /* Call it from on_open. A shutdown before the first handshake step sends its FIN after that step. */ void us_socket_set_first_flight_before_fin(us_socket_r s) nonnull_fn_decl; diff --git a/src/js/node/net.ts b/src/js/node/net.ts index fb84cddd897f..202685098ca8 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -268,6 +268,8 @@ const addServerName = $newRustFunction("Listener.rs", "jsAddServerName", 3); const upgradeDuplexToTLS = $newRustFunction("runtime/socket/socket.rs", "jsUpgradeDuplexToTLS", 2); // tls.connect({ socket }) upgrade: hostname policy stays with this JS layer. const upgradeTLSDeferred = $newRustFunction("runtime/socket/socket.rs", "jsUpgradeTLSDeferred", 2); +// destroy() in the handshake callback: drops the handshake flight that the native layer holds. +const releaseHeldFlight = $newRustFunction("runtime/socket/socket.rs", "jsReleaseHeldFlight", 1); const isNamedPipeSocket = $newRustFunction("runtime/socket/socket.rs", "jsIsNamedPipeSocket", 1); const getBufferedAmount = $newRustFunction("runtime/socket/socket.rs", "jsGetBufferedAmount", 1); @@ -2404,6 +2406,10 @@ Socket.prototype._destroy = function _destroy(err, callback) { this[kBytesWritten] = this._handle.bytesWritten; const currentHandle = this._handle; + // Ahead of every branch: two of them close the handle a loop turn later, when the flight has left. + if (typeof this[bunTlsSymbol] === "function" || currentHandle[kAdoptedTLSRaw]) { + releaseHeldFlight(currentHandle); + } if (this.resetAndClosing) { this.resetAndClosing = false; // resetAndDestroy() must send an RST (not a graceful FIN) so the peer sees diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index 51aec89f319e..e6e40b6bdc41 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -121,8 +121,8 @@ pub(crate) use udp_socket::UDPSocket; pub(crate) mod socket { pub(crate) use super::socket_body::{ js_create_socket_pair, js_first_flight_before_fin, js_get_buffered_amount, - js_is_named_pipe_socket, js_set_socket_options, js_upgrade_duplex_to_tls, - js_upgrade_tls_deferred, testing_ap_is, + js_is_named_pipe_socket, js_release_held_flight, js_set_socket_options, + js_upgrade_duplex_to_tls, js_upgrade_tls_deferred, testing_ap_is, }; } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 64dcf36154cb..0a6bbe943cd6 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -4683,6 +4683,23 @@ pub(crate) fn js_upgrade_tls_deferred( Err(global.throw(format_args!("Expected a socket instance"))) } +/// node:net's `destroy()`: drops the handshake flight held for the handshake callback. +#[bun_jsc::host_fn] +pub(crate) fn js_release_held_flight( + _global: &JSGlobalObject, + callframe: &CallFrame, +) -> JsResult { + jsc::mark_binding!(); + let [socket] = callframe.arguments_as_array::<1>(); + if let Some(this) = socket.as_class_ref::() { + this.socket.get().release_held_flight(); + } else if let Some(this) = socket.as_class_ref::() { + // The raw half of an `upgradeTLS` pair shares the socket of its TLS half. + this.socket.get().release_held_flight(); + } + Ok(JSValue::UNDEFINED) +} + /// `tls.connect()`'s first 'connect' listener, where node sends the ClientHello. #[bun_jsc::host_fn] pub(crate) fn js_first_flight_before_fin( diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 1b89ba33042e..ce56c749b451 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -581,6 +581,13 @@ impl NewSocketHandler { } } + /// Drop the handshake flight that usockets holds across the handshake callback. + pub fn release_held_flight(&self) { + if let InternalSocket::Connected(s) = self.socket { + sock(s).release_held_flight(); + } + } + /// A shutdown before the first handshake step sends its FIN after that step. pub fn set_first_flight_before_fin(&self) { if let InternalSocket::Connected(s) = self.socket { diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index 16cb907f9e1f..1ce25c107dc6 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -309,6 +309,11 @@ impl us_socket_t { c::us_socket_set_inline_reject(self); } + /// Drop the handshake flight that is held across the handshake callback. + pub fn release_held_flight(&mut self) { + c::us_socket_release_held_flight(self); + } + /// A shutdown before the first handshake step sends its FIN after that step. pub fn set_first_flight_before_fin(&mut self) { c::us_socket_set_first_flight_before_fin(self); @@ -601,6 +606,7 @@ mod c { ) -> *mut us_socket_t; pub(super) safe fn us_socket_start_tls_handshake(s: &mut us_socket_t); pub(super) safe fn us_socket_set_inline_reject(s: &mut us_socket_t); + pub(super) safe fn us_socket_release_held_flight(s: &mut us_socket_t); pub(super) safe fn us_socket_set_first_flight_before_fin(s: &mut us_socket_t); } } diff --git a/test/js/bun/net/tls-reject-before-client-cert.test.ts b/test/js/bun/net/tls-reject-before-client-cert.test.ts index 8d60c7b8f488..2a66ba16dd80 100644 --- a/test/js/bun/net/tls-reject-before-client-cert.test.ts +++ b/test/js/bun/net/tls-reject-before-client-cert.test.ts @@ -1,7 +1,7 @@ // A client with rejectUnauthorized on must abort the TLS handshake when the // server's chain fails verification, before its own Certificate message goes // out. Otherwise a server the client then refuses still learns the client's -// mTLS identity. node:tls already did this; these cover the other clients. +// mTLS identity. node:tls already did this for the chain; these cover the other clients. // // Each mock server requests a client certificate, accepts any, and records // the peer CN on `secure`. The client pins ca2 while the server presents the @@ -20,6 +20,7 @@ import { afterAll, describe, expect, test } from "bun:test"; import { tls as harnessTls, isWindows, tempDir } from "harness"; import { randomUUID, X509Certificate } from "node:crypto"; import { readFileSync } from "node:fs"; +import https from "node:https"; import net from "node:net"; import { join } from "node:path"; import { Duplex } from "node:stream"; @@ -159,7 +160,9 @@ const httpOk = (socket: tls.TLSSocket) => socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")); // The handshake is all this test needs: drop the connection once it is done. -const dropAfterHandshake = (socket: tls.TLSSocket) => socket.destroy(); +// Not inside 'secure': a TLS 1.2 server has not sent its Finished there yet, +// and a destroy() in that callback turns the client down. +const dropAfterHandshake = (socket: tls.TLSSocket) => setImmediate(() => socket.destroy()); // Postgres: answer the 8-byte SSLRequest with "S", then TLS starts. const postgresPrelude = (socket: net.Socket) => @@ -906,6 +909,50 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)( }, ); +// node:tls decides the name in JS, in the handshake callback. Under TLS 1.3 +// the client's final flight, with its certificate, is still held at that +// point, and the destroy() of the refusal drops it. Under TLS 1.2 the +// certificate leaves before the server's Finished, in node too. +// node-tls-connect.test.ts runs every route in node as well. +describe("TLSv1.3: a node:tls client sends no client certificate to a server whose certificate names another host", () => { + const maxVersion = "TLSv1.3"; + + test("tls.connect", async () => { + await using srv = await mtlsServer({ identity: otherHost, maxVersion }); + const err = await tlsOutcome(tls.connect({ host: "localhost", port: srv.port, ...otherHostMtls })); + expect(err?.code).toBe("ERR_TLS_CERT_ALTNAME_INVALID"); + await srv.seen.closed; + expect(srv.seen.peerCN).toBeNull(); + expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); + }); + + test("tls.connect with a checkServerIdentity function that returns an Error", async () => { + await using srv = await mtlsServer({ identity: otherHost, maxVersion }); + const checkServerIdentity = () => Object.assign(new Error("not the pinned key"), { code: "ERR_PINNED_KEY" }); + const err = await tlsOutcome( + tls.connect({ host: "localhost", port: srv.port, servername: "agent1", ...otherHostMtls, checkServerIdentity }), + ); + expect(err?.code).toBe("ERR_PINNED_KEY"); + await srv.seen.closed; + expect(srv.seen.peerCN).toBeNull(); + expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); + }); + + test("https.request", async () => { + await using srv = await mtlsServer({ identity: otherHost, onSecure: httpOk, maxVersion }); + const err = await new Promise(resolve => + https + .request({ host: "localhost", port: srv.port, agent: false, ...otherHostMtls }, () => resolve(null)) + .on("error", resolve) + .end(), + ); + expect(err?.code).toBe("ERR_TLS_CERT_ALTNAME_INVALID"); + await srv.seen.closed; + expect(srv.seen.peerCN).toBeNull(); + expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); + }); +}); + // The inline reject is installed only when the client's policy rejects a bad // chain. A client that accepts one must still complete the handshake, and // the server then sees the client certificate as before. diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index a2c3d9cf4ae2..bb2d0f71a3b1 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -20,7 +20,10 @@ import tls, { checkServerIdentity, connect as tlsConnect, TLSSocket } from "tls" import type { AddressInfo } from "net"; import { Duplex } from "node:stream"; +import { pathToFileURL } from "node:url"; import * as clientWrap from "./node-tls-client-wrap-fixture.mjs"; +import { report as closeReport } from "./tls-client-close-fixture.mjs"; +import { report as refuseReport } from "./tls-server-refuse-fixture.mjs"; const symbolConnectOptions = Symbol.for("::buntlsconnectoptions::"); @@ -3616,6 +3619,80 @@ it("ending a TLS 1.3 socket from its handshake callback still completes the serv await clientClosed.promise; }); +// The release of the held flight is node:net's. A socket of the Bun socket API +// keeps its behaviour: whatever it calls in its handshake callback, its final +// flight goes out and the server completes its handshake. A socket with no +// handshake callback gets its open callback at that point. +describe("a TLS 1.3 Bun.connect client that closes once its handshake is done", () => { + const keys = join(import.meta.dir, "..", "test", "fixtures", "keys"); + const pem = (name: string) => readFileSync(join(keys, name), "utf8"); + type Close = "end" | "shutdown" | "close"; + + async function run(callback: "handshake" | "open", method: Close) { + const serverSaw = Promise.withResolvers(); + await using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: pem("agent1-key.pem"), cert: pem("agent1-cert.pem"), requestCert: true, rejectUnauthorized: false }, + socket: { + handshake(socket, success, error) { + const peerCN = (socket.getPeerCertificate() as tls.PeerCertificate)?.subject?.CN; + serverSaw.resolve(success ? `handshake:${peerCN}` : `fail:${(error as NodeJS.ErrnoException)?.code}`); + }, + data() {}, + error() {}, + close() {}, + }, + }); + const fromClient: Buffer[] = []; + const relay = net.createServer(downstream => { + const upstream = net.connect(listener.port, "127.0.0.1"); + downstream.on("data", chunk => { + fromClient.push(chunk); + upstream.write(chunk); + }); + upstream.on("data", chunk => downstream.write(chunk)); + downstream.on("end", () => upstream.end()); + upstream.on("end", () => downstream.end()); + downstream.on("error", () => upstream.destroy()); + upstream.on("error", () => downstream.destroy()); + }); + await once(relay.listen(0, "127.0.0.1"), "listening"); + try { + const clientClosed = Promise.withResolvers(); + await Bun.connect({ + hostname: "127.0.0.1", + port: (relay.address() as AddressInfo).port, + tls: { + ca: pem("ca1-cert.pem"), + serverName: "agent1", + key: pem("agent3-key.pem"), + cert: pem("agent3-cert.pem"), + }, + socket: { + [callback]: (socket: { [method in Close]: () => void }) => void socket[method](), + data() {}, + error() {}, + close: () => clientClosed.resolve(), + }, + }); + const [server] = await Promise.all([serverSaw.promise, clientClosed.promise]); + const bytes = Buffer.concat(fromClient); + return { server, sentAfterClientHello: bytes.length > 5 + bytes.readUInt16BE(3) }; + } finally { + relay.close(); + } + } + + it.each([ + ["handshake", "close"], + ["open", "close"], + ["handshake", "end"], + ] as const)("%s: %s() completes the server's handshake", async (callback, method) => { + expect(await run(callback, method)).toEqual({ server: "handshake:agent3", sentAfterClientHello: true }); + }); +}); + // End-to-end shape of the issue: a Node TLS 1.3 server that rejects the // client's certificate does so AFTER the client saw 'secureConnect' (TLS 1.3 // clients finish first), and destroys the raw socket with no close_notify. @@ -4232,6 +4309,161 @@ describe.each([ }); }); +// Runs report(mode, version) of a fixture module in node, for every row at +// once. Resolves with node's version and the reports in the order of the rows. +async function reportsFromNode(fixture: string, rows: (readonly [version: string, mode: string])[]) { + const script = ` + import { report } from ${JSON.stringify(pathToFileURL(join(import.meta.dir, fixture)).href)}; + const rows = ${JSON.stringify(rows)}; + const reports = await Promise.all(rows.map(([version, mode]) => report(mode, version))); + console.log(JSON.stringify({ reports })); + process.exit(0); + `; + await using proc = Bun.spawn({ + cmd: [nodeExe()!, "--input-type=module", "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + const { reports } = JSON.parse(stdout) as { reports: unknown[] }; + expect(exitCode).toBe(0); + return { reports }; +} + +// A TLS 1.3 client, and a client that resumes a TLS 1.2 session, finishes its +// handshake before it sends its final flight. The handshake callback runs in +// between. A client that destroys the socket there turns the server down, so +// that flight must not go out: under TLS 1.3 it carries the client +// certificate, and it lets the server report a connection that the client +// never used. node drops its pending output on destroy(): +// https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1310-L1331 +// The last test runs the same rows in node, so the reports are pinned to it. +describe("how a TLS client's way of closing reaches the server", () => { + const delivered = (data: string, alerts: number, client = ["close:false"]) => ({ + client, + server: { event: "secureConnection", peerCN: "agent3", data, error: null }, + sentAfterClientHello: true, + alerts, + }); + const turnedDown = (...client: string[]) => ({ + client, + server: { event: "tlsClientError", code: "ECONNRESET" }, + sentAfterClientHello: false, + alerts: 0, + }); + const altnameInvalid = "error:ERR_TLS_CERT_ALTNAME_INVALID"; + + const rows = [ + ["TLSv1.3", "checkServerIdentity", turnedDown(altnameInvalid, "close:true")], + ["TLSv1.3", "checkServerIdentity function", turnedDown("error:ERR_PINNED_KEY", "close:true")], + ["TLSv1.3", "a junk record behind the server's Finished", turnedDown(altnameInvalid, "close:true")], + ["TLSv1.3", "a close_notify behind the server's Finished", turnedDown(altnameInvalid, "close:true")], + ["TLSv1.3", "application data behind the server's Finished", turnedDown(altnameInvalid, "close:true")], + ["TLSv1.3", "tls.connect({ socket })", turnedDown(altnameInvalid, "close:true")], + ["TLSv1.3", "tls.connect({ socket }) and a destroy() of that socket", turnedDown("close:false")], + ["TLSv1.3", "tls.connect({ socket }) over a Duplex", turnedDown(altnameInvalid, "close:true")], + ["TLSv1.3", "https.request", turnedDown(altnameInvalid)], + ["TLSv1.3", "http2.connect", turnedDown(altnameInvalid)], + ["TLSv1.3", "destroy()", turnedDown("close:false")], + ["TLSv1.3", "destroy(error)", turnedDown("error:ERR_REFUSED", "close:true")], + ["TLSv1.3", "destroy() from process.nextTick", turnedDown("close:false")], + ["TLSv1.3", "destroy() from queueMicrotask", turnedDown("close:false")], + ["TLSv1.3", "destroy() on a resumed session", turnedDown("reused:true", "close:false")], + ["TLSv1.2", "destroy() on a resumed session", turnedDown("reused:true", "close:false")], + [ + "TLSv1.3", + "checkServerIdentity function that writes to another TLS socket", + turnedDown("error:ERR_PINNED_KEY", "close:true"), + ], + + // A graceful close still sends the flight. + ["TLSv1.3", "end()", delivered("", 1)], + ["TLSv1.3", "end(data)", delivered("hello", 1)], + ["TLSv1.3", "destroySoon()", delivered("", 1)], + + // A server that resumes a TLS 1.2 session can ask for a renegotiation right + // behind its Finished. The client reports the handshake and sends its own + // Finished before it answers with a new ClientHello. + [ + "TLSv1.2", + "a HelloRequest behind the server's Finished", + { + client: ["reused:true", "ChangeCipherSpec", "Handshake", "Handshake", "close:false"], + server: { event: "tlsClientError", code: "ECONNRESET" }, + sentAfterClientHello: true, + alerts: 0, + }, + ], + + // In a full TLS 1.2 handshake the client's flight leaves before the server's Finished. + ["TLSv1.2", "checkServerIdentity", delivered("", 0, [altnameInvalid, "close:true"])], + ["TLSv1.2", "destroy()", delivered("", 0)], + ] as const; + + it.each(rows)("%s %s", async (version, mode, expected) => { + expect(await closeReport(mode, version)).toEqual(expected); + }); + + // node gives this verdict too. It also reports the junk record as an error of the socket. + it("TLSv1.3 a junk record behind the Finished does not change the verdict on the certificate", async () => { + const mode = "a junk record behind the Finished of a server that the client does not verify"; + expect(await closeReport(mode, "TLSv1.3")).toEqual( + turnedDown("authorized:false", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "close:false"), + ); + }); + + it.skipIf(!nodeExe())("node gives the same reports", async () => { + const { reports } = await reportsFromNode( + "tls-client-close-fixture.mjs", + rows.map(([version, mode]) => [version, mode]), + ); + expect(reports).toEqual(rows.map(([, , expected]) => expected)); + }); +}); + +// The same rule from the server's side. In a full TLS 1.2 handshake the +// server's Finished is the last message, and the server's handshake callback +// runs before it is sent. A server that destroys the socket there turns the +// client down, so the Finished must not go out: the client then reports a +// connection that the server never had. In TLS 1.3 the server has nothing left +// to send at that point. +describe("a server that turns the client down once its handshake is done", () => { + const turnedDown = { secureConnect: false, error: "ECONNRESET" }; + const connected = { secureConnect: true, error: null }; + + const rows = [ + ["TLSv1.2", "requestCert and rejectUnauthorized", { client: turnedDown, server: "tlsClientError" }], + ["TLSv1.2", "destroy() in 'secureConnection'", { client: turnedDown, server: "secureConnection" }], + [ + "TLSv1.2", + "destroy() in 'secureConnection' with a junk record behind the client's Finished", + { client: turnedDown, server: "secureConnection" }, + ], + [ + "TLSv1.2", + "destroy() in 'secure' of a TLSSocket that wraps the connection", + { client: turnedDown, server: "secure" }, + ], + ["TLSv1.2", "end() in 'secureConnection'", { client: connected, server: "secureConnection" }], + ["TLSv1.3", "requestCert and rejectUnauthorized", { client: connected, server: "tlsClientError" }], + ["TLSv1.3", "destroy() in 'secureConnection'", { client: connected, server: "secureConnection" }], + ] as const; + + it.each(rows)("%s %s", async (version, mode, expected) => { + expect(await refuseReport(mode, version)).toEqual(expected); + }); + + it.skipIf(!nodeExe())("node gives the same reports", async () => { + const { reports } = await reportsFromNode( + "tls-server-refuse-fixture.mjs", + rows.map(([version, mode]) => [version, mode]), + ); + expect(reports).toEqual(rows.map(([, , expected]) => expected)); + }); +}); + 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 => { diff --git a/test/js/node/tls/tls-client-close-fixture.mjs b/test/js/node/tls/tls-client-close-fixture.mjs new file mode 100644 index 000000000000..d146192e13a1 --- /dev/null +++ b/test/js/node/tls/tls-client-close-fixture.mjs @@ -0,0 +1,438 @@ +// How a TLS client's way of closing reaches the server. report(mode, version) +// makes one connection and resolves with what the server and the wire saw. +// node-tls-connect.test.ts runs it in bun and in node, and expects the same +// reports from both. +// +// The server asks for a client certificate and accepts any. A plain TCP relay +// in front of it records what the client sent. The report is about the last +// connection of the mode: +// - `client` lists the first error and the 'close' event of the client. +// - `server` is what the server saw: "secureConnection" with the CN of the +// client certificate, the data it read and the error of its socket, or +// "tlsClientError" with the error code. +// - `sentAfterClientHello` says whether the client sent anything after its +// first record. A client that turns the server down does not. +// - `alerts` counts the client's alert records. A TLS 1.3 alert travels as an +// application data record of 19 bytes: the alert, the inner content type and +// the AEAD tag. Nothing else a mode sends has that size. +import { createCipheriv, createDecipheriv, createHmac } from "node:crypto"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import http2 from "node:http2"; +import https from "node:https"; +import net from "node:net"; +import { join } from "node:path"; +import { Duplex } from "node:stream"; +import tls from "node:tls"; + +const keys = join(import.meta.dirname, "..", "test", "fixtures", "keys"); +const pem = name => readFileSync(join(keys, name)); + +// One application data record that does not decrypt. +const junkRecord = Buffer.concat([Buffer.from([23, 3, 3, 0, 16]), Buffer.alloc(16, 0xa5)]); + +// The record protection keys of a TLS 1.3 traffic secret (RFC 8446, section +// 7.3). `serverHello` starts with the ServerHello record, which names the +// cipher suite. +function trafficKeys(serverHello, secret) { + const cipherSuite = serverHello.readUInt16BE(44 + serverHello[43]); + const [hash, cipher, keyLength] = { + 0x1301: ["sha256", "aes-128-gcm", 16], + 0x1302: ["sha384", "aes-256-gcm", 32], + 0x1303: ["sha256", "chacha20-poly1305", 32], + }[cipherSuite]; + const expandLabel = (label, length) => + createHmac(hash, secret) + .update( + Buffer.concat([Buffer.from([0, length, 6 + label.length]), Buffer.from("tls13 " + label), Buffer.from([0, 1])]), + ) + .digest() + .subarray(0, length); + return { cipher, key: expandLabel("key", keyLength), iv: expandLabel("iv", 12) }; +} + +// `content` of type `contentType` as the first record under the server's +// application traffic secret (RFC 8446, section 5.2). A TLS 1.3 server may send +// records under that secret right behind its Finished. OpenSSL and BoringSSL +// send none during their handshake, so the relay seals it. +function sealedBehindFinished(serverHello, secret, content, contentType) { + const { cipher, key, iv } = trafficKeys(serverHello, secret); + const header = Buffer.from([23, 3, 3, 0, content.length + 17]); + const seal = createCipheriv(cipher, key, iv, { authTagLength: 16 }); + seal.setAAD(header); + return Buffer.concat([header, seal.update(Buffer.from([...content, contentType])), seal.final(), seal.getAuthTag()]); +} + +// The length of the server's first flight: all records up to the one that +// completes its Finished. 0 while `bytes` does not hold the whole flight. The +// relay opens the records under the server's handshake traffic secret to find +// the Finished, so the way TCP splits the flight does not matter. +function flightLength(bytes, secret) { + let keys; + let messages = Buffer.alloc(0); + let sequence = 0; + for (let at = 0; at + 5 <= bytes.length; ) { + const end = at + 5 + bytes.readUInt16BE(at + 3); + if (end > bytes.length) return 0; + if (bytes[at] === 23) { + keys ??= trafficKeys(bytes, secret); + const nonce = Buffer.from(keys.iv); + nonce[11] ^= sequence++; + const open = createDecipheriv(keys.cipher, keys.key, nonce, { authTagLength: 16 }); + open.setAAD(bytes.subarray(at, at + 5)); + open.setAuthTag(bytes.subarray(end - 16, end)); + const inner = Buffer.concat([open.update(bytes.subarray(at + 5, end - 16)), open.final()]); + // A record holds its content, then the content type, then zero padding. + const contentType = inner.findLastIndex(byte => byte !== 0); + messages = Buffer.concat([messages, inner.subarray(0, contentType)]); + for (let message = 0; message + 4 <= messages.length; ) { + const next = message + 4 + messages.readUIntBE(message + 1, 3); + if (next > messages.length) break; + // Handshake message type 20 is Finished. + if (messages[message] === 20) return end; + message = next; + } + } + at = end; + } + return 0; +} + +// A HelloRequest as the first record behind the Finished of a TLS 1.2 server +// that resumed a session with TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (RFC 5246, +// sections 5, 6.3 and 7.4.1.1, and RFC 5288). `hello` starts with the hello +// record of each side, which carries its random at offset 11. +function sealedHelloRequest(clientHello, serverHello, masterSecret) { + const seed = Buffer.concat([ + Buffer.from("key expansion"), + serverHello.subarray(11, 43), + clientHello.subarray(11, 43), + ]); + let a = seed; + let keyBlock = Buffer.alloc(0); + while (keyBlock.length < 40) { + a = createHmac("sha256", masterSecret).update(a).digest(); + keyBlock = Buffer.concat([keyBlock, createHmac("sha256", masterSecret).update(a).update(seed).digest()]); + } + // The Finished was record 0 under the server's key, so this is record 1. + const sequence = Buffer.from([0, 0, 0, 0, 0, 0, 0, 1]); + const helloRequest = Buffer.from([0, 0, 0, 0]); + const seal = createCipheriv( + "aes-128-gcm", + keyBlock.subarray(16, 32), + Buffer.concat([keyBlock.subarray(36, 40), sequence]), + { + authTagLength: 16, + }, + ); + seal.setAAD(Buffer.concat([sequence, Buffer.from([22, 3, 3, 0, helloRequest.length])])); + const body = Buffer.concat([sequence, seal.update(helloRequest), seal.final(), seal.getAuthTag()]); + return Buffer.concat([Buffer.from([22, 3, 3, 0, body.length]), body]); +} + +// The types of the whole records in `bytes`. +function recordTypes(bytes) { + const types = []; + for (let at = 0; at + 5 <= bytes.length && at + 5 + bytes.readUInt16BE(at + 3) <= bytes.length; ) { + types.push({ 20: "ChangeCipherSpec", 21: "Alert", 22: "Handshake", 23: "ApplicationData" }[bytes[at]]); + at += 5 + bytes.readUInt16BE(at + 3); + } + return types; +} + +// The length of the first flight of a TLS 1.2 server that resumes a session: +// ServerHello, ChangeCipherSpec, Finished. 0 for any other start. +function resumedFlightLength(bytes) { + if (recordTypes(bytes).slice(0, 3).join() !== "Handshake,ChangeCipherSpec,Handshake") return 0; + let at = 0; + for (let record = 0; record < 3; record++) at += 5 + bytes.readUInt16BE(at + 3); + return at; +} + +function wire(bytes) { + let alerts = 0; + for (let at = 0; at + 5 <= bytes.length; at += 5 + bytes.readUInt16BE(at + 3)) { + const type = bytes[at]; + if (type === 21 || (type === 23 && bytes.readUInt16BE(at + 3) === 19)) alerts++; + } + const clientHello = bytes.length >= 5 ? 5 + bytes.readUInt16BE(3) : 0; + return { sentAfterClientHello: bytes.length > clientHello, alerts }; +} + +// What a 'secureConnect' listener does with the socket. +const inSecureConnect = { + "destroy()": socket => socket.destroy(), + "destroy(error)": socket => socket.destroy(Object.assign(new Error("refused"), { code: "ERR_REFUSED" })), + "destroy() from process.nextTick": socket => process.nextTick(() => socket.destroy()), + "destroy() from queueMicrotask": socket => queueMicrotask(() => socket.destroy()), + "end()": socket => socket.end(), + "end(data)": socket => socket.end("hello"), + "destroySoon()": socket => socket.destroySoon(), +}; + +// A second TLS connection of this process. +async function secondConnection() { + const server = tls.createServer({ key: pem("agent1-key.pem"), cert: pem("agent1-cert.pem") }, socket => { + socket.on("error", () => {}); + socket.resume(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const socket = tls.connect({ + host: "127.0.0.1", + port: server.address().port, + ca: pem("ca1-cert.pem"), + servername: "agent1", + }); + socket.on("error", () => {}); + await once(socket, "secureConnect"); + return { + socket, + close() { + socket.destroy(); + server.close(); + }, + }; +} + +const helloRequestMode = "a HelloRequest behind the server's Finished"; + +export async function report(mode, version) { + const second = + mode === "checkServerIdentity function that writes to another TLS socket" ? await secondConnection() : null; + // agent1 is signed by ca1 and names only "agent1". + let serverSaw = Promise.withResolvers(); + const server = tls.createServer({ + key: pem("agent1-key.pem"), + cert: pem("agent1-cert.pem"), + requestCert: true, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + ALPNProtocols: mode === "http2.connect" ? ["h2"] : undefined, + ciphers: mode === helloRequestMode ? "ECDHE-RSA-AES128-GCM-SHA256" : undefined, + }); + server.on("secureConnection", socket => { + // BoringSSL sends its TLS 1.3 tickets with the first write of the server. + if (mode === "destroy() on a resumed session") socket.write("x"); + const peerCN = socket.getPeerCertificate()?.subject?.CN ?? null; + let data = ""; + let error = null; + socket.on("data", chunk => (data += chunk)); + socket.on("error", err => (error = err.code)); + socket.on("close", () => serverSaw.resolve({ event: "secureConnection", peerCN, data, error })); + }); + server.on("tlsClientError", error => serverSaw.resolve({ event: "tlsClientError", code: error.code })); + // The server derives its secrets before it sends the flight they protect. + const secrets = {}; + server.on("keylog", line => { + const [label, , secret] = line.toString().trim().split(" "); + secrets[label] = Buffer.from(secret, "hex"); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + + // What the relay puts behind the server's Finished, in the same write. + const behindFinished = { + "a junk record behind the server's Finished": () => junkRecord, + "a close_notify behind the server's Finished": flight => + sealedBehindFinished(flight, secrets.SERVER_TRAFFIC_SECRET_0, [1, 0], 21), + "application data behind the server's Finished": flight => + sealedBehindFinished(flight, secrets.SERVER_TRAFFIC_SECRET_0, Buffer.from("early"), 23), + "a junk record behind the Finished of a server that the client does not verify": () => junkRecord, + }[mode]; + + let fromClient = []; + // What the client sent behind the HelloRequest, once its new ClientHello is in. + const behindHelloRequest = Promise.withResolvers(); + const relay = net.createServer(downstream => { + fromClient = []; + let flight = behindFinished ? Buffer.alloc(0) : null; + let resumedFlight = mode === helloRequestMode ? Buffer.alloc(0) : null; + let frozenAt = -1; + const upstream = net.connect(server.address().port, "127.0.0.1"); + downstream.on("data", chunk => { + fromClient.push(chunk); + if (frozenAt < 0) return void upstream.write(chunk); + // The new ClientHello is the one long record. It leaves last. + const sent = Buffer.concat(fromClient).subarray(frozenAt); + for (let at = 0; at + 5 <= sent.length; at += 5 + sent.readUInt16BE(at + 3)) { + if (sent.readUInt16BE(at + 3) > 100 && at + 5 + sent.readUInt16BE(at + 3) <= sent.length) { + behindHelloRequest.resolve(recordTypes(sent)); + } + } + }); + upstream.on("data", chunk => { + if (frozenAt >= 0) return; + if (resumedFlight) { + resumedFlight = Buffer.concat([resumedFlight, chunk]); + const length = resumedFlightLength(resumedFlight); + if (length) { + const sent = Buffer.concat(fromClient); + frozenAt = sent.length; + const helloRequest = sealedHelloRequest(sent, resumedFlight, secrets.CLIENT_RANDOM); + return void downstream.write(Buffer.concat([resumedFlight.subarray(0, length), helloRequest])); + } + // A full handshake starts with three handshake records. Only the + // first flight of a connection can be a resumed one. + if (recordTypes(resumedFlight).length < 3) return; + chunk = resumedFlight; + resumedFlight = null; + } + if (flight) { + flight = Buffer.concat([flight, chunk]); + const length = flightLength(flight, secrets.SERVER_HANDSHAKE_TRAFFIC_SECRET); + if (!length) return; + chunk = Buffer.concat([flight.subarray(0, length), behindFinished(flight), flight.subarray(length)]); + flight = null; + } + downstream.write(chunk); + }); + downstream.on("end", () => upstream.end()); + upstream.on("end", () => downstream.end()); + downstream.on("error", () => upstream.destroy()); + upstream.on("error", () => downstream.destroy()); + }); + await once(relay.listen(0, "127.0.0.1"), "listening"); + + const port = relay.address().port; + // `servername: "agent1"` is the name the certificate carries. + const accepted = { + host: "127.0.0.1", + port, + ca: pem("ca1-cert.pem"), + servername: "agent1", + key: pem("agent3-key.pem"), + cert: pem("agent3-cert.pem"), + }; + const refused = { ...accepted, servername: "not-agent1" }; + const pinnedKeyError = () => Object.assign(new Error("not the pinned key"), { code: "ERR_PINNED_KEY" }); + + let client = []; + function watch(socket) { + // node reports the junk record too, after the error of the refusal. + socket.on("error", error => client.some(event => event.startsWith("error:")) || client.push(`error:${error.code}`)); + return new Promise(resolve => + socket.on("close", hadError => { + client.push(`close:${hadError}`); + resolve(); + }), + ); + } + + // A full handshake that keeps its session, then the options of a second one. + async function resume(second) { + const first = tls.connect(accepted); + const firstClosed = watch(first); + // A TLS 1.3 ticket arrives after the handshake, so the socket has to read. + first.resume(); + const [session] = await once(first, "session"); + first.end(); + await Promise.all([firstClosed, serverSaw.promise]); + client = []; + serverSaw = Promise.withResolvers(); + return { ...second, session }; + } + + let closed; + switch (mode) { + case "checkServerIdentity": + case "a junk record behind the server's Finished": + case "a close_notify behind the server's Finished": + case "application data behind the server's Finished": + closed = watch(tls.connect(refused)); + break; + case "tls.connect({ socket }) over a Duplex": { + const raw = net.connect(port, "127.0.0.1"); + raw.on("error", () => {}); + const socket = new Duplex({ + read() {}, + write: (chunk, encoding, callback) => raw.write(chunk, callback), + final: callback => raw.end(callback), + destroy: (error, callback) => (raw.destroy(), callback(error)), + }); + raw.on("data", chunk => socket.push(chunk)); + raw.on("end", () => socket.push(null)); + closed = watch(tls.connect({ ...refused, socket })); + break; + } + case "checkServerIdentity function": + closed = watch(tls.connect({ ...accepted, checkServerIdentity: pinnedKeyError })); + break; + case "checkServerIdentity function that writes to another TLS socket": { + const checkServerIdentity = () => { + second.socket.write("checked"); + return pinnedKeyError(); + }; + closed = watch(tls.connect({ ...accepted, checkServerIdentity })); + break; + } + case "destroy() on a resumed session": { + const socket = tls.connect(await resume(accepted), () => { + client.push(`reused:${socket.isSessionReused()}`); + socket.destroy(); + }); + closed = watch(socket); + break; + } + case "tls.connect({ socket })": { + const raw = net.connect(port, "127.0.0.1"); + raw.on("error", () => {}); + await once(raw, "connect"); + closed = watch(tls.connect({ ...refused, socket: raw })); + break; + } + case "a junk record behind the Finished of a server that the client does not verify": { + // The verdict on the certificate must not depend on the record that follows the Finished. + const socket = tls.connect({ ...accepted, ca: undefined, rejectUnauthorized: false }, () => { + client.push(`authorized:${socket.authorized}`, String(socket.authorizationError)); + socket.destroy(); + }); + closed = watch(socket); + break; + } + case helloRequestMode: { + // The relay keeps what the client sends from here on, so the server sees no more of it. + const socket = tls.connect(await resume(accepted), () => client.push(`reused:${socket.isSessionReused()}`)); + closed = watch(socket); + client.push(...(await behindHelloRequest.promise)); + socket.destroy(); + break; + } + case "tls.connect({ socket }) and a destroy() of that socket": { + // The raw socket closes its handle two loop turns after its destroy(). + const raw = net.connect(port, "127.0.0.1"); + raw.on("error", () => {}); + await once(raw, "connect"); + closed = watch(tls.connect({ ...accepted, socket: raw }, () => raw.destroy())); + break; + } + case "https.request": { + const request = https.request({ ...refused, agent: false }); + closed = new Promise(resolve => + request.on("error", error => { + client.push(`error:${error.code}`); + resolve(); + }), + ); + request.end(); + break; + } + case "http2.connect": { + const session = http2.connect(`https://127.0.0.1:${port}`, refused); + session.on("error", error => client.push(`error:${error.code}`)); + closed = new Promise(resolve => session.on("close", resolve)); + break; + } + default: { + const act = inSecureConnect[mode]; + if (!act) throw new Error(`unknown mode ${mode}`); + const socket = tls.connect(accepted, () => act(socket)); + closed = watch(socket); + } + } + + const [saw] = await Promise.all([serverSaw.promise, closed]); + second?.close(); + relay.close(); + server.close(); + return { client, server: saw, ...wire(Buffer.concat(fromClient)) }; +} diff --git a/test/js/node/tls/tls-server-refuse-fixture.mjs b/test/js/node/tls/tls-server-refuse-fixture.mjs new file mode 100644 index 000000000000..957a6fa4c200 --- /dev/null +++ b/test/js/node/tls/tls-server-refuse-fixture.mjs @@ -0,0 +1,109 @@ +// A TLS server that turns the client down once its own handshake is done. +// report(mode, version) makes one connection and resolves with what both +// sides saw. node-tls-connect.test.ts runs it in bun and in node, and expects +// the same reports from both. +// +// In a full TLS 1.2 handshake the server's Finished is the last message, and +// the server's handshake callback runs before it is sent. In TLS 1.3 the +// server has nothing left to send at that point. +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import net from "node:net"; +import { join } from "node:path"; +import tls from "node:tls"; + +const keys = join(import.meta.dirname, "..", "test", "fixtures", "keys"); +const pem = name => readFileSync(join(keys, name)); + +// One application data record that does not decrypt. +const junkRecord = Buffer.concat([Buffer.from([23, 3, 3, 0, 16]), Buffer.alloc(16, 0xa5)]); +const junkMode = "destroy() in 'secureConnection' with a junk record behind the client's Finished"; +const wrapMode = "destroy() in 'secure' of a TLSSocket that wraps the connection"; + +export async function report(mode, version) { + // The client certificate (agent3) is not signed by ca1, so the server cannot verify it. + const serverDone = Promise.withResolvers(); + let serverEvent = null; + const options = { + key: pem("agent1-key.pem"), + cert: pem("agent1-cert.pem"), + ca: pem("ca1-cert.pem"), + requestCert: true, + rejectUnauthorized: mode === "requestCert and rejectUnauthorized", + minVersion: version, + maxVersion: version, + }; + const server = + mode === wrapMode + ? net.createServer(raw => { + raw.on("error", () => {}); + const socket = new tls.TLSSocket(raw, { ...options, isServer: true }); + socket.on("error", () => {}); + socket.on("close", () => serverDone.resolve()); + socket.on("secure", () => { + serverEvent = "secure"; + socket.destroy(); + }); + }) + : tls.createServer(options); + server.on("secureConnection", socket => { + serverEvent = "secureConnection"; + socket.on("error", () => {}); + socket.on("close", () => serverDone.resolve()); + if (mode.startsWith("destroy()")) socket.destroy(); + else socket.end(); + }); + server.on("tlsClientError", () => { + serverEvent = "tlsClientError"; + serverDone.resolve(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + + // Puts the junk record behind the flight that ends with the client's Finished, in the same write. In TLS 1.2 that + // flight ends with the record behind the client's ChangeCipherSpec. + const relay = net.createServer(downstream => { + const upstream = net.connect(server.address().port, "127.0.0.1"); + let pending = Buffer.alloc(0); + let held = []; + let behindChangeCipherSpec = false; + downstream.on("data", chunk => { + if (mode !== junkMode || !held) return void upstream.write(chunk); + pending = Buffer.concat([pending, chunk]); + while (held && pending.length >= 5 && pending.length >= 5 + pending.readUInt16BE(3)) { + const record = pending.subarray(0, 5 + pending.readUInt16BE(3)); + pending = pending.subarray(record.length); + // The ClientHello goes through at once. + if (!held.length && record[0] === 22 && record[5] === 1) upstream.write(record); + else held.push(record); + if (behindChangeCipherSpec) { + upstream.write(Buffer.concat([...held, junkRecord, pending])); + held = null; + } + behindChangeCipherSpec = record[0] === 20; + } + }); + upstream.on("data", chunk => downstream.write(chunk)); + downstream.on("end", () => upstream.end()); + upstream.on("end", () => downstream.end()); + downstream.on("error", () => upstream.destroy()); + upstream.on("error", () => downstream.destroy()); + }); + await once(relay.listen(0, "127.0.0.1"), "listening"); + + const client = { secureConnect: false, error: null }; + const socket = tls.connect({ + host: "127.0.0.1", + port: relay.address().port, + ca: pem("ca1-cert.pem"), + servername: "agent1", + key: pem("agent3-key.pem"), + cert: pem("agent3-cert.pem"), + }); + socket.on("secureConnect", () => (client.secureConnect = true)); + socket.on("error", error => (client.error = error.code)); + socket.resume(); + await Promise.all([new Promise(resolve => socket.on("close", resolve)), serverDone.promise]); + relay.close(); + server.close(); + return { client, server: serverEvent }; +} From 726e79f277ac14b6a9fab0cff9bcae94e11e9275 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:17:34 +0000 Subject: [PATCH 093/174] Bun.connect: terminate() in the handshake callback sends no client certificate (#43946 follow-up) Bun.connect had no way to refuse a server in `handshake()` without handing over the client certificate: every close code flushed the held final flight first. terminate() is documented as an abrupt termination (SO_LINGER 0, then close), so a close with LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET now drops that flight. close() and end() still send it. --- packages/bun-types/bun.d.ts | 3 +++ packages/bun-usockets/src/crypto/openssl.c | 4 +++- test/js/node/tls/node-tls-connect.test.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 806b972bf1cb..b675302cfc82 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -6849,6 +6849,9 @@ declare module "bun" { * It uses `SO_LINGER` with `l_onoff=1` and `l_linger=0` before calling `close(2)`. * Consider using {@link close close()} or {@link end end()} for graceful shutdowns. * + * In the `handshake` callback of a TLS 1.3 client, this refuses the server + * before the client certificate is sent. + * * @example * ```ts * socket.terminate(); diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 9c27705a0566..6a49ba199ae2 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2135,7 +2135,9 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; if (loop_ssl_data && loop_ssl_data->ssl_write_batch_len && loop_ssl_data->ssl_write_batch_owner == s && !us_socket_is_closed(s)) { - ssl_flush_write_batch(loop_ssl_data, s); + /* A reset sends nothing more: terminate() in the handshake callback refuses the peer. */ + if (code == LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET) ssl_release_batch(s->group->loop, s); + else ssl_flush_write_batch(loop_ssl_data, s); } } /* Neither node's `_handle.close()` (FAST_SHUTDOWN, no reason) nor a graceful diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index bb2d0f71a3b1..50ad58236748 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -3620,13 +3620,13 @@ it("ending a TLS 1.3 socket from its handshake callback still completes the serv }); // The release of the held flight is node:net's. A socket of the Bun socket API -// keeps its behaviour: whatever it calls in its handshake callback, its final +// keeps its behaviour: with close() or end() in its handshake callback, its final // flight goes out and the server completes its handshake. A socket with no // handshake callback gets its open callback at that point. describe("a TLS 1.3 Bun.connect client that closes once its handshake is done", () => { const keys = join(import.meta.dir, "..", "test", "fixtures", "keys"); const pem = (name: string) => readFileSync(join(keys, name), "utf8"); - type Close = "end" | "shutdown" | "close"; + type Close = "end" | "close" | "terminate"; async function run(callback: "handshake" | "open", method: Close) { const serverSaw = Promise.withResolvers(); @@ -3691,6 +3691,11 @@ describe("a TLS 1.3 Bun.connect client that closes once its handshake is done", ] as const)("%s: %s() completes the server's handshake", async (callback, method) => { expect(await run(callback, method)).toEqual({ server: "handshake:agent3", sentAfterClientHello: true }); }); + + // A reset sends nothing more, so this is how a `handshake` callback refuses the server. + it.each(["handshake", "open"] as const)("%s: terminate() sends no client certificate", async callback => { + expect(await run(callback, "terminate")).toEqual({ server: "fail:ECONNRESET", sentAfterClientHello: false }); + }); }); // End-to-end shape of the issue: a Node TLS 1.3 server that rejects the From c263e9608e85b32a23d4288782449d351a9ec3f2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:40:47 +0000 Subject: [PATCH 094/174] test(node:tls): a TLS 1.2 client whose certificate the server refuses gets a hard error (#33630) The acceptance test of #33630 for the behaviour that #43946 gives: like Node, the server sends neither its Finished nor an alert, so the client gets ECONNRESET and no 'secureConnect'. Before, it got 'secureConnect' and a clean 'end'. serverTls moves to the 2048-bit ca2/agent10 of the vendored Node fixtures: Node rejects the 1024-bit copy as "CA certificate key too weak", which failed 16 cases of this file there. --- test/js/node/tls/node-tls-cert.test.ts | 60 +++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index 71d4985b0e04..92f6cebec39d 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -11,12 +11,14 @@ const clientTls = { key: readFileSync(join(import.meta.dir, "fixtures", "ec10-key.pem"), "utf8"), cert: readFileSync(join(import.meta.dir, "fixtures", "ec10-cert.pem"), "utf8"), ca: readFileSync(join(import.meta.dir, "fixtures", "ca5-cert.pem"), "utf8"), -} as Certs; +}; +// The copy of ca2 under ./fixtures has a 1024-bit key, which Node rejects as "CA certificate key too weak". +const upstreamKeys = join(import.meta.dir, "..", "test", "fixtures", "keys"); const serverTls = { - key: readFileSync(join(import.meta.dir, "fixtures", "agent10-key.pem"), "utf8"), - cert: readFileSync(join(import.meta.dir, "fixtures", "agent10-cert.pem"), "utf8"), - ca: readFileSync(join(import.meta.dir, "fixtures", "ca2-cert.pem"), "utf8"), -} as Certs; + key: readFileSync(join(upstreamKeys, "agent10-key.pem"), "utf8"), + cert: readFileSync(join(upstreamKeys, "agent10-cert.pem"), "utf8"), + ca: readFileSync(join(upstreamKeys, "ca2-cert.pem"), "utf8"), +}; function split(file: any, into: any) { const certs = /([^]*END CERTIFICATE-----\r?\n)(-----BEGIN[^]*)/.exec(file) as RegExpExecArray; @@ -369,6 +371,54 @@ it("rejects an unverifiable client certificate by default when requestCert is tr } }); +it("client sees a hard error, not a clean close, when the server rejects its certificate under rejectUnauthorized", async () => { + // TLS 1.2, so the refusal lands before the client's 'secureConnect'. A TLS 1.3 client finishes its handshake first. + const untrustedClient = { + key: readFileSync(join(import.meta.dir, "fixtures", "agent2-key.pem"), "utf8"), + cert: readFileSync(join(import.meta.dir, "fixtures", "agent2-cert.pem"), "utf8"), + }; + const server = tls.createServer( + { + key: serverTls.key, + cert: serverTls.cert, + ca: clientTls.ca, + requestCert: true, + rejectUnauthorized: true, + maxVersion: "TLSv1.2", + }, + socket => socket.end("SECRET"), + ); + const refused = once(server, "tlsClientError"); + await once(server.listen(0, "127.0.0.1"), "listening"); + + try { + const events: string[] = []; + const closed = Promise.withResolvers(); + const client = tls.connect({ + host: "127.0.0.1", + port: (server.address() as AddressInfo).port, + ca: serverTls.ca, + ...untrustedClient, + checkServerIdentity, + }); + client.on("secureConnect", () => events.push("secureConnect")); + client.on("data", data => events.push(`data ${data}`)); + client.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}`)); + client.on("close", hadError => { + events.push(`close ${hadError}`); + closed.resolve(); + }); + await closed.promise; + // The server sends neither its Finished nor an alert: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1214-L1232 + expect(events).toEqual(["error ECONNRESET", "close true"]); + // Bun reports the certificate check to 'tlsClientError', Node how the connection ended. + const [err] = await refused; + expect(err.code).toBe(process.versions.bun ? "DEPTH_ZERO_SELF_SIGNED_CERT" : "ECONNRESET"); + } finally { + server.close(); + } +}); + it("explicit rejectUnauthorized: false still admits an unverified client certificate", async () => { const { promise: handledSocket, resolve: onHandledSocket } = Promise.withResolvers(); From 89a997e46b692860b946d7aa7e173f080f865731 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:00:39 +0000 Subject: [PATCH 095/174] node:tls: a failed write on an established session keeps its syscall The native layer hands two kinds of EPROTO to 'error': the engine's report of a fatal TLS error, which is rebuilt as ERR_SSL_, and a write that failed, which is already complete. Only the first is rebuilt. --- src/js/node/_http2_upgrade.ts | 4 +++- src/js/node/net.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index d249bc955efe..ce5567d5588a 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -178,7 +178,9 @@ function socketError(this: TLSProxySocket, _socket: NativeHandle, err: NodeJS.Er if (!ctx.server._requestCert && err?.code === "UNABLE_TO_GET_ISSUER_CERT") { return; } - if (this._secureEstablished && err?.code === "EPROTO") err = require("internal/tls").tlsHandshakeError(err); + if (this._secureEstablished && err?.code === "EPROTO" && err.syscall === undefined) { + err = require("internal/tls").tlsHandshakeError(err); + } this.destroy(err); } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 202685098ca8..15f53f433ee5 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -698,9 +698,11 @@ function onConnectEnd() { } } -// The native layer hands a fatal TLS error on an established session to `error`, then closes. +// The native layer hands a fatal TLS error on an established session to `error`, then closes. A failed write has a syscall. function establishedTLSError(self, error) { - if (self._secureEstablished && error?.code === "EPROTO") return require("internal/tls").tlsHandshakeError(error); + if (self._secureEstablished && error?.code === "EPROTO" && error.syscall === undefined) { + return require("internal/tls").tlsHandshakeError(error); + } } // Node reports a throwing 'data' listener as uncaughtException and keeps reading. From 067718539da4929fb9ceab39845456964d18238b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:00:40 +0000 Subject: [PATCH 096/174] test(tls): a throwing ALPNCallback over a Duplex sends its alert; a wss client closes its proxy connection The stream engine sends its own fatal alerts now, so the client of a throwing ALPNCallback sees no_application_protocol, as it does on a real socket. The tunnelled WebSocket closes the connection to its proxy after a bad record. --- test/js/bun/net/tls-fatal-error-closes.test.ts | 2 +- test/js/node/tls/node-tls-server.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/js/bun/net/tls-fatal-error-closes.test.ts b/test/js/bun/net/tls-fatal-error-closes.test.ts index e94331885036..a93304ea1d7a 100644 --- a/test/js/bun/net/tls-fatal-error-closes.test.ts +++ b/test/js/bun/net/tls-fatal-error-closes.test.ts @@ -290,7 +290,7 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)("a bad record on an established % using proxy = await startRecordingProxy(); const events = await webSocketEvents(server.port, { ca: cert.cert }, `http://127.0.0.1:${proxy.port}`); expect(events).toEqual(["message first", "close 1006"]); - // The connection to the proxy stays open: https://github.com/oven-sh/bun/pull/37487 + await server.closedByOwner; }); test.concurrent("Bun.SQL postgres", async () => { diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index f346ab9520d4..d40b250aa49d 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -732,7 +732,11 @@ describe.each(["TLSv1.3", "TLSv1.2"])("an ALPNCallback over a Duplex can drop it "destroy(err)": ["server error boom", "client error ECONNRESET"], "destroy() the client": [], "destroy() the transport": ["client error ECONNRESET"], - "throw": ["server error boom", "client error ECONNRESET"], + // The refusal's alert goes out, as on a real socket. Node v26.3.0 sends none: its client gets ECONNRESET. + "throw": + door === "staged" + ? ["client error ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL", "server error boom"] + : ["server error boom", "client error ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL"], "select": ["client secureConnect h2"], }); expect(exitCode).toBe(0); From 5888cfc7b2a7d946ac39ccbdc952e30214e5ef3b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 02:20:47 +0000 Subject: [PATCH 097/174] node:tls: run the built-in name check inside the handshake too (#43946) A verdict that JS gives after the handshake is too late under TLS 1.2, where the client's Certificate leaves before the server's Finished, and whenever the final flight could not be held. For the case that matters most, the default name check with rejectUnauthorized on, node:tls stops deferring: the native check that Bun.connect uses refuses inside the handshake. onClientHandshake calls the JS checkServerIdentity(hostname, cert) for the error, so the error object is the one of today. JS still runs the check after every successful handshake, so a difference between the two matchers can only fail closed. Only when the name that JS checks is the SNI name the native layer has. An IP literal host, a checkServerIdentity function and a standalone wrap stay deferred. --- src/js/node/net.ts | 25 ++++++ src/js/node/tls.ts | 1 + src/runtime/socket/mod.rs | 6 +- src/runtime/socket/socket_body.rs | 14 +++ .../net/tls-reject-before-client-cert.test.ts | 87 +++++++++++-------- test/js/node/tls/node-tls-connect.test.ts | 30 +++++-- 6 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 15f53f433ee5..15cb144a5973 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -268,6 +268,11 @@ const addServerName = $newRustFunction("Listener.rs", "jsAddServerName", 3); const upgradeDuplexToTLS = $newRustFunction("runtime/socket/socket.rs", "jsUpgradeDuplexToTLS", 2); // tls.connect({ socket }) upgrade: hostname policy stays with this JS layer. const upgradeTLSDeferred = $newRustFunction("runtime/socket/socket.rs", "jsUpgradeTLSDeferred", 2); +const checkServerIdentityInHandshake = $newRustFunction( + "runtime/socket/socket.rs", + "jsCheckServerIdentityInHandshake", + 2, +); // destroy() in the handshake callback: drops the handshake flight that the native layer holds. const releaseHeldFlight = $newRustFunction("runtime/socket/socket.rs", "jsReleaseHeldFlight", 1); const isNamedPipeSocket = $newRustFunction("runtime/socket/socket.rs", "jsIsNamedPipeSocket", 1); @@ -571,6 +576,20 @@ function writeAfterFIN(chunk, encoding, cb) { return false; } +// The built-in name check also runs natively inside the handshake: a refusal there keeps the client certificate off the wire. +function startNameCheckInHandshake(self, socket) { + const options = self[bunTLSConnectOptions]; + if (!options) return; + checkServerIdentityInHandshake( + socket, + self._rejectUnauthorized === true && + !self[kStandaloneWrap] && + options.checkServerIdentity === options.builtinCheckServerIdentity && + // The native check reads the SNI name. Any other host stays with the check after the handshake. + !!options.serverName && + options.serverName === self.servername, + ); +} // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1647-L1707 function onClientHandshake(self, socket, success, verifyError) { if (!success && verifyError?.code === "ECONNRESET") { @@ -597,6 +616,9 @@ function onClientHandshake(self, socket, success, verifyError) { } } + // The chain verified. The JS check below gives node's error for the name that the native check refused. + const refusedName = verifyError?.code === "ERR_TLS_CERT_ALTNAME_INVALID" ? verifyError : undefined; + if (refusedName) verifyError = null; self._securePending = false; self._secureEstablished = true; self[kVerifyError] = verifyError ?? null; @@ -628,6 +650,7 @@ function onClientHandshake(self, socket, success, verifyError) { verifyError = checkServerIdentity.$call(receiver, hostname, cert); } } + verifyError ||= refusedName; let rejectUnauthorized; if (self._requestCert || (rejectUnauthorized = self._rejectUnauthorized)) { if (verifyError) { @@ -823,6 +846,7 @@ const SocketHandlers = { (self as TLSSocketInstance).setSession(session); } } + startNameCheckInHandshake(self, socket); if (self[kSetNoDelay]) { socket.setNoDelay(true); @@ -1581,6 +1605,7 @@ const SocketHandlers2 = { } } } + startNameCheckInHandshake(self, socket); if (!self[kupgraded]) req.oncomplete(0, self._handle, req, true, true); socket.data.req = undefined; }, diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 2d59c60328c2..c089e99afc2d 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1034,6 +1034,7 @@ TLSSocket.prototype[buntls] = function (port, host) { return { ALPNProtocols: this.ALPNProtocols, checkServerIdentity: this[kcheckServerIdentity], + builtinCheckServerIdentity: checkServerIdentity, session: this[ksession], rejectUnauthorized: this._rejectUnauthorized, requestCert: this._requestCert, diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index e6e40b6bdc41..d24f2e3097a1 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -120,9 +120,9 @@ pub(crate) use udp_socket::UDPSocket; /// the name the generator expects rather than special-casing the generator. pub(crate) mod socket { pub(crate) use super::socket_body::{ - js_create_socket_pair, js_first_flight_before_fin, js_get_buffered_amount, - js_is_named_pipe_socket, js_release_held_flight, js_set_socket_options, - js_upgrade_duplex_to_tls, js_upgrade_tls_deferred, testing_ap_is, + js_check_server_identity_in_handshake, js_create_socket_pair, js_first_flight_before_fin, + js_get_buffered_amount, js_is_named_pipe_socket, js_release_held_flight, + js_set_socket_options, js_upgrade_duplex_to_tls, js_upgrade_tls_deferred, testing_ap_is, }; } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 0a6bbe943cd6..7a1caaf01909 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -4700,6 +4700,20 @@ pub(crate) fn js_release_held_flight( Ok(JSValue::UNDEFINED) } +/// node:tls with the built-in `checkServerIdentity`: `server_identity` refuses a wrong name like Bun.connect's. +#[bun_jsc::host_fn] +pub(crate) fn js_check_server_identity_in_handshake( + _global: &JSGlobalObject, + callframe: &CallFrame, +) -> JsResult { + jsc::mark_binding!(); + let [socket, in_handshake] = callframe.arguments_as_array::<2>(); + if let Some(this) = socket.as_class_ref::() { + this.update_flags(|f| f.set(Flags::DEFERS_SERVER_IDENTITY, in_handshake != JSValue::TRUE)); + } + Ok(JSValue::UNDEFINED) +} + /// `tls.connect()`'s first 'connect' listener, where node sends the ClientHello. #[bun_jsc::host_fn] pub(crate) fn js_first_flight_before_fin( diff --git a/test/js/bun/net/tls-reject-before-client-cert.test.ts b/test/js/bun/net/tls-reject-before-client-cert.test.ts index 2a66ba16dd80..5c67d96f3c56 100644 --- a/test/js/bun/net/tls-reject-before-client-cert.test.ts +++ b/test/js/bun/net/tls-reject-before-client-cert.test.ts @@ -909,49 +909,60 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)( }, ); -// node:tls decides the name in JS, in the handshake callback. Under TLS 1.3 -// the client's final flight, with its certificate, is still held at that +// A checkServerIdentity function decides in JS, in the handshake callback. Under +// TLS 1.3 the client's final flight, with its certificate, is still held at that // point, and the destroy() of the refusal drops it. Under TLS 1.2 the -// certificate leaves before the server's Finished, in node too. +// certificate leaves before the server's Finished, in node too. The built-in +// check also runs inside the handshake, so it covers TLS 1.2. // node-tls-connect.test.ts runs every route in node as well. -describe("TLSv1.3: a node:tls client sends no client certificate to a server whose certificate names another host", () => { - const maxVersion = "TLSv1.3"; - - test("tls.connect", async () => { - await using srv = await mtlsServer({ identity: otherHost, maxVersion }); - const err = await tlsOutcome(tls.connect({ host: "localhost", port: srv.port, ...otherHostMtls })); - expect(err?.code).toBe("ERR_TLS_CERT_ALTNAME_INVALID"); - await srv.seen.closed; - expect(srv.seen.peerCN).toBeNull(); - expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); - }); +describe.each(["TLSv1.3", "TLSv1.2"] as const)( + "%s: a node:tls client sends no client certificate to a server whose certificate names another host", + maxVersion => { + test("tls.connect", async () => { + await using srv = await mtlsServer({ identity: otherHost, maxVersion }); + const err = await tlsOutcome(tls.connect({ host: "localhost", port: srv.port, ...otherHostMtls })); + expect(err?.code).toBe("ERR_TLS_CERT_ALTNAME_INVALID"); + await srv.seen.closed; + expect(srv.seen.peerCN).toBeNull(); + expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); + }); - test("tls.connect with a checkServerIdentity function that returns an Error", async () => { - await using srv = await mtlsServer({ identity: otherHost, maxVersion }); - const checkServerIdentity = () => Object.assign(new Error("not the pinned key"), { code: "ERR_PINNED_KEY" }); - const err = await tlsOutcome( - tls.connect({ host: "localhost", port: srv.port, servername: "agent1", ...otherHostMtls, checkServerIdentity }), + test.skipIf(maxVersion === "TLSv1.2")( + "tls.connect with a checkServerIdentity function that returns an Error", + async () => { + await using srv = await mtlsServer({ identity: otherHost, maxVersion }); + const checkServerIdentity = () => Object.assign(new Error("not the pinned key"), { code: "ERR_PINNED_KEY" }); + const err = await tlsOutcome( + tls.connect({ + host: "localhost", + port: srv.port, + servername: "agent1", + ...otherHostMtls, + checkServerIdentity, + }), + ); + expect(err?.code).toBe("ERR_PINNED_KEY"); + await srv.seen.closed; + expect(srv.seen.peerCN).toBeNull(); + expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); + }, ); - expect(err?.code).toBe("ERR_PINNED_KEY"); - await srv.seen.closed; - expect(srv.seen.peerCN).toBeNull(); - expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); - }); - test("https.request", async () => { - await using srv = await mtlsServer({ identity: otherHost, onSecure: httpOk, maxVersion }); - const err = await new Promise(resolve => - https - .request({ host: "localhost", port: srv.port, agent: false, ...otherHostMtls }, () => resolve(null)) - .on("error", resolve) - .end(), - ); - expect(err?.code).toBe("ERR_TLS_CERT_ALTNAME_INVALID"); - await srv.seen.closed; - expect(srv.seen.peerCN).toBeNull(); - expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); - }); -}); + test("https.request", async () => { + await using srv = await mtlsServer({ identity: otherHost, onSecure: httpOk, maxVersion }); + const err = await new Promise(resolve => + https + .request({ host: "localhost", port: srv.port, agent: false, ...otherHostMtls }, () => resolve(null)) + .on("error", resolve) + .end(), + ); + expect(err?.code).toBe("ERR_TLS_CERT_ALTNAME_INVALID"); + await srv.seen.closed; + expect(srv.seen.peerCN).toBeNull(); + expect(srv.seen.clientTlsBytes).toBe(srv.seen.clientHelloBytes); + }); + }, +); // The inline reject is installed only when the client's policy rejects a bad // chain. A client that accepts one must still complete the handshake, and diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 50ad58236748..66d368aa1673 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -1738,17 +1738,26 @@ describe("application data written over a Duplex transport before the handshake } }); - // The identity verdict always comes after the handshake. A client can act - // on the chain verdict as soon as it has it, and a TLS 1.2 client has it - // before the flight this write waits for. So the chain row is TLS 1.3 only. + // A checkServerIdentity function gives its verdict after the handshake. A client acts + // on the chain and on the built-in name check as soon as it has the certificate, and + // a TLS 1.2 client has it before the flight this write waits for. So those rows are TLS 1.3 only. + const wrongName = { ca: COMMON_CERT_.cert, servername: "wrong.example" }; const verdicts = [ { - verdict: "identity", - options: { ca: COMMON_CERT_.cert, servername: "wrong.example" }, + verdict: "identity (function)", + options: { + ...wrongName, + checkServerIdentity: (...args: Parameters) => checkServerIdentity(...args), + }, code: "ERR_TLS_CERT_ALTNAME_INVALID", }, - // The self-signed fixture fails verification under the default rejectUnauthorized. - ...(version === "TLSv1.3" ? [{ verdict: "chain", options: {}, code: "DEPTH_ZERO_SELF_SIGNED_CERT" }] : []), + ...(version === "TLSv1.3" + ? [ + { verdict: "identity", options: wrongName, code: "ERR_TLS_CERT_ALTNAME_INVALID" }, + // The self-signed fixture fails verification under the default rejectUnauthorized. + { verdict: "chain", options: {}, code: "DEPTH_ZERO_SELF_SIGNED_CERT" }, + ] + : []), ]; it.each(verdicts)("client: a rejected $verdict fails the write and sends nothing", async ({ options, code }) => { const received: Buffer[] = []; @@ -4403,10 +4412,15 @@ describe("how a TLS client's way of closing reaches the server", () => { ], // In a full TLS 1.2 handshake the client's flight leaves before the server's Finished. - ["TLSv1.2", "checkServerIdentity", delivered("", 0, [altnameInvalid, "close:true"])], + ["TLSv1.2", "checkServerIdentity function", delivered("", 0, ["error:ERR_PINNED_KEY", "close:true"])], ["TLSv1.2", "destroy()", delivered("", 0)], ] as const; + // The built-in name check also runs inside the handshake, ahead of that flight. In node the certificate leaves. + it("TLSv1.2 checkServerIdentity", async () => { + expect(await closeReport("checkServerIdentity", "TLSv1.2")).toEqual(turnedDown(altnameInvalid, "close:true")); + }); + it.each(rows)("%s %s", async (version, mode, expected) => { expect(await closeReport(mode, version)).toEqual(expected); }); From 6ba27cd8688277bdf0f01aace163397f361eb98f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 01:39:44 +0000 Subject: [PATCH 098/174] usockets(tls): the write BIO never refuses a record, so write() counts every record it sealed (#44529) While another TLS socket owned the loop's spill slot, the write BIO refused what the kernel did not take. BoringSSL had already sealed that record and kept it for a retry, and us_internal_ssl_writev did not count it. The next write of other data flushed the old record and dropped as many accepted bytes (silent stream corruption); a shorter one, or any write after setMaxSendFragment(), failed with BAD_WRITE_RETRY and the socket went fatal with no event. The BIO now takes every record. Only what the kernel refused is copied, to the connection (us_ssl_rare_t), and freed when it drains. The loop-wide rule stays: while one connection holds the rest of a batch flush nobody batches, so userspace holds one flush unit per loop plus at most the rest of one record per other stalled connection. That record used to sit, whole, in BoringSSL's own per-connection write buffer. 16 stalled writers on one loop: 340378 B before (94288 slot + 15 x 16406), 275968 B + 76 B per connection now. Nothing is allocated or copied while the kernel keeps up: 200 writes of 64 KiB, 203 send() calls and 0 spills, as before. us_ssl_rare_t 56 -> 64 bytes, loop_ssl_data 392 -> 368, us_socket_t 80 -> 80 (one bit of its padding). Gone with the slot: its owner, the relocation hook for it, and the "slot is another socket's, so this connection dies" branch. A close now waits behind the spill of every stalled connection, not only the slot owner's (16 peers that stop reading, destroy() on all: 9 fds stay open, 1 before). The next commit bounds that wait. tls-low-prio-queue-fixture.ts: a refused flight no longer leaves the next byte unread, so the burst that fails the parked handshakes is a fatal alert. --- packages/bun-usockets/src/crypto/openssl.c | 244 ++++++++---------- packages/bun-usockets/src/internal/internal.h | 6 +- packages/bun-usockets/src/libusockets.h | 2 +- packages/bun-uws/src/AsyncSocket.h | 6 +- packages/bun-uws/src/HttpContext.h | 2 +- test/js/bun/net/socket.test.ts | 138 ++++++++++ test/js/bun/net/tls-low-prio-queue-fixture.ts | 33 ++- 7 files changed, 269 insertions(+), 162 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 6a49ba199ae2..9201de93b04f 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -101,15 +101,10 @@ struct loop_ssl_data { * ssl_write_batch_len > 0. */ struct us_socket_t *ssl_write_batch_owner; - /* Single spill slot: ciphertext a partial batch flush could not deliver. - * SSL believes these records were written, so they MUST reach this exact - * socket's fd, in order, before any of its later records. Drained from the - * owner's writable event; while the slot is occupied, other sockets write - * through per record (the pre-batching path). */ - struct us_socket_t *ssl_spill_owner; - char *ssl_spill; - unsigned int ssl_spill_len; - unsigned int ssl_spill_off; + /* Connections whose spill (us_ssl_spill_t) holds the rest of a batch flush. While there is + * one, nobody batches and a spill takes at most the rest of one record: userspace holds + * one flush unit per loop plus one record per other stalled connection. */ + unsigned int ssl_batch_spills; /* Why ssl_raw_write ended a write side inside the running us_internal_ssl_writev or spill drain. */ int ssl_send_error; @@ -199,8 +194,19 @@ struct us_ssl_pending_event_t { unsigned char data[]; }; -/* Allocated for an accepted socket, a node:tls socket's events, a client's first renegotiation, and fetch's session sink. */ +/* Sealed ciphertext the kernel refused. SSL counts it as written, so it reaches this connection's + * fd, in order, before any later record. Drained from the writable event, then freed. */ +struct us_ssl_spill_t { + unsigned int length, offset; + /* Counted in loop_ssl_data::ssl_batch_spills. */ + unsigned char is_batch; + char data[]; +}; + +/* Allocated for an accepted socket, a node:tls socket's events, a client's first renegotiation, fetch's session sink, and a first spill. */ struct us_ssl_rare_t { + /* Non-NULL exactly while us_socket_t::ssl_spilled is set. */ + struct us_ssl_spill_t *spill; /* One reference to the accepting listen socket's names. */ struct us_server_names_t *server_names; /* Sessions and keylog lines parked inside SSL_read/SSL_do_handshake, in arrival order. */ @@ -453,13 +459,14 @@ extern void us_internal_socket_raw_shutdown(struct us_socket_t *s); static void ssl_update_handshake(struct us_socket_t *s, int fin_ends_handshake); static int ssl_flush_write_batch(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s); +static void ssl_emit(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s, const char *data, unsigned int length, int is_batch); static int us_ssl_inline_reject_tripped(struct us_socket_t *s); static inline int ssl_gone(struct us_socket_t *s); /* ── BIO plumbing ───────────────────────────────────────────────────────── * The same shared mem-BIO pair is reused for every SSL* on a loop. The write - * BIO sends ciphertext straight to the wire via raw_write (which never - * re-enters the SSL layer). */ + * BIO never refuses a record: BoringSSL would hold it back, sealed and uncounted, + * for a retry with the caller's next buffer. */ int passphrase_cb(char *buf, int size, int rwflag, void *u) { /* No passphrase configured: behave like Node's PasswordCallback and try an @@ -559,12 +566,9 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { int batch_is_foreign = loop_ssl_data->ssl_write_batch_len && loop_ssl_data->ssl_write_batch_owner != loop_ssl_data->ssl_socket; - if (loop_ssl_data->ssl_write_batching && !batch_is_foreign && loop_ssl_data->ssl_spill_owner == NULL) { + if (loop_ssl_data->ssl_write_batching && !batch_is_foreign) { /* Append the sealed record; the batch hits the kernel once, after - * SSL_write returns. Reporting the full length keeps BoringSSL sealing - * the next record instead of parking a partial one. Skipped while a - * spill occupies the slot: a later short flush could not park its - * remainder without clobbering that socket's pending ciphertext. */ + * SSL_write returns. */ unsigned int needed = loop_ssl_data->ssl_write_batch_len + (unsigned int)length; if (needed > loop_ssl_data->ssl_write_batch_cap) { unsigned int new_cap = loop_ssl_data->ssl_write_batch_cap ? loop_ssl_data->ssl_write_batch_cap : 65536; @@ -589,27 +593,58 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { BIO_clear_retry_flags(bio); return length; } - int written = ssl_raw_write(loop_ssl_data, loop_ssl_data->ssl_socket, data, length); - + /* A flight of this socket that the batch still holds goes first. */ + ssl_flush_write_batch(loop_ssl_data, loop_ssl_data->ssl_socket); + ssl_emit(loop_ssl_data, loop_ssl_data->ssl_socket, data, (unsigned int)length, 0); BIO_clear_retry_flags(bio); - if (!written) { - if (!us_internal_socket_can_raw_write(loop_ssl_data->ssl_socket)) { - /* Sealed after our FIN, so it can never leave. A retry would wait for a - * writable event that never comes. */ - return length; - } - BIO_set_retry_write(bio); - return -1; + return length; +} + +static void ssl_discard_spill(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s) { + if (!s->ssl_spilled) return; + struct us_ssl_rare_t *rare = us_ssl_rare(s_ssl(s)); + loop_ssl_data->ssl_batch_spills -= rare->spill->is_batch; + us_free(rare->spill); + rare->spill = NULL; + s->ssl_spilled = 0; +} + +/* Every sealed record of `s` leaves through here: to the kernel, or behind what the kernel refused. */ +static void ssl_emit(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s, + const char *data, unsigned int length, int is_batch) { + if (!s->ssl_spilled) { + unsigned int written = (unsigned int)ssl_raw_write(loop_ssl_data, s, data, (int)length); + /* Sealed after our FIN, it can never leave. */ + if (written == length || !us_internal_socket_can_raw_write(s)) return; + data += written; + length -= written; } - return written; + struct us_ssl_rare_t *rare = us_ssl_rare_ensure(s_ssl(s)); + unsigned int held = rare->spill ? rare->spill->length : 0; + struct us_ssl_spill_t *spill = us_realloc(rare->spill, sizeof(*spill) + held + length); + if (!spill) { + /* Out of memory with ciphertext in flight: the connection cannot stay + * coherent (SSL already advanced its sequence numbers). Drop it. */ + s->ssl_fatal_error = 1; + return; + } + if (!held) { + spill->offset = 0; + spill->is_batch = 0; + } + memcpy(spill->data + held, data, length); + spill->length = held + length; + if (is_batch && !spill->is_batch) { + spill->is_batch = 1; + loop_ssl_data->ssl_batch_spills++; + } + rare->spill = spill; + s->ssl_spilled = 1; } -/* Flush the ciphertext batch to its socket in one write. A partial write - * spills the remainder into the loop's single spill slot - SSL already - * counts those records as delivered, so they are drained (in order, to this - * socket only) from its writable event. Returns 1 when the wire took - * everything, 0 when it did not: the rest is spilled, or dropped when it can - * never be sent. */ +/* Flush the ciphertext batch to its socket in one write. Returns 1 when the + * wire took everything, 0 when it did not: the rest is spilled, or dropped + * when it can never be sent. */ static int ssl_flush_write_batch(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s) { unsigned int len = loop_ssl_data->ssl_write_batch_len; if (!len) return 1; @@ -621,37 +656,8 @@ static int ssl_flush_write_batch(struct loop_ssl_data *loop_ssl_data, struct us_ } loop_ssl_data->ssl_write_batch_len = 0; loop_ssl_data->ssl_write_batch_owner = NULL; - int written = ssl_raw_write(loop_ssl_data, s, loop_ssl_data->ssl_write_batch, (int)len); - if ((unsigned int)written < len) { - unsigned int remainder = len - (unsigned int)written; - if (!us_internal_socket_can_raw_write(s)) { - /* Sealed after our FIN, so it can never leave. A spill would hold the - * loop's one spill slot, and us_internal_ssl_close would wait for it. */ - return 0; - } - if (loop_ssl_data->ssl_spill_owner) { - /* The spill slot is already another socket's (a re-entrant JS region - * produced one between the entry-time gate and this flush). - * Overwriting it would leak and corrupt that socket's stream; this - * socket's remainder is undeliverable. */ - s->ssl_fatal_error = 1; - return 0; - } - char *spill = us_malloc(remainder); - if (!spill) { - /* Out of memory with ciphertext in flight: the connection cannot stay - * coherent (SSL already advanced its sequence numbers). Drop it. */ - s->ssl_fatal_error = 1; - return 0; - } - memcpy(spill, loop_ssl_data->ssl_write_batch + written, remainder); - loop_ssl_data->ssl_spill = spill; - loop_ssl_data->ssl_spill_len = remainder; - loop_ssl_data->ssl_spill_off = 0; - loop_ssl_data->ssl_spill_owner = s; - return 0; - } - return 1; + ssl_emit(loop_ssl_data, s, loop_ssl_data->ssl_write_batch, len, 1); + return !s->ssl_spilled && us_internal_socket_can_raw_write(s); } /* Drop the batch slot when its owner dies without reaching a flush point @@ -665,35 +671,21 @@ static void ssl_release_batch(struct us_loop_t *loop, struct us_socket_t *s) { } } -static void ssl_discard_spill(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s) { - if (loop_ssl_data->ssl_spill_owner != s) return; - us_free(loop_ssl_data->ssl_spill); - loop_ssl_data->ssl_spill = NULL; - loop_ssl_data->ssl_spill_len = 0; - loop_ssl_data->ssl_spill_off = 0; - loop_ssl_data->ssl_spill_owner = NULL; -} - -/* Try to drain the spill slot for `s`. Returns 1 when clear (not ours, sent, or - * dropped with the write side), 0 while ciphertext is still pending for this socket. */ +/* Try to send the spill of `s`. Returns 1 when none is left (sent, or dropped + * with the write side), 0 while ciphertext is still pending for this socket. */ static int ssl_drain_spill(struct loop_ssl_data *loop_ssl_data, struct us_socket_t *s) { - if (loop_ssl_data->ssl_spill_owner != s) return 1; - unsigned int pending = loop_ssl_data->ssl_spill_len - loop_ssl_data->ssl_spill_off; - loop_ssl_data->ssl_spill_off += (unsigned int)ssl_raw_write( - loop_ssl_data, s, loop_ssl_data->ssl_spill + loop_ssl_data->ssl_spill_off, (int)pending); - if (loop_ssl_data->ssl_spill_off == loop_ssl_data->ssl_spill_len || !us_internal_socket_can_raw_write(s)) { - ssl_discard_spill(loop_ssl_data, s); - return 1; - } - return 0; + if (!s->ssl_spilled) return 1; + struct us_ssl_spill_t *spill = us_ssl_rare(s_ssl(s))->spill; + spill->offset += (unsigned int)ssl_raw_write(loop_ssl_data, s, spill->data + spill->offset, + (int)(spill->length - spill->offset)); + if (spill->offset != spill->length && us_internal_socket_can_raw_write(s)) return 0; + ssl_discard_spill(loop_ssl_data, s); + return 1; } -/* Release the spill slot when its owner dies (close path). */ +/* The connection ends with ciphertext still spilled: give the kernel one last chance to take it. */ static void ssl_release_spill(struct us_loop_t *loop, struct us_socket_t *s) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)loop->data.ssl_data; - if (!loop_ssl_data) return; - /* Hard close with ciphertext still spilled: give the kernel one last - * chance to take it (it usually can - the spill is bounded small). */ ssl_drain_spill(loop_ssl_data, s); ssl_discard_spill(loop_ssl_data, s); } @@ -702,9 +694,6 @@ void us_internal_ssl_socket_relocated(struct us_loop_t *loop, struct us_socket_t struct us_socket_t *new_s) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)loop->data.ssl_data; if (!loop_ssl_data) return; - if (loop_ssl_data->ssl_spill_owner == old_s) { - loop_ssl_data->ssl_spill_owner = new_s; - } if (loop_ssl_data->ssl_write_batch_owner == old_s) { loop_ssl_data->ssl_write_batch_owner = new_s; } @@ -787,7 +776,6 @@ void us_internal_free_loop_ssl_data(struct us_loop_t *loop) { if (loop_ssl_data) { us_free(loop_ssl_data->ssl_read_output); us_free(loop_ssl_data->ssl_write_batch); - us_free(loop_ssl_data->ssl_spill); BIO_free(loop_ssl_data->shared_rbio); BIO_free(loop_ssl_data->shared_wbio); BIO_meth_free(loop_ssl_data->shared_biom); @@ -1775,6 +1763,7 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, s->ssl_read_wants_write = 0; s->ssl_fatal_error = 0; s->ssl_raw_tap = 0; + s->ssl_spilled = 0; s->ssl_shutdown_after_spill = 0; s->ssl_close_after_spill = 0; s->ssl_end_delivered = 0; @@ -1794,13 +1783,11 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, } void us_internal_ssl_detach(struct us_socket_t *s) { - /* Error/RST teardowns (us_internal_socket_close_raw) reach here without going - * through us_internal_ssl_close: release any spilled ciphertext this socket - * owns or the loop-wide slot dangles (batching permanently disabled, and a - * reused socket address would drain the dead socket's records). */ - ssl_release_spill(s->group->loop, s); ssl_release_batch(s->group->loop, s); if (s->ssl) { + /* Error/RST teardowns (us_internal_socket_close_raw) reach here without going + * through us_internal_ssl_close: a batch spill left counted disables batching for good. */ + ssl_release_spill(s->group->loop, s); if (s->ssl_in_use) { /* SSL_do_handshake/SSL_read is on the stack (a JS callback run from * inside it destroyed the socket); freeing now would leave BoringSSL @@ -2086,30 +2073,14 @@ static int ssl_handle_shutdown(struct us_socket_t *s) { s->ssl_fatal_error = 1; return 1; } - if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) { - /* The close_notify could not be flushed (BIO write failed: kernel - * buffer full or peer already gone). There is no retry path — - * SSL_SENT_SHUTDOWN is already set, so on_writable/on_data - * short-circuit through is_shut_down and never re-dispatch the - * alert. Returning 0 here would keep s->ssl (and BoringSSL's - * write_buffer holding the encoded alert) alive until the next - * socket event, which may never arrive — observed as an LSan - * leak in node-https-checkServerIdentity.test.ts when the child - * exits right after server.close(). The deferred-close contract - * documented in us_internal_ssl_close only applies to the - * SSL_shutdown()==0 case where the alert *was* flushed; here it - * never went out, so close now. Deliberate divergence from node, - * which parks the alert in its stream write queue (crypto_tls.cc - * DoShutdown): replicating that needs a retry hook surviving the - * is_shut_down short-circuits, and until one exists dropping the - * alert under backpressure is the accepted tradeoff — the peer - * still observes the TCP FIN. */ - return 1; - } + if (err == SSL_ERROR_WANT_READ) return 1; s->ssl_fatal_error = 1; return 1; } - return ret == 1; + /* The kernel refused the close_notify, and SSL_SENT_SHUTDOWN (is_shut_down) ends the writable + * poll that would drain it: close now without it, the peer still observes the FIN. Node parks + * the alert in its stream write queue (crypto_tls.cc DoShutdown). */ + return ret == 1 || s->ssl_spilled; } return 1; } @@ -2361,8 +2332,7 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s); * so the retry happens after on_writable drains it. */ static struct us_socket_t *ssl_retry_parked_write(struct us_socket_t *s) { if (!s->ssl_write_wants_read || s->ssl_read_wants_write) return s; - struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; - if (loop_ssl_data && loop_ssl_data->ssl_spill_owner == s) return s; + if (s->ssl_spilled) return s; s->ssl_write_wants_read = 0; return ssl_on_writable(s); } @@ -2574,12 +2544,11 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i * down right after the handshake (node's post-verify destroy, #40653) * close with the second segment unread, which turns its FIN teardown * into an RST and a bogus ECONNRESET at this side. Node's memory BIO - * drained once per cycle has the same single-segment shape. Gated on a - * free spill slot like us_internal_ssl_write, so a short flush cannot - * clobber another socket's pending ciphertext. */ + * drained once per cycle has the same single-segment shape. Same gate + * as us_internal_ssl_writev. */ int hs_batching = s->ssl_handshake_state == HANDSHAKE_PENDING && !loop_ssl_data->ssl_write_batching && - !loop_ssl_data->ssl_spill_owner; + !loop_ssl_data->ssl_batch_spills; if (hs_batching) loop_ssl_data->ssl_write_batching = 1; unsigned char ssl_was_in_use = s->ssl_in_use; s->ssl_in_use = 1; @@ -2805,9 +2774,9 @@ void *us_internal_ssl_get_native_handle(struct us_socket_t *s) { * close-after-drain gates do not fire while the last batch is still in * userspace. */ unsigned int us_internal_ssl_spill_pending(struct us_socket_t *s) { - struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; - if (!loop_ssl_data || loop_ssl_data->ssl_spill_owner != s) return 0; - return loop_ssl_data->ssl_spill_len - loop_ssl_data->ssl_spill_off; + if (!s->ssl_spilled) return 0; + struct us_ssl_spill_t *spill = us_ssl_rare(s_ssl(s))->spill; + return spill->length - spill->offset; } int us_internal_ssl_write(struct us_socket_t *s, const char *data, int length) { @@ -2852,8 +2821,8 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, loop_ssl_data->ssl_read_input_length = 0; loop_ssl_data->ssl_socket = s; - /* Batch this write's records unless another socket's spill occupies the - * slot (then write through per record, the pre-batching behavior). + /* Batch this write's records unless a connection on the loop still holds + * the rest of a batch flush (then send record by record). * * Plaintext is consumed in record-size slices and the batch flushes every * few records: the moment the wire blocks we STOP consuming, so the bytes @@ -2861,7 +2830,7 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, * whole large write as consumed while its ciphertext sat in memory let the * layers above fire 'finish' and close before the data reached the wire. */ int outer_batching = loop_ssl_data->ssl_write_batching; - int batching = (loop_ssl_data->ssl_spill_owner == NULL); + int batching = (loop_ssl_data->ssl_batch_spills == 0); loop_ssl_data->ssl_write_batching = batching; int total = 0; @@ -2903,8 +2872,9 @@ int us_internal_ssl_writev(struct us_socket_t *s, const struct us_iovec_t *iov, /* A batching allocation failure marks the socket fatal from inside the BIO, a * rejected send() ends the write side: stop sealing records that cannot leave. */ if (s->ssl_fatal_error || !us_internal_socket_can_raw_write(s)) break; + if (s->ssl_spilled) break; /* wire blocked: stop consuming */ if (batching && loop_ssl_data->ssl_write_batch_len >= 131072) { - if (!ssl_flush_write_batch(loop_ssl_data, s)) break; /* wire blocked: stop consuming */ + if (!ssl_flush_write_batch(loop_ssl_data, s)) break; if (s->ssl_fatal_error) break; } } @@ -2966,11 +2936,9 @@ void us_internal_ssl_shutdown(struct us_socket_t *s) { loop_ssl_data->ssl_write_batch_owner == s) { ssl_flush_write_batch(loop_ssl_data, s); } - if (loop_ssl_data && loop_ssl_data->ssl_spill_owner == s) { - if (!ssl_drain_spill(loop_ssl_data, s)) { - s->ssl_shutdown_after_spill = 1; - return; - } + if (!ssl_drain_spill(loop_ssl_data, s)) { + s->ssl_shutdown_after_spill = 1; + return; } } @@ -2985,6 +2953,7 @@ void us_internal_ssl_shutdown(struct us_socket_t *s) { * prepends any pending TLS 1.3 NewSessionTicket flight to the alert, so * tickets are still delivered) and owns the error handling. */ ssl_handle_shutdown(s); + ssl_release_spill(s->group->loop, s); us_internal_socket_raw_shutdown(s); return; } @@ -3009,6 +2978,7 @@ void us_internal_ssl_shutdown(struct us_socket_t *s) { /* RECEIVED_SHUTDOWN brought us here, so the TLS shutdown is complete; FIN the * TCP write side so the poll type becomes SHUT_DOWN and the loop's * is_shut_down eof branch can close once both halves are done. */ + ssl_release_spill(s->group->loop, s); us_internal_socket_raw_shutdown(s); } diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 48602ca85190..ff0221bdfb28 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -330,8 +330,10 @@ struct us_socket_t { * Used by Bun's `socket.upgradeTLS()` so the returned [raw, tls] pair's * `raw` half can observe ciphertext (node:net Duplex.ondata semantics). */ unsigned char ssl_raw_tap : 1; - /* A graceful TLS shutdown arrived while batched ciphertext was still - * spilled (see ssl_flush_write_batch); the shutdown re-runs once the + /* Sealed ciphertext of this socket waits for the kernel (openssl.c us_ssl_spill_t). */ + unsigned char ssl_spilled : 1; + /* A graceful TLS shutdown arrived while ciphertext was still + * spilled (see ssl_emit); the shutdown re-runs once the * spill drains so those records are not cut off by our FIN/close_notify. */ unsigned char ssl_shutdown_after_spill : 1; /* Same as ssl_shutdown_after_spill but for us_internal_ssl_close: the diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index f499d1b89e1f..4f7bd60e1852 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -707,7 +707,7 @@ int us_socket_is_ssl_handshake_finished(us_socket_r s) nonnull_fn_decl; int us_socket_ssl_handshake_callback_has_fired(us_socket_r s) nonnull_fn_decl; /* TLS ciphertext bytes already sealed for this socket and reported as * written by us_socket_write(), still waiting on a writable event to reach - * the kernel (the loop-wide spill slot owned by this socket). 0 for + * the kernel (the connection's spill). 0 for * plain-TCP sockets and for TLS sockets with nothing spilled. */ unsigned int us_socket_ssl_spill_pending(us_socket_r s) nonnull_fn_decl; diff --git a/packages/bun-uws/src/AsyncSocket.h b/packages/bun-uws/src/AsyncSocket.h index cf33e6059bf4..1a799d605c1a 100644 --- a/packages/bun-uws/src/AsyncSocket.h +++ b/packages/bun-uws/src/AsyncSocket.h @@ -199,9 +199,9 @@ struct AsyncSocket { } /* Whether every byte handed to us_socket_write() has reached the kernel. - * For TLS, us_socket_write() can report a batch as written while its - * ciphertext still sits in the loop's spill slot (openssl.c - * ssl_flush_write_batch); the close-after-drain gates in HttpResponse / + * For TLS, us_socket_write() can report records as written while their + * ciphertext still sits in the connection's spill (openssl.c + * ssl_emit); the close-after-drain gates in HttpResponse / * HttpContext must wait for that too. Kept separate from * getBufferedAmount() so WebSocket's maxBackpressure policy and the * JS-exposed bufferedAmount stay a plaintext count. */ diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 177b9ea558fb..15e16227b607 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -485,7 +485,7 @@ struct HttpContext { /* node:http also queues behind responses that were dispatched but * are not the connection's current response yet, and behind a * response that has ended but not finished: its bytes are still - * in the outgoing buffer (or the TLS spill slot), and it owns the + * in the outgoing buffer (or the TLS spill), and it owns the * connection (Node's socket._httpMessage) until they have been * written out, with later responses queued behind it (Node's * state.outgoing). A write or uncork can empty the buffer before diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index db1372dc76ad..557e34e8ee00 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1539,6 +1539,144 @@ it("TLS mid-read boundary dispatch: writing to another TLS socket from data() do } }, 60_000); +// write() reports how many bytes it took, and the caller may send anything next: the peer must get +// exactly the reported bytes, then the next write. While one TLS socket holds the unsent rest of a +// batch, the other TLS sockets of the loop send record by record, which is the path under test. +describe("TLS write() that ends short while another TLS socket is stalled", () => { + const STEP = 1024 * 1024; + const FIRST = 0x61; + const OTHER = 0x62; + type Next = "end" | "shutdown" | { bytes: Buffer; fragment?: "while unsent" | "once sent" }; + type Pair = { stalled: boolean }; + + // Writes `step` until a write is short. Returns the sum write() reported. + function fill(socket: Socket, step: Buffer) { + let total = 0; + for (let wrote = step.length; wrote === step.length && total < 64 * STEP; ) { + wrote = socket.write(step); + total += Math.max(wrote, 0); + } + return total; + } + + async function shortWriteThen(next: Next, { accepted = false, maxVersion = 0 } = {}) { + const step = Buffer.alloc(STEP, FIRST); + const stalledStep = Buffer.alloc(STEP, 0x7a); + const stalledFilled = Promise.withResolvers(); + const written = Promise.withResolvers>(); + const reportedArrived = Promise.withResolvers(); + const peerClosed = Promise.withResolvers(); + const received = { length: 0, otherStartsAt: -1, firstEndsAt: -1 }; + const sockets: Socket[] = []; + let reported = -1; + let sent = -1; + + const sendRest = (socket: Socket, bytes: Buffer) => { + while (sent < bytes.length) { + const wrote = socket.write(bytes.subarray(sent)); + if (wrote <= 0) return; + sent += wrote; + } + socket.end(); + }; + const writer = { + handshake(socket: Socket) { + if (socket.data.stalled) { + fill(socket, stalledStep); + return stalledFilled.resolve(); + } + reported = fill(socket, step); + if (next === "end") socket.end(); + else if (next === "shutdown") socket.shutdown(); + else if (next.fragment === "while unsent") socket.setMaxSendFragment(512); + written.resolve(socket); + }, + drain(socket: Socket) { + if (socket.data.stalled) fill(socket, stalledStep); + else if (typeof next === "object" && sent !== -1) sendRest(socket, next.bytes); + }, + }; + const reader = { + data(socket: Socket, chunk: Buffer) { + if (socket.data.stalled) return socket.pause(); + const otherStart = chunk.indexOf(OTHER); + if (otherStart !== -1 && received.otherStartsAt === -1) received.otherStartsAt = received.length + otherStart; + const firstEnd = chunk.lastIndexOf(FIRST); + if (firstEnd !== -1) received.firstEndsAt = received.length + firstEnd; + received.length += chunk.length; + if (received.length >= reported) reportedArrived.resolve(); + }, + close(socket: Socket) { + if (!socket.data.stalled) peerClosed.resolve(); + }, + }; + const side = (handlers: typeof writer | typeof reader) => ({ + data() {}, + error() {}, + ...handlers, + open(socket: Socket) { + // The first connection is the stalled pair. + socket.data ??= { stalled: sockets.length < 2 }; + sockets.push(socket); + }, + }); + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { ...tls, maxVersion }, + socket: side(accepted ? writer : reader), + }); + const connect = () => + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + socket: side(accepted ? reader : writer), + }); + try { + await connect(); + await stalledFilled.promise; + await connect(); + const socket = await written.promise; + if (typeof next === "object") { + // Every reported byte has arrived, so the socket is idle again and takes a write. + await reportedArrived.promise; + if (next.fragment === "once sent") socket.setMaxSendFragment(512); + sent = socket.write(next.bytes); + expect(sent).toBeGreaterThan(0); + sendRest(socket, next.bytes); + } + await peerClosed.promise; + return { reported, received }; + } finally { + for (const socket of sockets) socket.terminate(); + } + } + + it.each([ + ["other data arrives byte for byte", 256 * 1024, {}], + ["other data arrives byte for byte (accepted socket, TLS 1.2)", 256 * 1024, { accepted: true, maxVersion: 0x0303 }], + ["less than one TLS record is sent", 100, {}], + ])("a later write of %s", async (_, length, options) => { + const { reported, received } = await shortWriteThen({ bytes: Buffer.alloc(length, OTHER) }, options); + expect(received).toEqual({ length: reported + length, otherStartsAt: reported, firstEndsAt: reported - 1 }); + }); + + it.each(["while unsent", "once sent"] as const)( + "the stream continues after setMaxSendFragment() %s", + async fragment => { + const length = 64 * 1024; + const { reported, received } = await shortWriteThen({ bytes: Buffer.alloc(length, FIRST), fragment }); + expect(received).toEqual({ length: reported + length, otherStartsAt: -1, firstEndsAt: reported + length - 1 }); + }, + ); + + it.each(["end", "shutdown"] as const)("%s() in the same tick sends every reported byte first", async next => { + const { reported, received } = await shortWriteThen(next); + expect(received).toEqual({ length: reported, otherStartsAt: -1, firstEndsAt: reported - 1 }); + }); +}); + describe.concurrent("TLS server: write() to the accepted socket from inside its own selection callback", () => { // alpnCallback / serverName are the listener hooks node:tls's ALPNCallback / // SNICallback go through. Both run from inside the read that is processing diff --git a/test/js/bun/net/tls-low-prio-queue-fixture.ts b/test/js/bun/net/tls-low-prio-queue-fixture.ts index b158efc5e049..4d012dc64219 100644 --- a/test/js/bun/net/tls-low-prio-queue-fixture.ts +++ b/test/js/bun/net/tls-low-prio-queue-fixture.ts @@ -8,7 +8,7 @@ // parked sockets. // // A parked socket can still get a WRITABLE dispatch. If its handshake flight -// is backpressured, us_internal_ssl_on_writable retries the BIO write, and +// is backpressured, us_internal_ssl_on_writable retries the spilled flight, and // us_socket_raw_write re-issues us_poll_change(READABLE|WRITABLE); readable // is now back on a socket that is still in the low-prio queue. The next // readable dispatch with an exhausted budget used to park it a SECOND time: @@ -32,16 +32,16 @@ // has fired. // 2. The server resumes all N at once: N readables land in one loop // iteration, the budget processes 5 ClientHellos (flight -> send() -> 0 -// -> WANT_WRITE) and parks the rest at the ClientHello stage. Each later +// -> spilled) and parks the rest at the ClientHello stage. Each later // iteration re-enables 5 more, so after ceil(N / 5) + 1 iterations every // socket has a backpressured flight. The server counts iterations // (getEventLoopStats().iteration is us_internal_loop_pre's counter) and // then pauses all N again. -// 3. The child writes one byte to each socket and reports `bursted`. The -// server resumes all N at once: N readables in one iteration again. The -// budget processes 5 (the byte is unread ciphertext after WANT_WRITE, so -// on_data closes them in that same iteration) and parks N - 5 with their -// flight still pending. Every iteration after that re-enables 5 parked +// 3. The child writes a fatal alert record to each socket and reports +// `bursted`. The server resumes all N at once: N readables in one +// iteration again. The budget processes 5 (the alert fails the +// handshake, so on_data closes them in that same iteration) and parks +// N - 5 with their flight still pending. Every iteration after that re-enables 5 parked // sockets (each then closes the same way), while the still-parked ones // get their WRITABLE retry, which re-enables READABLE, and then a // READABLE dispatch with the budget exhausted: the guarded path. @@ -52,10 +52,9 @@ // wave and the test asserts the exact split. // // One extra "primer" connection per round takes the first handshake flight: -// that flight is batched and its ciphertext occupies the loop's single spill -// slot (ssl_spill_owner), and a spill owner never hits the unread-ciphertext -// close in step 3. With the slot taken, all N wave sockets take the same -// per-record WANT_WRITE path and the per-wave numbers are exact. +// that flight is batched, and while its unsent rest waits in the primer's +// spill no other socket on the loop batches. So all N wave sockets take the +// same per-record path and the per-wave numbers are exact. import { socketFaultInjection as fault, getEventLoopStats } from "bun:internal-for-testing"; import net from "node:net"; import tls from "node:tls"; @@ -171,11 +170,9 @@ const clientHello = await captureClientHello(); // stdout once the command's effect has reached the kernel (write callbacks) // or the sockets are gone (close events). // connect -> connects n sockets and writes ONLY the ClientHello -// to each (trailing bytes in the same segment would -// trip the unread-ciphertext close in on_data before -// the socket can be parked); answers `hellos ` -// burst -> writes one byte to each socket of the last batch; -// answers `bursted ` +// to each; answers `hellos ` +// burst -> writes a fatal handshake_failure alert record to +// each socket of the last batch; answers `bursted ` // reset -> destroys every socket; answers `reset` once all of // them have closed // exit -> exits 0 @@ -209,7 +206,7 @@ readline.createInterface({ input: process.stdin }).on("line", line => { } } else if (cmd === "burst") { const live = batch.filter(c => !c.destroyed); - writeAll(live, Buffer.from([0]), () => say("bursted " + live.length)); + writeAll(live, Buffer.from([21, 3, 3, 0, 2, 2, 40]), () => say("bursted " + live.length)); } else if (cmd === "reset") { const open = all.filter(c => !c.closed); all = []; @@ -338,7 +335,7 @@ async function round() { }); // Every send from THIS process returns 0 (backpressure): the server flights - // stay WANT_WRITE forever and every writable retry re-issues + // stay spilled forever and every writable retry re-issues // us_poll_change(READABLE|WRITABLE), including on already-parked sockets. // The faults are process-wide, so clear them even if the child fails. // fault.set returns false if the rule could not be armed; without the From adeb160b0c034a82e489eedcdeb08ee74c14c5f5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 02:18:12 +0000 Subject: [PATCH 099/174] usockets(tls): a close that waits for unsent ciphertext ends at a fixed deadline close(0) and node's destroy() must not cut off ciphertext that write() already reported, so they wait until the kernel has taken the spill. That wait had no end, and its owner has let go, so nobody else could end it: a peer that stops reading kept the fd past the application's own destroy() or timeout. On main that was the one owner of the loop's spill slot (which also kept the whole loop from batching for good); with a spill per connection it is every stalled connection. 16 TLS clients that stop reading, 12 MiB written to each, destroy() on all ('close' fired on all 16 at once), fds still open (one run per number): after 1.5 s: main 1, with the spill per connection 9 and 5, with this commit 6 and 5 after 13 s: main 1, with the spill per connection 3, with this commit 0 The deferral arms the socket's own timeout once, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT = 10 s (8 to 12 s on the 4 s sweep), the default idleTimeout of Bun.serve. It is not re-armed on progress: a peer that reads one byte every 9 s would hold a socket the application believes is gone. The spill is at most one flush unit (~144 KiB), so a peer that takes 15 KB/s is never cut. When the deadline fires the spill is dropped and the fd closes with the code the close asked for. The same deadline covers the rest of that close: once the spill has drained, a graceful close sends close_notify and waits for the peer's. That state (close_notify sent, no FIN) is only ever a close in progress, so a timeout that fires in it ends the close instead of reaching an owner that already let go. The owner's timeout and long timeout are overwritten and cleared at the deferral, and no on_timeout is dispatched while the close waits. --- packages/bun-usockets/src/crypto/openssl.c | 18 +++ packages/bun-usockets/src/internal/internal.h | 4 +- packages/bun-usockets/src/loop.c | 4 +- test/js/bun/net/socket.test.ts | 110 +++++++++++++++++- test/js/node/tls/tls-syscall-fault.test.ts | 14 +++ 5 files changed, 146 insertions(+), 4 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 9201de93b04f..97c671479806 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2085,6 +2085,9 @@ static int ssl_handle_shutdown(struct us_socket_t *s) { return 1; } +/* Seconds a close waits for the peer to take the spill (at most one flush unit, so 13 KB/s): Bun.serve's default idleTimeout. */ +#define US_SSL_CLOSE_AFTER_SPILL_TIMEOUT 10 + struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void *reason) { if (s->ssl && s->ssl_in_use) { /* A JS callback running from inside SSL_do_handshake/SSL_read (ALPN, SNI, @@ -2124,6 +2127,9 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void /* Resume with the SAME code: a graceful close must not come back as a * forceful FAST_SHUTDOWN (on_close would see an abortive teardown). */ s->ssl_pending_close_code = (unsigned char) code; + /* The owner let go, so its timeouts are over and nobody else bounds this wait. */ + us_socket_timeout(s, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT); + us_socket_long_timeout(s, 0); return s; } } @@ -2390,6 +2396,18 @@ static struct us_socket_t *ssl_close_if_fatal(struct us_socket_t *s) { return ssl_close(s, 0, NULL); } +struct us_socket_t *us_internal_ssl_on_timeout(struct us_socket_t *s) { + if (s->ssl_close_after_spill) { + ssl_release_spill(s->group->loop, s); + return us_internal_socket_close_raw(s, s->ssl_pending_close_code, NULL); + } + /* close_notify without the FIN: the spill drained, and the graceful close now waits for the peer's reply. */ + if ((SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) && us_internal_socket_can_raw_write(s)) { + return us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + } + return us_dispatch_timeout(s); +} + /* The read side delivers what the peer sent before it closes, unless the owner already let go. */ static struct us_socket_t *ssl_close_after_rejected_send(struct us_socket_t *s, int send_error) { if (!s->ssl_close_after_spill && us_socket_queued_input(s) == LIBUS_QUEUED_INPUT_DATA) return s; diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index ff0221bdfb28..3b3e8e969a0b 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -257,6 +257,7 @@ void us_internal_ssl_socket_relocated(us_loop_r loop, us_socket_r old_s, us_sock struct us_socket_t *us_internal_ssl_on_open(us_socket_r s, int is_client, char *ip, int ip_length); struct us_socket_t *us_internal_ssl_on_data(us_socket_r s, char *data, int length); struct us_socket_t *us_internal_ssl_on_writable(us_socket_r s); +struct us_socket_t *us_internal_ssl_on_timeout(us_socket_r s); struct us_socket_t *us_internal_ssl_on_close(us_socket_r s, int code, void *reason); struct us_socket_t *us_internal_ssl_on_end(us_socket_r s); int us_internal_ssl_is_low_prio(us_socket_r s); @@ -337,7 +338,8 @@ struct us_socket_t { * spill drains so those records are not cut off by our FIN/close_notify. */ unsigned char ssl_shutdown_after_spill : 1; /* Same as ssl_shutdown_after_spill but for us_internal_ssl_close: the - * close re-runs from the writable event once the spill drains. */ + * close re-runs from the writable event once the spill drains, or without it + * from the socket's timeout. */ unsigned char ssl_close_after_spill : 1; /* The plaintext EOF (peer close_notify or the raw TCP FIN behind it) was * already dispatched to the user layer; both EOF paths can fire for one diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 135ec4c879f4..e2f68299d35c 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -272,7 +272,7 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { if (short_ticks == s->timeout) { s->timeout = 255; - us_dispatch_timeout(s); + s->ssl ? us_internal_ssl_on_timeout(s) : us_dispatch_timeout(s); } /* An owner must not deinit the embedding group from a timeout handler * (see us_socket_group_deinit). Survive one that closed every socket @@ -317,7 +317,7 @@ void us_internal_timer_sweep(struct us_loop_t *loop) { unsigned char long_stamp = s->group->long_timestamp; if (stamp == s->timeout) { s->timeout = 255; - us_dispatch_timeout(s); + s->ssl ? us_internal_ssl_on_timeout(s) : us_dispatch_timeout(s); if (loop_data->low_prio_iterator != s) continue; } if (long_stamp == s->long_timeout) { diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 557e34e8ee00..4de6966ab638 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -2,7 +2,7 @@ import type { Socket } from "bun"; import { connect, fileURLToPath, SocketHandler, spawn } from "bun"; import { createSocketPair, socketFaultInjection } from "bun:internal-for-testing"; import { afterAll, afterEach, describe, expect, it, jest } from "bun:test"; -import { closeSync, readFileSync } from "fs"; +import { closeSync, fstatSync, readFileSync } from "fs"; import { bunEnv, bunExe, @@ -1677,6 +1677,114 @@ describe("TLS write() that ends short while another TLS socket is stalled", () = }); }); +// A close must not cut off ciphertext that write() already reported, so it waits for the kernel to take +// it. The owner has let go by then: only the deadline (10 s on a 4 s tick) ends the wait for such a peer. +it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", async () => { + const step = Buffer.alloc(1024 * 1024, "a"); + const probeStep = Buffer.alloc(64 * 1024, "p"); + // Tells whether the loop batches: the kernel refuses one send(), of a whole batch or of one record. + const probeBatching = (socket: Socket) => { + if (!socketFaultInjection.available()) return undefined; + socketFaultInjection.set({ syscall: "send", action: "zero" }); + const wrote = socket.write(probeStep); + socketFaultInjection.clear(); + return wrote; + }; + const fdIsOpen = (fd: number) => { + try { + fstatSync(fd); + return true; + } catch { + return false; + } + }; + + // In the order they connect. Half-open: a peer that reads the close_notify does not answer it. + const [PROBE, SILENT, DRIPPING, UNANSWERING, DESTROYED] = [0, 1, 2, 3, 4]; + const peers: Socket<{ received: number }>[] = []; + const lastPeerPaused = Promise.withResolvers(); + const keepsReading = (peer: Socket<{ received: number }>) => peer === peers[PROBE] || peer === peers[UNANSWERING]; + using server = Bun.listen<{ received: number }>({ + hostname: "127.0.0.1", + port: 0, + tls, + allowHalfOpen: true, + socket: { + open(peer) { + peer.data = { received: 0 }; + peers.push(peer); + if (!keepsReading(peer)) peer.pause(); + if (peer === peers[DESTROYED]) lastPeerPaused.resolve(); + }, + data(peer, chunk) { + peer.data.received += chunk.length; + if (!keepsReading(peer)) peer.pause(); + }, + }, + }); + const connected = (handshake: (socket: Socket) => void, close = () => {}) => + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + socket: { handshake, close, data() {}, timeout: () => void timeouts++ }, + }); + let timeouts = 0; + const reported: number[] = [0]; + // A write that ends short leaves ciphertext unsent, and end() in the same tick finds it there. + const fillAndEnd = (socket: Socket) => { + let total = 0; + for (let wrote = step.length; wrote === step.length; total += Math.max(wrote, 0)) wrote = socket.write(step); + reported.push(total); + // The owner's own timeout is over with its close. + socket.timeout(1); + socket.end(); + }; + + const probeReady = Promise.withResolvers(); + await connected(probeReady.resolve); + const probe = await probeReady.promise; + const closed: Promise[] = []; + for (const _peer of [SILENT, DRIPPING, UNANSWERING]) { + const { promise, resolve } = Promise.withResolvers(); + closed.push(promise); + await connected(fillAndEnd, resolve); + } + const destroyed = tlsConnect({ port: server.port, host: "127.0.0.1", ca: tls.cert }); + await Promise.all([once(destroyed, "secureConnect"), lastPeerPaused.promise]); + const destroyedFd: number = (destroyed as any)._handle.fd; + destroyed.write(Buffer.alloc(12 * step.length, "a")); + destroyed.destroy(); + const openAfterDestroy = fdIsOpen(destroyedFd); + await once(destroyed, "close"); + + const drip = setInterval(() => peers[DRIPPING].resume(), 2000); + try { + const before = { fdIsOpen: openAfterDestroy, wrote: probeBatching(probe) }; + await Promise.all(closed); + const after = { fdIsOpen: fdIsOpen(destroyedFd), wrote: probeBatching(probe) }; + const batches = socketFaultInjection.available(); + const missing = (peer: number) => reported[peer] - peers[peer].data.received; + expect({ + before, + after, + timeouts, + cutShort: [missing(SILENT) > 0, missing(DRIPPING) > 0], + missing: missing(UNANSWERING), + }).toEqual({ + before: { fdIsOpen: true, wrote: batches ? 16 * 1024 : undefined }, + after: { fdIsOpen: false, wrote: batches ? 64 * 1024 : undefined }, + timeouts: 0, + cutShort: [true, true], + missing: 0, + }); + } finally { + clearInterval(drip); + probe.terminate(); + for (const peer of peers) peer.terminate(); + } +}, 30_000); + describe.concurrent("TLS server: write() to the accepted socket from inside its own selection callback", () => { // alpnCallback / serverName are the listener hooks node:tls's ALPNCallback / // SNICallback go through. Both run from inside the read that is processing diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 4c8dcda17c7c..6077802372db 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -555,6 +555,20 @@ describe.skipIf(skip)("node:tls close_notify / shutdown under faults", () => { }); }); + test("destroy() from the write callback does not cut off ciphertext the kernel has yet to take", async () => { + let received!: () => Buffer; + let serverSide!: ReturnType; + using p = await connectedTLSPair(s => { + received = collect(s); + serverSide = observe(s); + }); + fault.set({ syscall: "send", action: "short", bytes: 16384, repeat: -1, fd: fdOf(p.client) }); + const payload = Buffer.alloc(256 * 1024, "d"); + p.client.write(payload, () => p.client.destroy()); + await serverSide.closed; + expect(received().length).toBe(payload.length); + }); + test("client.end() under 1-byte sends still delivers close_notify and peer sees clean 'end'", async () => { let serverSide!: ReturnType; using p = await connectedTLSPair(s => (serverSide = observe(s))); From 8fc60abdcb19cbd28c58583cd3cada62189c11f2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 02:26:27 +0000 Subject: [PATCH 100/174] usockets(tls): drop the WANT_WRITE arms the write BIO can no longer produce The BIO takes every record, so SSL_read, SSL_do_handshake and SSL_shutdown never return SSL_ERROR_WANT_WRITE. Gone: the arms for it in the handshake and read drivers, ssl_read_wants_write (which gives back the bit ssl_spilled took) and the empty re-entry of the data path from the writable event. --- packages/bun-usockets/src/crypto/openssl.c | 38 +++---------------- packages/bun-usockets/src/internal/internal.h | 1 - 2 files changed, 6 insertions(+), 33 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 97c671479806..1f64e90a9907 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1760,7 +1760,6 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, s->ssl_handshake_state = HANDSHAKE_PENDING; s->ssl_write_wants_read = 0; s->ssl_write_parked = 0; - s->ssl_read_wants_write = 0; s->ssl_fatal_error = 0; s->ssl_raw_tap = 0; s->ssl_spilled = 0; @@ -2244,7 +2243,7 @@ static void ssl_update_handshake(struct us_socket_t *s, int fin_ends_handshake) s->ssl_handshake_state = HANDSHAKE_PENDING; return; } - if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) { + if (err != SSL_ERROR_WANT_READ) { if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) { ssl_park_fatal_reason(s); } @@ -2253,15 +2252,6 @@ static void ssl_update_handshake(struct us_socket_t *s, int fin_ends_handshake) } s->ssl_handshake_state = HANDSHAKE_PENDING; s->ssl_write_wants_read = 1; - /* Keep writable interest only for a blocked write. Setting this for - * WANT_READ too kept the always-writable socket's writable event firing - * every tick with zero progress (100% CPU) whenever writable interest - * existed while the handshake stalled, e.g. pause() mid-handshake. - * WANT_WRITE's blocked BIO write normally sets it in us_socket_raw_write - * already; kept for BIO retry paths that buffer without a send. */ - if (err == SSL_ERROR_WANT_WRITE) { - s->flags.last_write_failed = 1; - } return; } @@ -2337,8 +2327,7 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s); * finished). No-op while this socket's spill is undrained: the flag is kept * so the retry happens after on_writable drains it. */ static struct us_socket_t *ssl_retry_parked_write(struct us_socket_t *s) { - if (!s->ssl_write_wants_read || s->ssl_read_wants_write) return s; - if (s->ssl_spilled) return s; + if (!s->ssl_write_wants_read || s->ssl_spilled) return s; s->ssl_write_wants_read = 0; return ssl_on_writable(s); } @@ -2439,16 +2428,6 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { return us_internal_ssl_close(s, s->ssl_pending_close_code, NULL); } ssl_update_handshake(s, 0); - if (ssl_gone(s)) return s; - - if (s->ssl_read_wants_write) { - s->ssl_read_wants_write = 0; - /* Re-enter the data path with an empty buffer; SSL_read will pull from - * the kernel via the next readable event but this lets it flush any - * pending decrypt that was blocked on a write. */ - s = us_internal_ssl_on_data(s, "", 0); - if (!s || ssl_gone(s)) return s; - } s = ssl_close_if_fatal(s); if (!s || ssl_gone(s)) return s; /* uWS HTTP sockets keep the pre-existing SENT_SHUTDOWN suppression: their @@ -2516,9 +2495,8 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i * per-thread error queue so a captured reason cannot belong to another * socket on the same thread. */ ERR_clear_error(); - /* upgradeTLS [raw, _] half observes ciphertext before SSL_read consumes it. - * Skip the empty-flush call from on_writable (length==0 → no real wire bytes). */ - if (s->ssl_raw_tap && length > 0) { + /* upgradeTLS [raw, _] half observes ciphertext before SSL_read consumes it. */ + if (s->ssl_raw_tap) { s = us_dispatch_ssl_raw_tap(s, data, length); if (!s || us_socket_is_closed(s) || !s->ssl) return s; } @@ -2604,8 +2582,7 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i * like WANT_READ - stop the read loop, deliver whatever was decrypted, * and park the socket; us_socket_sni_resolve() re-drives the handshake * when the JS resolution arrives. */ - if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE && - err != SSL_ERROR_PENDING_CERTIFICATE) { + if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_PENDING_CERTIFICATE) { if (err == SSL_ERROR_WANT_RENEGOTIATE) { /* The HelloRequest can share a read with the server's Finished. */ s = ssl_report_finished_handshake(s, loop_ssl_data, 0); @@ -2676,8 +2653,6 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i loop_ssl_data->ssl_last_fatal_error[0] = 0; return NULL; } else { - if (err == SSL_ERROR_WANT_WRITE) s->ssl_read_wants_write = 1; - /* If the BIO still has unread ciphertext at this point, the TLS * framing is broken — close. */ if (loop_ssl_data->ssl_read_input_length) { @@ -2741,8 +2716,7 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i } /* If the last SSL_write failed with WANT_READ and we've now read, give the - * application a writable callback — but not if SSL_read just told us it - * needs to write first (would recurse). Re-check s->ssl: any dispatch above may + * application a writable callback. Re-check s->ssl: any dispatch above may * have closed and freed s->ssl. */ if (ssl_gone(s)) return NULL; s = ssl_retry_parked_write(s); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 3b3e8e969a0b..69493e37b8ff 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -323,7 +323,6 @@ struct us_socket_t { * not finished. ssl_write_wants_read cannot tell: every pending handshake * sets it. */ unsigned char ssl_write_parked : 1; - unsigned char ssl_read_wants_write : 1; unsigned char ssl_fatal_error : 1; unsigned char ssl_is_server : 1; /* If set, us_internal_ssl_on_data() first dispatches the still-encrypted From 929f169d95649a39cbc094d4c3abece4ad4f389a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:58:06 +0000 Subject: [PATCH 101/174] websocket: call tls.checkServerIdentity once when the server renegotiates before the 101 A renegotiation that ends before the upgrade response is reported to the upgrade client, which judged it like a first handshake: the callback ran a second time, directly and inside a proxy tunnel. After the 101 it does not run again, and Node's tls.connect() calls it once. The verdict now reads from the SSL whether the handshake is a renegotiation, so it does not matter who owns the connection. BoringSSL refuses a changed certificate in every case. Also covers a renegotiation inside a tunnel after the 101, which had no test. --- src/http_jsc/websocket_client/CppWebSocket.rs | 8 ++- test/js/web/websocket/proxy-test-utils.ts | 65 ++++++++++++++++++- test/js/web/websocket/websocket-proxy.test.ts | 25 +++++++ .../websocket-tls-server-identity.test.ts | 53 ++------------- 4 files changed, 99 insertions(+), 52 deletions(-) diff --git a/src/http_jsc/websocket_client/CppWebSocket.rs b/src/http_jsc/websocket_client/CppWebSocket.rs index d241f02dfa30..c38d4c0439ad 100644 --- a/src/http_jsc/websocket_client/CppWebSocket.rs +++ b/src/http_jsc/websocket_client/CppWebSocket.rs @@ -117,6 +117,7 @@ unsafe extern "C" { cert: &mut boringssl::c::X509, global_object: &JSGlobalObject, ) -> JSValue; + safe fn SSL_total_renegotiations(ssl: &boringssl::c::SSL) -> core::ffi::c_int; } // Receivers are `&self` (not `&mut self`) because `CppWebSocket` is @@ -246,8 +247,11 @@ impl CppWebSocket { !hostname.is_empty() && bun_uws::check_server_identity(ssl, hostname) }), (NameCheck::Callback { callback, enforce }, TlsHandshake::First { context, .. }) => { - ssl.is_some_and(|ssl| run_check_server_identity(context, callback, ssl, hostname)) - || !enforce + // Before the 101 the upgrade client hears of a renegotiation too. + ssl.is_some_and(|ssl| { + SSL_total_renegotiations(ssl) > 0 + || run_check_server_identity(context, callback, ssl, hostname) + }) || !enforce } } } diff --git a/test/js/web/websocket/proxy-test-utils.ts b/test/js/web/websocket/proxy-test-utils.ts index def0ef04f4f7..c2c201b431aa 100644 --- a/test/js/web/websocket/proxy-test-utils.ts +++ b/test/js/web/websocket/proxy-test-utils.ts @@ -3,7 +3,7 @@ * ws-proxy.test.ts and websocket-syscall-fault.test.ts. */ -import { tls as tlsCerts } from "harness"; +import { bunEnv, nodeExe, tls as tlsCerts } from "harness"; import { createHash } from "crypto"; import net from "net"; import tls from "tls"; @@ -262,6 +262,69 @@ export async function startRawWssServer( }; } +/** + * A TLS 1.2 wss:// server that renegotiates once: before it answers the upgrade, + * when the upgrade client owns the connection, or on the client's first frame, + * when the connected client does. After the client's first frame (and that + * renegotiation) it sends "after renegotiation". It runs in real node because a + * BoringSSL server cannot renegotiate, so skip the test when `nodeExe()` is null. + */ +export async function startRenegotiatingWssServer(when: "before the 101" | "after the 101") { + const server = Bun.spawn({ + cmd: [ + nodeExe()!, + "-e", + ` + const tls = require("tls"); + const crypto = require("crypto"); + const server = tls.createServer( + { cert: process.env.SERVER_CERT, key: process.env.SERVER_KEY, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" }, + socket => { + socket.on("error", () => {}); + const renegotiate = then => + socket.renegotiate({ rejectUnauthorized: false }, err => (err ? socket.destroy(err) : then())); + const greet = () => { + const payload = Buffer.from("after renegotiation"); + socket.write(Buffer.concat([Buffer.from([0x81, payload.length]), payload])); + }; + let head = ""; + const onHead = chunk => { + head += chunk.toString("latin1"); + if (!head.includes("\\r\\n\\r\\n")) return; + socket.off("data", onHead); + const key = /sec-websocket-key:\\s*(\\S+)/i.exec(head)[1]; + const accept = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); + const answer = () => + socket.write( + "HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\n" + + "Sec-WebSocket-Accept: " + accept + "\\r\\n\\r\\n", + ); + if (process.env.WHEN === "before the 101") { + socket.once("data", greet); + renegotiate(answer); + } else { + socket.once("data", () => renegotiate(greet)); + answer(); + } + }; + socket.on("data", onHead); + }, + ); + server.listen(0, "127.0.0.1", () => console.log(server.address().port)); + `, + ], + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + env: { ...bunEnv, WHEN: when, SERVER_CERT: tlsCerts.cert, SERVER_KEY: tlsCerts.key }, + }); + const { value } = await server.stdout.getReader().read(); + return { + port: Number(new TextDecoder().decode(value).trim()), + [Symbol.asyncDispose]: () => server[Symbol.asyncDispose](), + }; +} + /** A WebSocket echo server. It greets each client with "connected", then echoes every message. */ export function startEchoServer(options: { tls?: boolean } = {}) { return Bun.serve({ diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 3a3e6072ae97..964678e30c19 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -20,6 +20,7 @@ import { startProxy, startRawWssServer, startRecordingProxy, + startRenegotiatingWssServer, } from "./proxy-test-utils"; // Use dynamic require to avoid linter removing the import const { HttpsProxyAgent } = require("https-proxy-agent") as { @@ -770,6 +771,30 @@ describe.concurrent("wss:// through a proxy: the end of the proxy connection", ( }); }); +// Whoever owns the connection when the origin renegotiates, the verdict of the first handshake stands: +// BoringSSL refuses a changed certificate. tls.connect() in Node calls checkServerIdentity once too. +describe.concurrent.skipIf(!harness.nodeExe())("wss:// origin that renegotiates", () => { + test.each([ + ["before the 101", "direct"], + ["before the 101", "http proxy"], + ["after the 101", "http proxy"], + ] as const)("%s, %s: the connection stays open and checkServerIdentity runs once", async (when, route) => { + await using origin = await startRenegotiatingWssServer(when); + using recorded = await startRecordingProxy(); + const calls: string[] = []; + const ws = new WebSocket(`wss://127.0.0.1:${origin.port}`, { + proxy: route === "direct" ? undefined : `http://127.0.0.1:${recorded.port}`, + tls: { ca: tlsCerts.cert, checkServerIdentity: hostname => void calls.push(hostname) }, + }); + ws.addEventListener("open", () => ws.send("ready")); + ws.addEventListener("message", () => ws.close(1000)); + expect({ events: await clientEvents(ws), calls }).toEqual({ + events: ["after renegotiation", { code: 1000, reason: "", wasClean: true }], + calls: ["127.0.0.1"], + }); + }); +}); + // Not concurrent: it counts every live SSL_CTX in the process. test("wss:// through a proxy: terminate() frees the SSL_CTX of the tunnel", async () => { using recorded = await startRecordingProxy(); diff --git a/test/js/web/websocket/websocket-tls-server-identity.test.ts b/test/js/web/websocket/websocket-tls-server-identity.test.ts index 1b6326176e82..b3d7864adef5 100644 --- a/test/js/web/websocket/websocket-tls-server-identity.test.ts +++ b/test/js/web/websocket/websocket-tls-server-identity.test.ts @@ -7,7 +7,7 @@ import net, { type AddressInfo } from "node:net"; import tls from "node:tls"; import { Worker } from "node:worker_threads"; import { WebSocket as WsPackageWebSocket } from "ws"; -import { clientEvents, startRecordingProxy } from "./proxy-test-utils"; +import { clientEvents, startRecordingProxy, startRenegotiatingWssServer } from "./proxy-test-utils"; // NO_PROXY applies to explicit proxies too. An ambient // NO_PROXY=localhost,127.0.0.1,... would bypass the proxy tests below. @@ -113,43 +113,6 @@ function startSniServer({ }; } -// A TLS 1.2 server that answers one WebSocket upgrade, then forces a TLS -// renegotiation and sends a text frame. It runs in real node because a -// BoringSSL server cannot renegotiate. It waits for the client's first frame, -// so the connected client, not the upgrade client, owns the socket by then. -const node = nodeExe(); -const renegotiatingServer = ` - const tls = require("tls"); - const crypto = require("crypto"); - const server = tls.createServer( - { cert: process.env.SERVER_CERT, key: process.env.SERVER_KEY, minVersion: "TLSv1.2", maxVersion: "TLSv1.2" }, - socket => { - socket.on("error", () => {}); - let head = ""; - const onHead = chunk => { - head += chunk.toString("latin1"); - if (!head.includes("\\r\\n\\r\\n")) return; - socket.off("data", onHead); - const key = /sec-websocket-key:\\s*(\\S+)/i.exec(head)[1]; - const accept = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); - socket.write( - "HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\n" + - "Sec-WebSocket-Accept: " + accept + "\\r\\n\\r\\n", - ); - socket.once("data", () => { - socket.renegotiate({ rejectUnauthorized: false }, err => { - if (err) return socket.destroy(err); - const payload = Buffer.from("after renegotiation"); - socket.write(Buffer.concat([Buffer.from([0x81, payload.length]), payload])); - }); - }); - }; - socket.on("data", onHead); - }, - ); - server.listen(0, "127.0.0.1", () => console.log(server.address().port)); -`; - function openSession(ws: WebSocket) { ws.addEventListener("open", () => ws.close(1000)); return clientEvents(ws); @@ -566,22 +529,14 @@ describe.concurrent("WebSocket tls.checkServerIdentity", () => { // On a renegotiation BoringSSL requires the same certificate, so the verdict // of the callback still holds and the callback does not run again. - (node ? test : test.skip).each([ + (nodeExe() ? test : test.skip).each([ ["a name the certificate does not have", { serverName: "evil.test" }, "evil.test"], ["an IP URL, which has no SNI", {}, "127.0.0.1"], ] as const)("a certificate it approved survives a TLS 1.2 renegotiation: %s", async (_label, names, hostname) => { - await using server = Bun.spawn({ - cmd: [node!, "-e", renegotiatingServer], - stdout: "pipe", - stderr: "inherit", - stdin: "ignore", - env: { ...bunEnv, SERVER_CERT: tlsCerts.cert, SERVER_KEY: tlsCerts.key }, - }); - const { value } = await server.stdout.getReader().read(); - const port = Number(new TextDecoder().decode(value).trim()); + await using server = await startRenegotiatingWssServer("after the 101"); const calls: string[] = []; - const ws = new WebSocket(`wss://127.0.0.1:${port}/`, { + const ws = new WebSocket(`wss://127.0.0.1:${server.port}/`, { tls: { ca: tlsCerts.cert, ...names, From 367946837c0e462686afb4c0b3d62fae6e1b9cfa Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:59:34 +0000 Subject: [PATCH 102/174] test(websocket): move the TLS server identity tests into websocket-client.test.ts Tests belong in the existing file for the code they cover. All 37 tests and their assertions are unchanged. They use `it`, because that file has its own `test` helper, and they clear NO_PROXY in hooks of their describe block only. --- .../js/web/websocket/websocket-client.test.ts | 775 +++++++++++++++++- .../websocket-tls-server-identity.test.ts | 767 ----------------- 2 files changed, 772 insertions(+), 770 deletions(-) delete mode 100644 test/js/web/websocket/websocket-tls-server-identity.test.ts diff --git a/test/js/web/websocket/websocket-client.test.ts b/test/js/web/websocket/websocket-client.test.ts index d4cf56a16cd6..a04885f8ce65 100644 --- a/test/js/web/websocket/websocket-client.test.ts +++ b/test/js/web/websocket/websocket-client.test.ts @@ -1,12 +1,16 @@ import type { Subprocess } from "bun"; import { spawn } from "bun"; +import { heapStats } from "bun:jsc"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, nodeExe } from "harness"; -import { createHash } from "node:crypto"; +import { bunEnv, bunExe, nodeExe, tls as tlsCerts } from "harness"; +import { createHash, X509Certificate } from "node:crypto"; import { once } from "node:events"; -import { createServer } from "node:net"; +import net, { type AddressInfo, createServer } from "node:net"; import * as path from "node:path"; +import tls from "node:tls"; +import { Worker } from "node:worker_threads"; import { WebSocket as NodeWS, WebSocketServer } from "ws"; +import { clientEvents, startRecordingProxy, startRenegotiatingWssServer } from "./proxy-test-utils"; function test( label: string, fn: (ws: WebSocket, done: (err?: unknown) => void) => void, @@ -663,6 +667,771 @@ describe("WebSocket handshake Connection header token list", () => { }); }); +describe("WebSocket TLS server identity", () => { + // NO_PROXY applies to explicit proxies too. An ambient + // NO_PROXY=localhost,127.0.0.1,... would bypass the proxy tests below. + const prevNoProxy = process.env.NO_PROXY; + const prevNoProxyLower = process.env.no_proxy; + beforeAll(() => { + process.env.NO_PROXY = ""; + process.env.no_proxy = ""; + }); + afterAll(() => { + if (prevNoProxy === undefined) delete process.env.NO_PROXY; + else process.env.NO_PROXY = prevNoProxy; + if (prevNoProxyLower === undefined) delete process.env.no_proxy; + else process.env.no_proxy = prevNoProxyLower; + }); + + // A TLS server that records the SNI of every handshake and answers one + // WebSocket upgrade per connection. The harness certificate has CN=server-bun + // and SAN DNS:localhost, IP:127.0.0.1, IP:::1. With `hold`, a handshake that + // sends SNI stops at the ClientHello until `hold` resolves. + function startSniServer({ + requestCert = false, + hold, + version, + }: { requestCert?: boolean; hold?: Promise; version?: tls.SecureVersion } = {}) { + const sni: (string | null)[] = []; + const clientCertificates: (string | undefined)[] = []; + let applicationData = ""; + const connectionEnded = Promise.withResolvers(); + const clientHello = Promise.withResolvers(); + const server = tls.createServer( + { + key: tlsCerts.key, + cert: tlsCerts.cert, + minVersion: version, + maxVersion: version, + // The client certificate is recorded, not verified. + requestCert, + rejectUnauthorized: false, + SNICallback(servername, cb) { + sni.push(servername); + clientHello.resolve(); + const resume = () => cb(null, tls.createSecureContext({ key: tlsCerts.key, cert: tlsCerts.cert })); + if (hold) hold.then(resume); + else resume(); + }, + }, + socket => { + let buffered = ""; + socket.on("data", chunk => { + applicationData += chunk.toString("latin1"); + buffered += chunk.toString("latin1"); + const end = buffered.indexOf("\r\n\r\n"); + if (end === -1) return; + const key = buffered + .slice(0, end) + .split("\r\n") + .find(line => line.toLowerCase().startsWith("sec-websocket-key:")) + ?.split(":", 2)[1] + .trim(); + if (!key) { + socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); + return; + } + const accept = createHash("sha1") + .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + buffered = ""; + }); + socket.on("error", () => {}); + socket.on("close", () => connectionEnded.resolve()); + }, + ); + server.on("tlsClientError", () => connectionEnded.resolve()); + // A handshake with no server_name extension never reaches SNICallback. + server.on("secureConnection", socket => { + if (!socket.servername) sni.push(null); + if (requestCert) clientCertificates.push(socket.getPeerCertificate()?.fingerprint256); + }); + const { promise, resolve } = Promise.withResolvers(); + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); + return { + port: promise, + sni, + clientCertificates, + // The server has the ClientHello of a handshake that sends SNI. + clientHello: clientHello.promise, + // What the client has sent over TLS so far. + get received() { + return applicationData; + }, + // The same, once the server saw the connection end. A rejected peer must + // get none of the upgrade request: it carries Authorization and Cookie. + async receivedInTotal() { + await connectionEnded.promise; + return applicationData; + }, + [Symbol.dispose]() { + server.close(); + }, + }; + } + + function openSession(ws: WebSocket) { + ws.addEventListener("open", () => ws.close(1000)); + return clientEvents(ws); + } + + const opened = [{ code: 1000, reason: "", wasClean: true }]; + const expectedFingerprint256 = new X509Certificate(tlsCerts.cert).fingerprint256; + const tlsFailed = (url: string) => [ + { error: `WebSocket connection to '${url}' failed: TLS handshake failed` }, + { code: 1015, reason: "TLS handshake failed", wasClean: false }, + ]; + + describe.concurrent("WebSocket tls.serverName", () => { + // `servername` is the node spelling of the same option. + it.each(["serverName", "servername"] as const)( + "%s is sent as SNI when the URL host is an IP address", + async key => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, [key]: "localhost" } }); + expect(await openSession(ws)).toEqual(opened); + expect(server.sni).toEqual(["localhost"]); + }, + ); + + it("is the name the certificate is verified against", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + // 127.0.0.1 is in the SAN, evil.test is not. Without serverName this would open. + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, serverName: "evil.test" } }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(server.sni).toEqual(["evil.test"]); + expect(await server.receivedInTotal()).toBe(""); + }); + + it("is the bare address when it is an IPv6 literal in brackets, like fetch", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + // ::1 is in the SAN as an IP address, and an IP address is never sent as SNI. + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, serverName: "[::1]" } }); + expect(await openSession(ws)).toEqual(opened); + const hostnames: string[] = []; + const withCallback = new WebSocket(url, { + tls: { ca: tlsCerts.cert, serverName: "[::1]", checkServerIdentity: hostname => void hostnames.push(hostname) }, + }); + expect(await openSession(withCallback)).toEqual(opened); + expect({ sni: server.sni, hostnames }).toEqual({ sni: [null, null], hostnames: ["::1"] }); + }); + + it("is used for the tunnel handshake through an HTTP proxy", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://127.0.0.1:${await server.port}/`; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${proxy.port}`, + tls: { ca: tlsCerts.cert, serverName: "localhost" }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(server.sni).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + }); + + // The proxy is dialed by IP address, so its handshake has no SNI and its certificate is + // checked against 127.0.0.1. `serverName` is for the handshake with the target. + it.each([ + ["a name the certificate has", "localhost", undefined], + ["a name that only the callback accepts", "target.test", () => undefined], + ] as const)("names the target and not an HTTPS proxy: %s", async (_label, serverName, checkServerIdentity) => { + using server = startSniServer(); + using proxy = await startRecordingProxy({ tls: true }); + const ws = new WebSocket(`wss://127.0.0.1:${await server.port}/`, { + proxy: `https://127.0.0.1:${proxy.port}`, + tls: { ca: tlsCerts.cert, serverName, checkServerIdentity }, + }); + expect(await openSession(ws)).toEqual(opened); + expect({ proxy: proxy.sni, target: server.sni }).toEqual({ proxy: [null], target: [serverName] }); + expect(proxy.requests).toHaveLength(1); + }); + }); + + describe.concurrent("WebSocket tls.checkServerIdentity", () => { + it("is called with the hostname and the peer certificate", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const calls: { hostname: string; subject: string; altnames: string; fingerprint256: string; raw: boolean }[] = []; + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity(hostname: string, cert: tls.PeerCertificate) { + calls.push({ + hostname, + subject: cert.subject.CN, + altnames: cert.subjectaltname, + fingerprint256: cert.fingerprint256, + raw: Buffer.isBuffer(cert.raw), + }); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual([ + { + hostname: "localhost", + subject: "server-bun", + altnames: "DNS:localhost, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1", + fingerprint256: expectedFingerprint256, + raw: true, + }, + ]); + // Control for the rejection tests: the server does record the request. + // It read all of it before the 101, so no wait for the connection to end. + expect(server.received).toStartWith("GET / HTTP/1.1\r\n"); + }); + + it("drains microtasks queued by the callback before the open event", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const order: string[] = []; + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + order.push("callback"); + queueMicrotask(() => order.push("microtask")); + return undefined; + }, + }, + }); + ws.addEventListener("open", () => order.push("open")); + expect(await openSession(ws)).toEqual(opened); + expect(order).toEqual(["callback", "microtask", "open"]); + }); + + it.each(["TLSv1.2", "TLSv1.3"] as const)("rejects the connection when it returns an Error: %s", async version => { + using server = startSniServer({ version }); + const url = `wss://localhost:${await server.port}/`; + let calls = 0; + const ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + calls++; + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(calls).toBe(1); + // Rejected before the upgrade: the peer never sees the request. + expect(await server.receivedInTotal()).toBe(""); + }); + + // The client runs in a child process: the exception it throws is reported as + // uncaught, which would fail this test run. + it("rejects the connection when it throws, and reports the exception", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + await using client = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const events = []; + process.on("uncaughtException", error => events.push("uncaught: " + error.message)); + const ws = new WebSocket(process.env.WS_URL, { + tls: { + ca: process.env.WS_CA, + checkServerIdentity() { + throw new Error("PIN-REJECT"); + }, + }, + }); + ws.onopen = () => { + events.push("open"); + ws.close(); + }; + ws.onerror = event => events.push("error: " + event.message); + ws.onclose = event => { + events.push("close " + event.code); + console.log(JSON.stringify(events)); + }; + `, + ], + env: { ...bunEnv, WS_URL: url, WS_CA: tlsCerts.cert }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([client.stdout.text(), client.stderr.text(), client.exited]); + expect({ stderr, events: JSON.parse(stdout || "null") }).toEqual({ + stderr: "", + events: [ + "uncaught: PIN-REJECT", + `error: WebSocket connection to '${url}' failed: TLS handshake failed`, + "close 1015", + ], + }); + expect(exitCode).toBe(0); + expect(await server.receivedInTotal()).toBe(""); + }); + + it("runs in the Bun.ModuleGraph that opened the WebSocket, so a throw goes to its onError", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const errors: unknown[] = []; + const graph = new Bun.ModuleGraph({ onError: (error, kind) => errors.push([kind, (error as Error).message]) }); + try { + let current: unknown; + const events = await graph.run(() => { + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + current = Bun.ModuleGraph.current; + throw new Error("PIN-REJECT"); + }, + }, + }); + return openSession(ws); + }); + expect({ events, errors, ranInGraph: current === graph }).toEqual({ + events: tlsFailed(url), + errors: [["uncaughtException", "PIN-REJECT"]], + ranInGraph: true, + }); + } finally { + graph.dispose(); + } + }); + + describe.each(["direct", "through an HTTP proxy"] as const)("from inside the callback, %s", route => { + it.each(["close", "terminate"] as const)("may %s() the WebSocket", async method => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://localhost:${await server.port}/`; + const ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + proxy: route === "direct" ? undefined : `http://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + ws[method](); + return undefined; + }, + }, + }); + const reason = "WebSocket is closed before the connection is established"; + expect(await clientEvents(ws)).toEqual([ + { error: `WebSocket connection to '${url}' failed: ${reason}` }, + { code: 1006, reason, wasClean: false }, + ]); + expect(await server.receivedInTotal()).toBe(""); + }); + }); + + it("replaces the built-in hostname check, like fetch", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + const calls: string[] = []; + // evil.test is not in the SAN. The callback approves it anyway. + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + serverName: "evil.test", + checkServerIdentity(hostname: string) { + calls.push(hostname); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["evil.test"]); + }); + + // mTLS. The callback, not the built-in check, still decides on the name. + it("replaces the built-in hostname check when the server asks for a client certificate", async () => { + using server = startSniServer({ requestCert: true }); + const url = `wss://127.0.0.1:${await server.port}/`; + const calls: string[] = []; + const ws = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + cert: tlsCerts.cert, + key: tlsCerts.key, + serverName: "evil.test", + checkServerIdentity(hostname: string) { + calls.push(hostname); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["evil.test"]); + expect(server.clientCertificates).toEqual([expectedFingerprint256]); + }); + + // The verdict is read as tls.connect() reads it: any truthy value rejects. An async + // callback returns a Promise, which is never an Error, so it must not pass for approval. + it.each([ + ["a Promise, from an async callback", async () => new Error("PIN-REJECT")], + ["a string", () => "pin mismatch"], + ] as const)("rejects the connection when it returns %s", async (_label, checkServerIdentity) => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + tls: { ca: tlsCerts.cert, checkServerIdentity: checkServerIdentity as never }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(await server.receivedInTotal()).toBe(""); + }); + + it.each([ + ["undefined", undefined], + ["false", false], + ] as const)("approves when it returns %s, as tls.connect() does", async (_label, verdict) => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + let calls = 0; + const checkServerIdentity = () => (calls++, verdict); + const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, checkServerIdentity: checkServerIdentity as never } }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toBe(1); + }); + + it.each([ + ["a string", "pin"], + ["an object", {}], + ] as const)("throws when it is %s, like fetch", (_label, value) => { + let error: unknown; + try { + new WebSocket("wss://localhost:1/", { tls: { checkServerIdentity: value as never } }); + } catch (e) { + error = e; + } + expect(error).toMatchObject({ + code: "ERR_INVALID_ARG_TYPE", + message: expect.stringContaining('The "tls.checkServerIdentity" property must be of type function'), + }); + }); + + it("is ignored when it is null, and the built-in check applies", async () => { + using server = startSniServer(); + const url = `wss://127.0.0.1:${await server.port}/`; + const ws = new WebSocket(url, { + tls: { ca: tlsCerts.cert, serverName: "evil.test", checkServerIdentity: null as never }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + }); + + it("is found on the prototype of the tls object, like the other tls options", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + let calls = 0; + const tlsOptions = Object.create({ + checkServerIdentity() { + calls++; + return new Error("PIN-REJECT"); + }, + }); + tlsOptions.ca = tlsCerts.cert; + const ws = new WebSocket(url, { tls: tlsOptions }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(calls).toBe(1); + }); + + describe("with rejectUnauthorized: false, like fetch", () => { + it("runs only on a chain that verified, and its verdict is not enforced", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + // Without `ca` the chain fails: the harness certificate is self-signed. + for (const [label, ca] of [ + ["verified", tlsCerts.cert], + ["unverified", undefined], + ] as const) { + const ws = new WebSocket(url, { + tls: { + ca, + rejectUnauthorized: false, + checkServerIdentity(hostname: string) { + calls.push(`${label}: ${hostname}`); + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + } + expect(calls).toEqual(["verified: localhost"]); + }); + + it("does the same for the target inside a proxy tunnel", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + rejectUnauthorized: false, + checkServerIdentity(hostname: string) { + calls.push(hostname); + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + }); + }); + + // On a renegotiation BoringSSL requires the same certificate, so the verdict + // of the callback still holds and the callback does not run again. + (nodeExe() ? it : it.skip).each([ + ["a name the certificate does not have", { serverName: "evil.test" }, "evil.test"], + ["an IP URL, which has no SNI", {}, "127.0.0.1"], + ] as const)("a certificate it approved survives a TLS 1.2 renegotiation: %s", async (_label, names, hostname) => { + await using server = await startRenegotiatingWssServer("after the 101"); + + const calls: string[] = []; + const ws = new WebSocket(`wss://127.0.0.1:${server.port}/`, { + tls: { + ca: tlsCerts.cert, + ...names, + checkServerIdentity(name: string) { + calls.push(name); + return undefined; + }, + }, + }); + ws.addEventListener("open", () => ws.send("ready")); + ws.addEventListener("message", () => ws.close(1000)); + expect(await clientEvents(ws)).toEqual(["after renegotiation", ...opened]); + expect(calls).toEqual([hostname]); + }); + + it("is called for the target certificate through an HTTP proxy", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy(); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + checkServerIdentity(hostname: string) { + calls.push(hostname); + return new Error("PIN-REJECT"); + }, + }, + }); + expect(await openSession(ws)).toEqual(tlsFailed(url)); + expect(calls).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + expect(await server.receivedInTotal()).toBe(""); + }); + + it("is not called for the certificate of an HTTPS proxy", async () => { + using server = startSniServer(); + using proxy = await startRecordingProxy({ tls: true }); + const url = `wss://localhost:${await server.port}/`; + const calls: string[] = []; + // The proxy presents the harness certificate too. The built-in check + // verifies it against 127.0.0.1; the callback sees only the target. + const ws = new WebSocket(url, { + proxy: `https://127.0.0.1:${proxy.port}`, + tls: { + ca: tlsCerts.cert, + checkServerIdentity(hostname: string) { + calls.push(hostname); + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + expect(calls).toEqual(["localhost"]); + expect(proxy.requests).toHaveLength(1); + }); + + // A call into a VM that is stopping is a no-op that returns undefined, which is also how the callback approves. + it("does not approve when the handshake ends in a worker that terminate() has stopped", async () => { + using server = startSniServer(); + const serverPort = await server.port; + // Holds the server's first flight, so that the test decides when the handshake can end. + const flight = Promise.withResolvers<() => Promise>(); + const relay = net.createServer(client => { + const upstream = net.connect(serverPort, "127.0.0.1"); + client.on("error", () => {}); + client.on("close", () => upstream.destroy()); + upstream.on("error", () => {}); + client.pipe(upstream); + upstream.once("data", bytes => + flight.resolve(() => new Promise(sent => client.write(bytes, () => (upstream.pipe(client), sent())))), + ); + }); + const other = Promise.withResolvers(); + const otherServer = net.createServer(other.resolve); + relay.listen(0, "127.0.0.1"); + otherServer.listen(0, "127.0.0.1"); + await Promise.all([once(relay, "listening"), once(otherServer, "listening")]); + + const [STAGE, BOTH_READABLE, STOPPED] = [0, 1, 2]; + const flags = new Int32Array(new SharedArrayBuffer(12)); + const stage = async (value: number) => { + while (Atomics.load(flags, STAGE) !== value) await new Promise(resolve => setImmediate(resolve)); + }; + const raise = (flag: number) => (Atomics.store(flags, flag, 1), Atomics.notify(flags, flag)); + const worker = new Worker( + ` + const { parentPort, workerData } = require("node:worker_threads"); + const { url, ca, otherPort, flags } = workerData; + globalThis.ws = new WebSocket(url, { + headers: { Authorization: "Bearer secret" }, + tls: { ca, checkServerIdentity: () => new Error("PIN-REJECT") }, + }); + Bun.connect({ + hostname: "127.0.0.1", + port: otherPort, + socket: { + data() { + Atomics.store(flags, ${STAGE}, 2); + Atomics.wait(flags, ${STOPPED}, 0); + }, + }, + }); + parentPort.on("message", () => { + Atomics.store(flags, ${STAGE}, 1); + Atomics.wait(flags, ${BOTH_READABLE}, 0); + }); + `, + { + eval: true, + workerData: { + url: `wss://127.0.0.1:${(relay.address() as AddressInfo).port}/`, + ca: tlsCerts.cert, + otherPort: (otherServer.address() as AddressInfo).port, + flags, + }, + }, + ); + try { + const [deliverFlight, otherSocket] = await Promise.all([flight.promise, other.promise]); + // While the worker's thread is blocked, both of its sockets become readable, the other one first. + worker.postMessage("block"); + await stage(1); + await new Promise(sent => otherSocket.write("x", sent)); + await deliverFlight(); + raise(BOTH_READABLE); + // One pass of the worker's event loop now reads both. terminate() lands between the two. + await stage(2); + const terminated = worker.terminate(); + raise(STOPPED); + expect(await server.receivedInTotal()).toBe(""); + await terminated; + } finally { + raise(BOTH_READABLE); + raise(STOPPED); + await worker.terminate(); + relay.close(); + otherServer.close(); + } + }); + + // The `ws` package passes its `tls` object to the native client as it is. + it("applies through the ws package, with tls.serverName", async () => { + using server = startSniServer(); + const calls: string[] = []; + const ws = new NodeWS(`wss://127.0.0.1:${await server.port}/`, { + tls: { + ca: tlsCerts.cert, + serverName: "localhost", + checkServerIdentity(hostname: string) { + calls.push(hostname); + return new Error("PIN-REJECT"); + }, + }, + }); + // The first event decides. A close with no error before it must fail the assertion, not hang. + const outcome = await new Promise(resolve => { + ws.on("open", () => resolve("open")); + ws.on("error", () => resolve("error")); + ws.on("close", code => resolve(`close ${code}`)); + }); + expect({ outcome, calls, sni: server.sni }).toEqual({ + outcome: "error", + calls: ["localhost"], + sni: ["localhost"], + }); + expect(await server.receivedInTotal()).toBe(""); + }); + }); + + // Not concurrent: these count every live WebSocket in the process, or force a full GC. + describe("WebSocket tls.checkServerIdentity lifetime", () => { + it("survives a GC before the handshake ends when only the WebSocket refers to it", async () => { + const release = Promise.withResolvers(); + using server = startSniServer({ hold: release.promise }); + const url = `wss://127.0.0.1:${await server.port}/`; + const calls: string[] = []; + // Nothing in this scope refers to the callback or to the tls object. The built-in check + // rejects evil.test, so the connection opens only if the callback is still there to run. + const ws = (() => + new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + serverName: "evil.test", + checkServerIdentity: (hostname: string) => void calls.push(hostname), + }, + }))(); + const session = openSession(ws); + // A connection that ends before its ClientHello fails the assertion below and does not hang here. + await Promise.race([server.clientHello, session]); + Bun.gc(true); + release.resolve(); + expect(await session).toEqual(opened); + expect(calls).toEqual(["evil.test"]); + }); + + it("does not keep a closed WebSocket alive when the callback captures it", async () => { + using server = startSniServer(); + const url = `wss://localhost:${await server.port}/`; + const liveWebSockets = () => { + Bun.gc(true); + return heapStats().objectTypeCounts.WebSocket || 0; + }; + const before = liveWebSockets(); + const total = 16; + let checks = 0; + await Promise.all( + Array.from({ length: total }, async () => { + // The cycle: the WebSocket holds the callback, and the callback holds the WebSocket. + const ws: WebSocket = new WebSocket(url, { + tls: { + ca: tlsCerts.cert, + checkServerIdentity() { + if (ws.readyState === WebSocket.CONNECTING) checks++; + return undefined; + }, + }, + }); + expect(await openSession(ws)).toEqual(opened); + }), + ); + expect(checks).toBe(total); + // The WebSocket drops its pending activity in a task after the close event. + let leaked = liveWebSockets() - before; + for (let i = 0; i < 10 && leaked > 2; i++) { + await new Promise(resolve => setImmediate(resolve)); + leaked = liveWebSockets() - before; + } + expect(leaked).toBeLessThanOrEqual(2); + }); + }); +}); + async function listen(): Promise { const pathname = path.join(import.meta.dir, "./websocket-server-echo.mjs"); const { promise, resolve, reject } = Promise.withResolvers(); diff --git a/test/js/web/websocket/websocket-tls-server-identity.test.ts b/test/js/web/websocket/websocket-tls-server-identity.test.ts deleted file mode 100644 index b3d7864adef5..000000000000 --- a/test/js/web/websocket/websocket-tls-server-identity.test.ts +++ /dev/null @@ -1,767 +0,0 @@ -import { heapStats } from "bun:jsc"; -import { afterAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, nodeExe, tls as tlsCerts } from "harness"; -import { createHash, X509Certificate } from "node:crypto"; -import { once } from "node:events"; -import net, { type AddressInfo } from "node:net"; -import tls from "node:tls"; -import { Worker } from "node:worker_threads"; -import { WebSocket as WsPackageWebSocket } from "ws"; -import { clientEvents, startRecordingProxy, startRenegotiatingWssServer } from "./proxy-test-utils"; - -// NO_PROXY applies to explicit proxies too. An ambient -// NO_PROXY=localhost,127.0.0.1,... would bypass the proxy tests below. -const prevNoProxy = process.env.NO_PROXY; -const prevNoProxyLower = process.env.no_proxy; -process.env.NO_PROXY = ""; -process.env.no_proxy = ""; -afterAll(() => { - if (prevNoProxy === undefined) delete process.env.NO_PROXY; - else process.env.NO_PROXY = prevNoProxy; - if (prevNoProxyLower === undefined) delete process.env.no_proxy; - else process.env.no_proxy = prevNoProxyLower; -}); - -// A TLS server that records the SNI of every handshake and answers one -// WebSocket upgrade per connection. The harness certificate has CN=server-bun -// and SAN DNS:localhost, IP:127.0.0.1, IP:::1. With `hold`, a handshake that -// sends SNI stops at the ClientHello until `hold` resolves. -function startSniServer({ - requestCert = false, - hold, - version, -}: { requestCert?: boolean; hold?: Promise; version?: tls.SecureVersion } = {}) { - const sni: (string | null)[] = []; - const clientCertificates: (string | undefined)[] = []; - let applicationData = ""; - const connectionEnded = Promise.withResolvers(); - const clientHello = Promise.withResolvers(); - const server = tls.createServer( - { - key: tlsCerts.key, - cert: tlsCerts.cert, - minVersion: version, - maxVersion: version, - // The client certificate is recorded, not verified. - requestCert, - rejectUnauthorized: false, - SNICallback(servername, cb) { - sni.push(servername); - clientHello.resolve(); - const resume = () => cb(null, tls.createSecureContext({ key: tlsCerts.key, cert: tlsCerts.cert })); - if (hold) hold.then(resume); - else resume(); - }, - }, - socket => { - let buffered = ""; - socket.on("data", chunk => { - applicationData += chunk.toString("latin1"); - buffered += chunk.toString("latin1"); - const end = buffered.indexOf("\r\n\r\n"); - if (end === -1) return; - const key = buffered - .slice(0, end) - .split("\r\n") - .find(line => line.toLowerCase().startsWith("sec-websocket-key:")) - ?.split(":", 2)[1] - .trim(); - if (!key) { - socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); - return; - } - const accept = createHash("sha1") - .update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") - .digest("base64"); - socket.write( - "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + - `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, - ); - buffered = ""; - }); - socket.on("error", () => {}); - socket.on("close", () => connectionEnded.resolve()); - }, - ); - server.on("tlsClientError", () => connectionEnded.resolve()); - // A handshake with no server_name extension never reaches SNICallback. - server.on("secureConnection", socket => { - if (!socket.servername) sni.push(null); - if (requestCert) clientCertificates.push(socket.getPeerCertificate()?.fingerprint256); - }); - const { promise, resolve } = Promise.withResolvers(); - server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)); - return { - port: promise, - sni, - clientCertificates, - // The server has the ClientHello of a handshake that sends SNI. - clientHello: clientHello.promise, - // What the client has sent over TLS so far. - get received() { - return applicationData; - }, - // The same, once the server saw the connection end. A rejected peer must - // get none of the upgrade request: it carries Authorization and Cookie. - async receivedInTotal() { - await connectionEnded.promise; - return applicationData; - }, - [Symbol.dispose]() { - server.close(); - }, - }; -} - -function openSession(ws: WebSocket) { - ws.addEventListener("open", () => ws.close(1000)); - return clientEvents(ws); -} - -const opened = [{ code: 1000, reason: "", wasClean: true }]; -const expectedFingerprint256 = new X509Certificate(tlsCerts.cert).fingerprint256; -const tlsFailed = (url: string) => [ - { error: `WebSocket connection to '${url}' failed: TLS handshake failed` }, - { code: 1015, reason: "TLS handshake failed", wasClean: false }, -]; - -describe.concurrent("WebSocket tls.serverName", () => { - // `servername` is the node spelling of the same option. - test.each(["serverName", "servername"] as const)( - "%s is sent as SNI when the URL host is an IP address", - async key => { - using server = startSniServer(); - const url = `wss://127.0.0.1:${await server.port}/`; - const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, [key]: "localhost" } }); - expect(await openSession(ws)).toEqual(opened); - expect(server.sni).toEqual(["localhost"]); - }, - ); - - test("is the name the certificate is verified against", async () => { - using server = startSniServer(); - const url = `wss://127.0.0.1:${await server.port}/`; - // 127.0.0.1 is in the SAN, evil.test is not. Without serverName this would open. - const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, serverName: "evil.test" } }); - expect(await openSession(ws)).toEqual(tlsFailed(url)); - expect(server.sni).toEqual(["evil.test"]); - expect(await server.receivedInTotal()).toBe(""); - }); - - test("is the bare address when it is an IPv6 literal in brackets, like fetch", async () => { - using server = startSniServer(); - const url = `wss://127.0.0.1:${await server.port}/`; - // ::1 is in the SAN as an IP address, and an IP address is never sent as SNI. - const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, serverName: "[::1]" } }); - expect(await openSession(ws)).toEqual(opened); - const hostnames: string[] = []; - const withCallback = new WebSocket(url, { - tls: { ca: tlsCerts.cert, serverName: "[::1]", checkServerIdentity: hostname => void hostnames.push(hostname) }, - }); - expect(await openSession(withCallback)).toEqual(opened); - expect({ sni: server.sni, hostnames }).toEqual({ sni: [null, null], hostnames: ["::1"] }); - }); - - test("is used for the tunnel handshake through an HTTP proxy", async () => { - using server = startSniServer(); - using proxy = await startRecordingProxy(); - const url = `wss://127.0.0.1:${await server.port}/`; - const ws = new WebSocket(url, { - proxy: `http://127.0.0.1:${proxy.port}`, - tls: { ca: tlsCerts.cert, serverName: "localhost" }, - }); - expect(await openSession(ws)).toEqual(opened); - expect(server.sni).toEqual(["localhost"]); - expect(proxy.requests).toHaveLength(1); - }); - - // The proxy is dialed by IP address, so its handshake has no SNI and its certificate is - // checked against 127.0.0.1. `serverName` is for the handshake with the target. - test.each([ - ["a name the certificate has", "localhost", undefined], - ["a name that only the callback accepts", "target.test", () => undefined], - ] as const)("names the target and not an HTTPS proxy: %s", async (_label, serverName, checkServerIdentity) => { - using server = startSniServer(); - using proxy = await startRecordingProxy({ tls: true }); - const ws = new WebSocket(`wss://127.0.0.1:${await server.port}/`, { - proxy: `https://127.0.0.1:${proxy.port}`, - tls: { ca: tlsCerts.cert, serverName, checkServerIdentity }, - }); - expect(await openSession(ws)).toEqual(opened); - expect({ proxy: proxy.sni, target: server.sni }).toEqual({ proxy: [null], target: [serverName] }); - expect(proxy.requests).toHaveLength(1); - }); -}); - -describe.concurrent("WebSocket tls.checkServerIdentity", () => { - test("is called with the hostname and the peer certificate", async () => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - const calls: { hostname: string; subject: string; altnames: string; fingerprint256: string; raw: boolean }[] = []; - const ws = new WebSocket(url, { - tls: { - ca: tlsCerts.cert, - checkServerIdentity(hostname: string, cert: tls.PeerCertificate) { - calls.push({ - hostname, - subject: cert.subject.CN, - altnames: cert.subjectaltname, - fingerprint256: cert.fingerprint256, - raw: Buffer.isBuffer(cert.raw), - }); - return undefined; - }, - }, - }); - expect(await openSession(ws)).toEqual(opened); - expect(calls).toEqual([ - { - hostname: "localhost", - subject: "server-bun", - altnames: "DNS:localhost, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1", - fingerprint256: expectedFingerprint256, - raw: true, - }, - ]); - // Control for the rejection tests: the server does record the request. - // It read all of it before the 101, so no wait for the connection to end. - expect(server.received).toStartWith("GET / HTTP/1.1\r\n"); - }); - - test("drains microtasks queued by the callback before the open event", async () => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - const order: string[] = []; - const ws = new WebSocket(url, { - tls: { - ca: tlsCerts.cert, - checkServerIdentity() { - order.push("callback"); - queueMicrotask(() => order.push("microtask")); - return undefined; - }, - }, - }); - ws.addEventListener("open", () => order.push("open")); - expect(await openSession(ws)).toEqual(opened); - expect(order).toEqual(["callback", "microtask", "open"]); - }); - - test.each(["TLSv1.2", "TLSv1.3"] as const)("rejects the connection when it returns an Error: %s", async version => { - using server = startSniServer({ version }); - const url = `wss://localhost:${await server.port}/`; - let calls = 0; - const ws = new WebSocket(url, { - headers: { Authorization: "Bearer secret" }, - tls: { - ca: tlsCerts.cert, - checkServerIdentity() { - calls++; - return new Error("PIN-REJECT"); - }, - }, - }); - expect(await openSession(ws)).toEqual(tlsFailed(url)); - expect(calls).toBe(1); - // Rejected before the upgrade: the peer never sees the request. - expect(await server.receivedInTotal()).toBe(""); - }); - - // The client runs in a child process: the exception it throws is reported as - // uncaught, which would fail this test run. - test("rejects the connection when it throws, and reports the exception", async () => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - await using client = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - ` - const events = []; - process.on("uncaughtException", error => events.push("uncaught: " + error.message)); - const ws = new WebSocket(process.env.WS_URL, { - tls: { - ca: process.env.WS_CA, - checkServerIdentity() { - throw new Error("PIN-REJECT"); - }, - }, - }); - ws.onopen = () => { - events.push("open"); - ws.close(); - }; - ws.onerror = event => events.push("error: " + event.message); - ws.onclose = event => { - events.push("close " + event.code); - console.log(JSON.stringify(events)); - }; - `, - ], - env: { ...bunEnv, WS_URL: url, WS_CA: tlsCerts.cert }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([client.stdout.text(), client.stderr.text(), client.exited]); - expect({ stderr, events: JSON.parse(stdout || "null") }).toEqual({ - stderr: "", - events: [ - "uncaught: PIN-REJECT", - `error: WebSocket connection to '${url}' failed: TLS handshake failed`, - "close 1015", - ], - }); - expect(exitCode).toBe(0); - expect(await server.receivedInTotal()).toBe(""); - }); - - test("runs in the Bun.ModuleGraph that opened the WebSocket, so a throw goes to its onError", async () => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - const errors: unknown[] = []; - const graph = new Bun.ModuleGraph({ onError: (error, kind) => errors.push([kind, (error as Error).message]) }); - try { - let current: unknown; - const events = await graph.run(() => { - const ws = new WebSocket(url, { - tls: { - ca: tlsCerts.cert, - checkServerIdentity() { - current = Bun.ModuleGraph.current; - throw new Error("PIN-REJECT"); - }, - }, - }); - return openSession(ws); - }); - expect({ events, errors, ranInGraph: current === graph }).toEqual({ - events: tlsFailed(url), - errors: [["uncaughtException", "PIN-REJECT"]], - ranInGraph: true, - }); - } finally { - graph.dispose(); - } - }); - - describe.each(["direct", "through an HTTP proxy"] as const)("from inside the callback, %s", route => { - test.each(["close", "terminate"] as const)("may %s() the WebSocket", async method => { - using server = startSniServer(); - using proxy = await startRecordingProxy(); - const url = `wss://localhost:${await server.port}/`; - const ws = new WebSocket(url, { - headers: { Authorization: "Bearer secret" }, - proxy: route === "direct" ? undefined : `http://127.0.0.1:${proxy.port}`, - tls: { - ca: tlsCerts.cert, - checkServerIdentity() { - ws[method](); - return undefined; - }, - }, - }); - const reason = "WebSocket is closed before the connection is established"; - expect(await clientEvents(ws)).toEqual([ - { error: `WebSocket connection to '${url}' failed: ${reason}` }, - { code: 1006, reason, wasClean: false }, - ]); - expect(await server.receivedInTotal()).toBe(""); - }); - }); - - test("replaces the built-in hostname check, like fetch", async () => { - using server = startSniServer(); - const url = `wss://127.0.0.1:${await server.port}/`; - const calls: string[] = []; - // evil.test is not in the SAN. The callback approves it anyway. - const ws = new WebSocket(url, { - tls: { - ca: tlsCerts.cert, - serverName: "evil.test", - checkServerIdentity(hostname: string) { - calls.push(hostname); - return undefined; - }, - }, - }); - expect(await openSession(ws)).toEqual(opened); - expect(calls).toEqual(["evil.test"]); - }); - - // mTLS. The callback, not the built-in check, still decides on the name. - test("replaces the built-in hostname check when the server asks for a client certificate", async () => { - using server = startSniServer({ requestCert: true }); - const url = `wss://127.0.0.1:${await server.port}/`; - const calls: string[] = []; - const ws = new WebSocket(url, { - tls: { - ca: tlsCerts.cert, - cert: tlsCerts.cert, - key: tlsCerts.key, - serverName: "evil.test", - checkServerIdentity(hostname: string) { - calls.push(hostname); - return undefined; - }, - }, - }); - expect(await openSession(ws)).toEqual(opened); - expect(calls).toEqual(["evil.test"]); - expect(server.clientCertificates).toEqual([expectedFingerprint256]); - }); - - // The verdict is read as tls.connect() reads it: any truthy value rejects. An async - // callback returns a Promise, which is never an Error, so it must not pass for approval. - test.each([ - ["a Promise, from an async callback", async () => new Error("PIN-REJECT")], - ["a string", () => "pin mismatch"], - ] as const)("rejects the connection when it returns %s", async (_label, checkServerIdentity) => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - const ws = new WebSocket(url, { - headers: { Authorization: "Bearer secret" }, - tls: { ca: tlsCerts.cert, checkServerIdentity: checkServerIdentity as never }, - }); - expect(await openSession(ws)).toEqual(tlsFailed(url)); - expect(await server.receivedInTotal()).toBe(""); - }); - - test.each([ - ["undefined", undefined], - ["false", false], - ] as const)("approves when it returns %s, as tls.connect() does", async (_label, verdict) => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - let calls = 0; - const checkServerIdentity = () => (calls++, verdict); - const ws = new WebSocket(url, { tls: { ca: tlsCerts.cert, checkServerIdentity: checkServerIdentity as never } }); - expect(await openSession(ws)).toEqual(opened); - expect(calls).toBe(1); - }); - - test.each([ - ["a string", "pin"], - ["an object", {}], - ] as const)("throws when it is %s, like fetch", (_label, value) => { - let error: unknown; - try { - new WebSocket("wss://localhost:1/", { tls: { checkServerIdentity: value as never } }); - } catch (e) { - error = e; - } - expect(error).toMatchObject({ - code: "ERR_INVALID_ARG_TYPE", - message: expect.stringContaining('The "tls.checkServerIdentity" property must be of type function'), - }); - }); - - test("is ignored when it is null, and the built-in check applies", async () => { - using server = startSniServer(); - const url = `wss://127.0.0.1:${await server.port}/`; - const ws = new WebSocket(url, { - tls: { ca: tlsCerts.cert, serverName: "evil.test", checkServerIdentity: null as never }, - }); - expect(await openSession(ws)).toEqual(tlsFailed(url)); - }); - - test("is found on the prototype of the tls object, like the other tls options", async () => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - let calls = 0; - const tlsOptions = Object.create({ - checkServerIdentity() { - calls++; - return new Error("PIN-REJECT"); - }, - }); - tlsOptions.ca = tlsCerts.cert; - const ws = new WebSocket(url, { tls: tlsOptions }); - expect(await openSession(ws)).toEqual(tlsFailed(url)); - expect(calls).toBe(1); - }); - - describe("with rejectUnauthorized: false, like fetch", () => { - test("runs only on a chain that verified, and its verdict is not enforced", async () => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - const calls: string[] = []; - // Without `ca` the chain fails: the harness certificate is self-signed. - for (const [label, ca] of [ - ["verified", tlsCerts.cert], - ["unverified", undefined], - ] as const) { - const ws = new WebSocket(url, { - tls: { - ca, - rejectUnauthorized: false, - checkServerIdentity(hostname: string) { - calls.push(`${label}: ${hostname}`); - return new Error("PIN-REJECT"); - }, - }, - }); - expect(await openSession(ws)).toEqual(opened); - } - expect(calls).toEqual(["verified: localhost"]); - }); - - test("does the same for the target inside a proxy tunnel", async () => { - using server = startSniServer(); - using proxy = await startRecordingProxy(); - const url = `wss://localhost:${await server.port}/`; - const calls: string[] = []; - const ws = new WebSocket(url, { - proxy: `http://127.0.0.1:${proxy.port}`, - tls: { - ca: tlsCerts.cert, - rejectUnauthorized: false, - checkServerIdentity(hostname: string) { - calls.push(hostname); - return new Error("PIN-REJECT"); - }, - }, - }); - expect(await openSession(ws)).toEqual(opened); - expect(calls).toEqual(["localhost"]); - expect(proxy.requests).toHaveLength(1); - }); - }); - - // On a renegotiation BoringSSL requires the same certificate, so the verdict - // of the callback still holds and the callback does not run again. - (nodeExe() ? test : test.skip).each([ - ["a name the certificate does not have", { serverName: "evil.test" }, "evil.test"], - ["an IP URL, which has no SNI", {}, "127.0.0.1"], - ] as const)("a certificate it approved survives a TLS 1.2 renegotiation: %s", async (_label, names, hostname) => { - await using server = await startRenegotiatingWssServer("after the 101"); - - const calls: string[] = []; - const ws = new WebSocket(`wss://127.0.0.1:${server.port}/`, { - tls: { - ca: tlsCerts.cert, - ...names, - checkServerIdentity(name: string) { - calls.push(name); - return undefined; - }, - }, - }); - ws.addEventListener("open", () => ws.send("ready")); - ws.addEventListener("message", () => ws.close(1000)); - expect(await clientEvents(ws)).toEqual(["after renegotiation", ...opened]); - expect(calls).toEqual([hostname]); - }); - - test("is called for the target certificate through an HTTP proxy", async () => { - using server = startSniServer(); - using proxy = await startRecordingProxy(); - const url = `wss://localhost:${await server.port}/`; - const calls: string[] = []; - const ws = new WebSocket(url, { - proxy: `http://127.0.0.1:${proxy.port}`, - tls: { - ca: tlsCerts.cert, - checkServerIdentity(hostname: string) { - calls.push(hostname); - return new Error("PIN-REJECT"); - }, - }, - }); - expect(await openSession(ws)).toEqual(tlsFailed(url)); - expect(calls).toEqual(["localhost"]); - expect(proxy.requests).toHaveLength(1); - expect(await server.receivedInTotal()).toBe(""); - }); - - test("is not called for the certificate of an HTTPS proxy", async () => { - using server = startSniServer(); - using proxy = await startRecordingProxy({ tls: true }); - const url = `wss://localhost:${await server.port}/`; - const calls: string[] = []; - // The proxy presents the harness certificate too. The built-in check - // verifies it against 127.0.0.1; the callback sees only the target. - const ws = new WebSocket(url, { - proxy: `https://127.0.0.1:${proxy.port}`, - tls: { - ca: tlsCerts.cert, - checkServerIdentity(hostname: string) { - calls.push(hostname); - return undefined; - }, - }, - }); - expect(await openSession(ws)).toEqual(opened); - expect(calls).toEqual(["localhost"]); - expect(proxy.requests).toHaveLength(1); - }); - - // A call into a VM that is stopping is a no-op that returns undefined, which is also how the callback approves. - test("does not approve when the handshake ends in a worker that terminate() has stopped", async () => { - using server = startSniServer(); - const serverPort = await server.port; - // Holds the server's first flight, so that the test decides when the handshake can end. - const flight = Promise.withResolvers<() => Promise>(); - const relay = net.createServer(client => { - const upstream = net.connect(serverPort, "127.0.0.1"); - client.on("error", () => {}); - client.on("close", () => upstream.destroy()); - upstream.on("error", () => {}); - client.pipe(upstream); - upstream.once("data", bytes => - flight.resolve(() => new Promise(sent => client.write(bytes, () => (upstream.pipe(client), sent())))), - ); - }); - const other = Promise.withResolvers(); - const otherServer = net.createServer(other.resolve); - relay.listen(0, "127.0.0.1"); - otherServer.listen(0, "127.0.0.1"); - await Promise.all([once(relay, "listening"), once(otherServer, "listening")]); - - const [STAGE, BOTH_READABLE, STOPPED] = [0, 1, 2]; - const flags = new Int32Array(new SharedArrayBuffer(12)); - const stage = async (value: number) => { - while (Atomics.load(flags, STAGE) !== value) await new Promise(resolve => setImmediate(resolve)); - }; - const raise = (flag: number) => (Atomics.store(flags, flag, 1), Atomics.notify(flags, flag)); - const worker = new Worker( - ` - const { parentPort, workerData } = require("node:worker_threads"); - const { url, ca, otherPort, flags } = workerData; - globalThis.ws = new WebSocket(url, { - headers: { Authorization: "Bearer secret" }, - tls: { ca, checkServerIdentity: () => new Error("PIN-REJECT") }, - }); - Bun.connect({ - hostname: "127.0.0.1", - port: otherPort, - socket: { - data() { - Atomics.store(flags, ${STAGE}, 2); - Atomics.wait(flags, ${STOPPED}, 0); - }, - }, - }); - parentPort.on("message", () => { - Atomics.store(flags, ${STAGE}, 1); - Atomics.wait(flags, ${BOTH_READABLE}, 0); - }); - `, - { - eval: true, - workerData: { - url: `wss://127.0.0.1:${(relay.address() as AddressInfo).port}/`, - ca: tlsCerts.cert, - otherPort: (otherServer.address() as AddressInfo).port, - flags, - }, - }, - ); - try { - const [deliverFlight, otherSocket] = await Promise.all([flight.promise, other.promise]); - // While the worker's thread is blocked, both of its sockets become readable, the other one first. - worker.postMessage("block"); - await stage(1); - await new Promise(sent => otherSocket.write("x", sent)); - await deliverFlight(); - raise(BOTH_READABLE); - // One pass of the worker's event loop now reads both. terminate() lands between the two. - await stage(2); - const terminated = worker.terminate(); - raise(STOPPED); - expect(await server.receivedInTotal()).toBe(""); - await terminated; - } finally { - raise(BOTH_READABLE); - raise(STOPPED); - await worker.terminate(); - relay.close(); - otherServer.close(); - } - }); - - // The `ws` package passes its `tls` object to the native client as it is. - test("applies through the ws package, with tls.serverName", async () => { - using server = startSniServer(); - const calls: string[] = []; - const ws = new WsPackageWebSocket(`wss://127.0.0.1:${await server.port}/`, { - tls: { - ca: tlsCerts.cert, - serverName: "localhost", - checkServerIdentity(hostname: string) { - calls.push(hostname); - return new Error("PIN-REJECT"); - }, - }, - }); - // The first event decides. A close with no error before it must fail the assertion, not hang. - const outcome = await new Promise(resolve => { - ws.on("open", () => resolve("open")); - ws.on("error", () => resolve("error")); - ws.on("close", code => resolve(`close ${code}`)); - }); - expect({ outcome, calls, sni: server.sni }).toEqual({ outcome: "error", calls: ["localhost"], sni: ["localhost"] }); - expect(await server.receivedInTotal()).toBe(""); - }); -}); - -// Not concurrent: these count every live WebSocket in the process, or force a full GC. -describe("WebSocket tls.checkServerIdentity lifetime", () => { - test("survives a GC before the handshake ends when only the WebSocket refers to it", async () => { - const release = Promise.withResolvers(); - using server = startSniServer({ hold: release.promise }); - const url = `wss://127.0.0.1:${await server.port}/`; - const calls: string[] = []; - // Nothing in this scope refers to the callback or to the tls object. The built-in check - // rejects evil.test, so the connection opens only if the callback is still there to run. - const ws = (() => - new WebSocket(url, { - tls: { - ca: tlsCerts.cert, - serverName: "evil.test", - checkServerIdentity: (hostname: string) => void calls.push(hostname), - }, - }))(); - const session = openSession(ws); - // A connection that ends before its ClientHello fails the assertion below and does not hang here. - await Promise.race([server.clientHello, session]); - Bun.gc(true); - release.resolve(); - expect(await session).toEqual(opened); - expect(calls).toEqual(["evil.test"]); - }); - - test("does not keep a closed WebSocket alive when the callback captures it", async () => { - using server = startSniServer(); - const url = `wss://localhost:${await server.port}/`; - const liveWebSockets = () => { - Bun.gc(true); - return heapStats().objectTypeCounts.WebSocket || 0; - }; - const before = liveWebSockets(); - const total = 16; - let checks = 0; - await Promise.all( - Array.from({ length: total }, async () => { - // The cycle: the WebSocket holds the callback, and the callback holds the WebSocket. - const ws: WebSocket = new WebSocket(url, { - tls: { - ca: tlsCerts.cert, - checkServerIdentity() { - if (ws.readyState === WebSocket.CONNECTING) checks++; - return undefined; - }, - }, - }); - expect(await openSession(ws)).toEqual(opened); - }), - ); - expect(checks).toBe(total); - // The WebSocket drops its pending activity in a task after the close event. - let leaked = liveWebSockets() - before; - for (let i = 0; i < 10 && leaked > 2; i++) { - await new Promise(resolve => setImmediate(resolve)); - leaked = liveWebSockets() - before; - } - expect(leaked).toBeLessThanOrEqual(2); - }); -}); From fcfe22d68df5d868551ea13d5ed796ed928993f9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:05:28 +0000 Subject: [PATCH 103/174] tls: a config that took tls.DEFAULT_CIPHERS needs its own context (#41810) apply_default_ciphers filled ssl_ciphers, and raised the minimum version for a list of TLS 1.3 suites only, without setting requires_custom_request_ctx. fetch and WebSocket look at that flag alone, so tls: { rejectUnauthorized: false } kept the default context and its ciphers while tls: { ca } followed the assignment. The fallback sets the flag, like an explicit `ciphers`. It is applied once `any` is decided, so tls: {} still means no TLS configuration. While nothing is assigned it returns before it writes anything. --- src/runtime/socket/SSLConfig.rs | 13 ++++-- test/js/node/tls/node-tls-server.test.ts | 59 +++++++++++++++++++++++- test/js/node/tls/ssl-ctx-cache.test.ts | 24 ++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index f438bc6c70e8..26815d6748d5 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -264,8 +264,6 @@ impl SSLConfigFromJs for SSLConfig { result.ssl_ciphers = zbox_into_raw(&ciphers.to_owned_slice_z()); result.is_using_default_ciphers = false; result.requires_custom_request_ctx = true; - } else { - apply_default_ciphers(vm, &mut result); } result.client_renegotiation_limit = generated.client_renegotiation_limit; @@ -275,8 +273,14 @@ impl SSLConfigFromJs for SSLConfig { || result.client_renegotiation_limit != 0 || generated.client_renegotiation_window != 0; - // We don't need to deinit `result` if `any` is false. - if any { Ok(Some(result)) } else { Ok(None) } + if !any { + return Ok(None); + } + // After `any`: the fallback does not make `tls: {}` a TLS configuration. + if generated.ciphers.as_ref().is_none() { + apply_default_ciphers(vm, &mut result); + } + Ok(Some(result)) } } @@ -295,6 +299,7 @@ fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { return; }; cfg.ssl_ciphers = dupe_z(ciphers); + cfg.requires_custom_request_ctx = true; // An empty TLS 1.2 list would leave BoringSSL's built-in one in effect. if ciphers.is_empty() { cfg.ssl_min_version = cfg diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index d40b250aa49d..d043e2dcd771 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1,7 +1,7 @@ import cluster from "cluster"; import crypto from "crypto"; import { readFileSync, realpathSync } from "fs"; -import { bunEnv, bunExe, tls as cert1, isASAN, isDebug, isWindows } from "harness"; +import { bunEnv, bunExe, tls as cert1, isASAN, isDebug, isWindows, tempDir } from "harness"; import http from "http"; import http2 from "http2"; import https from "https"; @@ -4077,6 +4077,63 @@ it("tls.DEFAULT_CIPHERS applies to every context built without a ciphers option" expect(exitCode).toBe(0); }); +it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", async () => { + using dir = tempDir("tls-default-ciphers", { "ca.pem": cert1.cert }); + const script = ` + import tls from "node:tls"; + import { once } from "node:events"; + const cert = ${JSON.stringify({ key: cert1.key, cert: cert1.cert })}; + const AES128 = "ECDHE-RSA-AES128-GCM-SHA256", AES256 = "ECDHE-RSA-AES256-GCM-SHA384"; + + // Prefers AES128, as every client does until it is told otherwise. + let seen; + const server = tls.createServer({ ...cert, maxVersion: "TLSv1.2", ciphers: AES128 + ":" + AES256 }, socket => { + seen.resolve(socket.getCipher().name); + socket.on("error", () => {}); + socket.once("data", () => socket.end("HTTP/1.1 200 OK\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n")); + }); + server.on("tlsClientError", error => seen.resolve(error.code)); + await once(server.listen(0, "127.0.0.1"), "listening"); + const { port } = server.address(); + const url = "https://localhost:" + port + "/"; + + const clients = { + "fetch, rejectUnauthorized": () => fetch(url, { keepalive: false, tls: { rejectUnauthorized: false } }), + "fetch, ca": () => fetch(url, { keepalive: false, tls: { ca: cert.cert } }), + "WebSocket, rejectUnauthorized": () => void new WebSocket(url.replace("https", "wss"), { tls: { rejectUnauthorized: false } }), + "Bun.connect, tls: true": () => Bun.connect({ hostname: "localhost", port, tls: true, socket: { data() {}, error() {} } }), + }; + const results = {}; + for (const list of [undefined, AES256, "TLS_AES_128_GCM_SHA256"]) { + if (list) tls.DEFAULT_CIPHERS = list; + for (const [name, connect] of Object.entries(clients)) { + seen = Promise.withResolvers(); + Promise.resolve(connect()).catch(() => {}); + (results[name] ??= []).push(await seen.promise); + } + } + console.log(JSON.stringify(results)); + process.exit(0); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(String(dir), "ca.pem") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // Nothing assigned, a TLS 1.2 suite, TLS 1.3 suites only (which this TLS 1.2 server cannot serve). + const row = ["ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-RSA-AES256-GCM-SHA384", "ERR_SSL_UNSUPPORTED_PROTOCOL"]; + expect(JSON.parse(stdout)).toEqual({ + "fetch, rejectUnauthorized": row, + "fetch, ca": row, + "WebSocket, rejectUnauthorized": row, + "Bun.connect, tls: true": row, + }); + expect(exitCode).toBe(0); +}); + // https://github.com/oven-sh/bun/issues/33954 it.each([ [{ requestCert: true }, "agent1"], diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index 8b8e2f14be02..76a28cfed310 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -549,3 +549,27 @@ test("a connection accepted before close() keeps working after it", async () => expect(echoed.toString()).toBe("ping"); client.destroy(); }); + +test("fetch takes a context of its own per tls.DEFAULT_CIPHERS, and none while nothing is assigned", async () => { + const script = ` + import tls from "node:tls"; + import { sslCtxLiveCount } from "bun:internal-for-testing"; + using server = Bun.serve({ port: 0, tls: ${JSON.stringify(tlsCerts)}, fetch: () => new Response("ok") }); + const request = () => fetch(server.url, { keepalive: false, tls: { rejectUnauthorized: false } }).then(res => res.text()); + await request(); // the server's context and the default one of fetch + const created = []; + for (const list of [undefined, undefined, "ECDHE-RSA-AES256-GCM-SHA384", undefined, "ECDHE-RSA-AES128-GCM-SHA256", undefined]) { + if (list) tls.DEFAULT_CIPHERS = list; + const before = sslCtxLiveCount(); + await request(); + created.push(sslCtxLiveCount() - before); + } + console.log(JSON.stringify(created)); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // Nothing assigned x2, a first list and the same again, a second list and the same again. + expect(JSON.parse(stdout)).toEqual([0, 0, 1, 0, 1, 0]); + expect(exitCode).toBe(0); +}); From cfc40b25db9bd95bcf732d6870417cae236a25bf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:05:29 +0000 Subject: [PATCH 104/174] fetch: follow tls.DEFAULT_CIPHERS without a tls option (#41810) The default context of fetch is built on the HTTP thread from fixed options, so a fetch with no `tls`, or with tls: {}, ignored an assignment that tls.connect, https.get and, in Node, fetch itself follow. Such a request now takes the config of `tls: true`. One Option check while nothing is assigned. --- src/runtime/webcore/fetch.rs | 5 +++++ test/js/node/tls/node-tls-server.test.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 7ca2d4a9fb93..b54fa40606ce 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -759,6 +759,11 @@ fn fetch_impl( reject_unauthorized = session.reject_unauthorized().unwrap_or(reject_unauthorized); session_check_server_identity = session.check_server_identity().is_some(); } + // The default context is built on the HTTP thread, which does not see `tls.DEFAULT_CIPHERS`. + if ssl_config.is_none() && vm.tls_default_ciphers().is_some() { + let defaults = crate::socket::tls_true_defaults(vm, false); + ssl_config = Some(http::ssl_config::global_registry::intern(defaults)); + } // unix: string | undefined unix_socket_path = 'extract_unix_socket_path: { diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index d043e2dcd771..1b9b18f2d49d 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4098,6 +4098,8 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as const url = "https://localhost:" + port + "/"; const clients = { + "fetch": () => fetch(url, { keepalive: false }), + "fetch, tls: {}": () => fetch(url, { keepalive: false, tls: {} }), "fetch, rejectUnauthorized": () => fetch(url, { keepalive: false, tls: { rejectUnauthorized: false } }), "fetch, ca": () => fetch(url, { keepalive: false, tls: { ca: cert.cert } }), "WebSocket, rejectUnauthorized": () => void new WebSocket(url.replace("https", "wss"), { tls: { rejectUnauthorized: false } }), @@ -4126,6 +4128,8 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as // Nothing assigned, a TLS 1.2 suite, TLS 1.3 suites only (which this TLS 1.2 server cannot serve). const row = ["ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-RSA-AES256-GCM-SHA384", "ERR_SSL_UNSUPPORTED_PROTOCOL"]; expect(JSON.parse(stdout)).toEqual({ + "fetch": row, + "fetch, tls: {}": row, "fetch, rejectUnauthorized": row, "fetch, ca": row, "WebSocket, rejectUnauthorized": row, From 352506577afd1987593df219ff751aa80ff45e31 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:07:35 +0000 Subject: [PATCH 105/174] tls: several identities send their intermediates again (#33534) A context with more than one key or certificate serves explicit SSL_CREDENTIALs. BoringSSL's handshake-time auto-chaining only completes the legacy slot, so a leaf-only `cert` no longer picked its intermediates up from `ca`: `{ key: [rsa, ec], cert: [rsaLeaf, ecLeaf], ca: [intermediate] }` sent the leaf alone and clients failed with UNABLE_TO_VERIFY_LEAF_SIGNATURE. A pfx array, or a pfx next to key/cert, lost the archive's intermediates the same way. The identities are now loaded after the context's store is filled, and each leaf-only one gets the chain auto-chaining would have built from that store before it is copied into its credential. An identity that came with intermediates is left alone, and with no `ca` the store is the shared default one, as it is for the handshake-time walk, so the bytes sent are those of a single identity. createSecureContext() adds a pfx's CAs after the context is built, too late for that, so with several identities each archive's certificates travel with its leaf, which is what Node's loadPKCS12 does. Not covered: context.addCACert() called by hand on a context that holds several identities does not extend their chains. test-tls-multi-key.js is upstream's again: its two PKCS#12 cases failed because of this. --- packages/bun-usockets/src/crypto/openssl.c | 50 +++++++--- src/js/internal/tls.ts | 6 +- .../node/test/parallel/test-tls-multi-key.js | 9 +- test/js/node/tls/node-tls-server.test.ts | 96 ++++++++++++++++++- 4 files changed, 136 insertions(+), 25 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 1f64e90a9907..883a824e3ec1 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1055,6 +1055,25 @@ static int us_ssl_ctx_use_certificate_chain(SSL_CTX *ctx, const char *content) { return ret; } +/* BoringSSL's ssl_auto_chain_if_needed, which at handshake time completes the legacy slot but no credential. */ +static int us_ssl_ctx_auto_chain(SSL_CTX *ctx) { + if (sk_CRYPTO_BUFFER_num(SSL_CTX_get0_chain(ctx)) != 1) return 1; + X509_STORE_CTX *walk = X509_STORE_CTX_new(); + if (walk == NULL || !X509_STORE_CTX_init(walk, SSL_CTX_get_cert_store(ctx), SSL_CTX_get0_certificate(ctx), NULL)) { + X509_STORE_CTX_free(walk); + return 0; + } + X509_verify_cert(walk); + ERR_clear_error(); + STACK_OF(X509) *chain = X509_STORE_CTX_get1_chain(walk); + X509_STORE_CTX_free(walk); + if (chain == NULL) return 0; + X509_free(sk_X509_shift(chain)); + int ok = SSL_CTX_set1_chain(ctx, chain); + sk_X509_pop_free(chain, X509_free); + return ok; +} + static SSL_CREDENTIAL *us_ssl_ctx_copy_legacy_identity(SSL_CTX *ctx) { const STACK_OF(CRYPTO_BUFFER) *chain = SSL_CTX_get0_chain(ctx); size_t count = sk_CRYPTO_BUFFER_num(chain); @@ -1101,7 +1120,7 @@ static int us_ssl_ctx_use_identities(SSL_CTX *ctx, const char *const *cert, unsi if (ok) ERR_clear_error(); } if (ok && has_key) { - ok = (identity[type] = us_ssl_ctx_copy_legacy_identity(ctx)) != NULL; + ok = us_ssl_ctx_auto_chain(ctx) && (identity[type] = us_ssl_ctx_copy_legacy_identity(ctx)) != NULL; } else if (ok && mismatched) { OPENSSL_PUT_ERROR(X509, X509_R_KEY_VALUES_MISMATCH); ok = 0; @@ -1289,13 +1308,9 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, * (see passphrase_cb), matching Node's key-decryption error shape. */ SSL_CTX_set_default_passwd_cb(ssl_context, passphrase_cb); - if (!options.cert_file_name && !options.key_file_name && options.cert && options.key && - (options.cert_count > 1 || options.key_count > 1)) { - if (!us_ssl_ctx_use_identities(ssl_context, options.cert, options.cert_count, options.key, options.key_count)) { - ssl_ctx_build_fail(ssl_context); - return NULL; - } - } else { + int several_identities = !options.cert_file_name && !options.key_file_name && options.cert && options.key && + (options.cert_count > 1 || options.key_count > 1); + if (!several_identities) { if (options.cert_file_name) { if (SSL_CTX_use_certificate_chain_file(ssl_context, options.cert_file_name) != 1) { ssl_ctx_build_fail(ssl_context); @@ -1324,10 +1339,6 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, } } } - /* passwd_cb is only consulted by SSL_CTX_use_PrivateKey* above; the secret - * is dead now. Dropping it here means SSL_CTX_free() is sufficient cleanup - * everywhere downstream — no special "owner" path. */ - ssl_ctx_drop_passphrase(ssl_context); if (options.ca_file_name) { /* An explicit CA replaces the default trust store (Node.js semantics): @@ -1376,6 +1387,17 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store()); } + /* After the store is filled: it completes their chains. */ + if (several_identities && + !us_ssl_ctx_use_identities(ssl_context, options.cert, options.cert_count, options.key, options.key_count)) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + /* passwd_cb is only consulted by SSL_CTX_use_PrivateKey* above; the secret + * is dead now. Dropping it here means SSL_CTX_free() is sufficient cleanup + * everywhere downstream — no special "owner" path. */ + ssl_ctx_drop_passphrase(ssl_context); + /* The callback is set at SSL_VERIFY_NONE too: lsquic's SNI switch calls SSL_set_verify(ssl, mode, NULL), which keeps it. */ SSL_CTX_set_verify(ssl_context, !options.request_cert ? SSL_VERIFY_NONE @@ -1530,8 +1552,8 @@ int us_ssl_ctx_add_ca_cert(SSL_CTX *ctx, const char *content) { return 0; } /* A CA added after the context was built (pfx extras, addCACert) lands in - * the store the handshake-time auto-chain walks, so a leaf-only cert picks - * the intermediate up with no eager re-walk. */ + * the store the handshake-time auto-chain walks, so a lone leaf-only cert + * picks the intermediate up with no eager re-walk. */ return add_ca_cert_to_ctx_store(ctx, content, store); } diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index 26e92bef7b92..0146171f7150 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -286,6 +286,8 @@ function processPfxOptions(options) { const certs = out.cert == null ? [] : Array.isArray(out.cert) ? [...out.cert] : [out.cert]; const pfxCAs: string[] = []; const entries = Array.isArray(out.pfx) ? out.pfx : [out.pfx]; + // CAs added after the context is built complete the chain of a lone identity only; several carry theirs, as in Node. + const several = entries.length + Math.max(keys.length, certs.length) > 1; for (const entry of entries) { let buf = entry; let passphrase = out.passphrase; @@ -297,13 +299,13 @@ function processPfxOptions(options) { } } const parsed = NativeSecureContext.parsePkcs12(buf, passphrase); + const parsedCA = parsed.ca; keys.push(parsed.key); - certs.push(parsed.cert); + certs.push(several && parsedCA ? parsed.cert + parsedCA : parsed.cert); // A CA bundled inside the PKCS#12 EXTENDS the trust set (Node loads it // via addCACert on top of the default roots); folding it into the `ca` // option would instead REPLACE the trust store and break verification // against the default/NODE_EXTRA_CA_CERTS roots for pfx-only clients. - const parsedCA = parsed.ca; if (parsedCA) pfxCAs.push(parsedCA); } out.key = keys.length === 1 ? keys[0] : keys; diff --git a/test/js/node/test/parallel/test-tls-multi-key.js b/test/js/node/test/parallel/test-tls-multi-key.js index 77c72b897d77..89f9931e5bdd 100644 --- a/test/js/node/test/parallel/test-tls-multi-key.js +++ b/test/js/node/test/parallel/test-tls-multi-key.js @@ -101,7 +101,7 @@ test({ // Key and cert with mixed algorithms, and cert chains with intermediate CAs, // using PFX for EC. -testPfxChain({ +test({ key: [ fixtures.readKey('agent10-key.pem'), ], @@ -120,7 +120,7 @@ testPfxChain({ // Key and cert with mixed algorithms, and cert chains with intermediate CAs, // using PFX for RSA. -testPfxChain({ +test({ key: [ fixtures.readKey('ec10-key.pem'), ], @@ -137,11 +137,6 @@ testPfxChain({ ] }, }); -// Bun trusts the extra certificates of a PKCS#12 archive but does not send them as the chain. -function testPfxChain(options) { - if (!process.versions.bun) test(options); -} - function test(options) { const rsaCN = options.rsaCN || 'agent1'; const eccCN = options.eccCN || 'agent2'; diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 1b9b18f2d49d..f729240dcb12 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -3609,8 +3609,7 @@ describe("key/cert arrays", () => { readFileSync(join(import.meta.dir, "../test/fixtures/keys/ec.pfx")), ], }, - // Bun does not send the extra certificates of a PKCS#12 archive as the chain; Node v26.3.0 serves "agent1/2". - each("agent2/1", process.versions.bun ? "agent1/1" : "agent1/2"), + each("agent2/1", "agent1/2"), ], ] as const)("every identity is served to the clients that can use it: %s", async (_, options, expected) => { using server = await listen(createServer(options as tls.TlsOptions, socket => socket.on("error", () => {}))); @@ -3726,6 +3725,99 @@ describe("key/cert arrays", () => { client.destroy(); } }); + + describe("intermediates", () => { + const certificates = (name: string) => + read(name).match(/-----BEGIN CERTIFICATE-----[^]*?-----END CERTIFICATE-----\n/g)!; + const [rsaLeaf, ca4] = certificates("agent10-cert.pem"); + const [ecLeaf, ca6] = certificates("ec10-cert.pem"); + const key = [read("agent10-key.pem"), ec.key]; + const roots = [read("ca2-cert.pem"), read("ca5-cert.pem")]; + const pfx = ["agent10.pfx", "ec10.pfx"].map(name => ({ + buf: readFileSync(join(import.meta.dir, "../test/fixtures/keys", name)), + passphrase: "sample", + })); + + /** The chain the server sent, then what a client that trusts only the roots makes of it. */ + async function chain(port: number, options: tls.ConnectionOptions) { + const results: string[] = []; + for (const ca of [undefined, roots]) { + const { promise, resolve } = Promise.withResolvers(); + const checkServerIdentity = () => undefined; + const base = { port, host: "127.0.0.1", rejectUnauthorized: false, checkServerIdentity }; + const socket = connect({ ...base, ...options, ca }, () => { + let c = socket.getPeerCertificate(true); + const names = [c.subject.CN]; + // agent10.pfx also holds ca1, which issued nothing here: it is sent, and only Bun lists it as an issuer. + for ( + ; + c.issuerCertificate?.subject.CN === c.issuer.CN && c.issuerCertificate !== c; + c = c.issuerCertificate + ) { + names.push(c.issuer.CN); + } + resolve(ca ? String(socket.authorizationError ?? "authorized") : names.join(" < ")); + socket.destroy(); + }); + socket.on("error", e => resolve((e as NodeJS.ErrnoException).code!)); + results.push(await promise); + } + return results.join(": "); + } + + const leafOnly = "agent10.example.com: UNABLE_TO_VERIFY_LEAF_SIGNATURE"; + it.each([ + ["in `ca`", { key, cert: [rsaLeaf, ecLeaf], ca: [ca4, ca6] }, "ca6: authorized", "ca4: authorized"], + [ + "in `ca`, up to the root", + { key, cert: [rsaLeaf, ecLeaf], ca: [ca4, ca6, ...roots] }, + "ca6 < ca5: authorized", + "ca4 < ca2: authorized", + ], + [ + "already in `cert`", + { key, cert: [rsaLeaf + ca4, ecLeaf + ca6], ca: [ca4, ca6, ...roots] }, + "ca6: authorized", + "ca4: authorized", + ], + ["in a pfx array", { pfx }, "ca6: authorized", "ca4: authorized"], + [ + "in a pfx next to key and cert", + { key: [ec.key], cert: [ecLeaf + ca6], pfx: pfx[0].buf, passphrase: "sample" }, + "ca6: authorized", + "ca4: authorized", + ], + [ + "in the pfx array of a secureContext", + { secureContext: tls.createSecureContext({ pfx }) }, + "ca6: authorized", + "ca4: authorized", + ], + ["nowhere", { key, cert: [rsaLeaf, ecLeaf] }, undefined, undefined], + ] as const)("%s", async (_, options, ecdsa, rsa) => { + using server = await listen( + "secureContext" in options + ? net.createServer( + raw => void new TLSSocket(raw, { isServer: true, ...options }).on("error", () => raw.destroy()), + ) + : createServer(options as tls.TlsOptions, socket => socket.on("error", () => {})), + ); + expect({ + "TLS 1.2, ECDSA only": await chain(server.port, { + maxVersion: "TLSv1.2", + ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256", + }), + "TLS 1.2, RSA only": await chain(server.port, { + maxVersion: "TLSv1.2", + ciphers: "ECDHE-RSA-AES128-GCM-SHA256", + }), + "TLS 1.3": await chain(server.port, {}), + "TLS 1.3, RSA only": await chain(server.port, { sigalgs: "rsa_pss_rsae_sha256" }), + }).toEqual( + each(ecdsa ? `agent10.example.com < ${ecdsa}` : leafOnly, rsa ? `agent10.example.com < ${rsa}` : leafOnly), + ); + }); + }); }); // close() keeps the connections it accepted, and each selects its context when its ClientHello arrives. From 2e29ff2c138e02b83670127db52c8682273be4b1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:56:56 +0000 Subject: [PATCH 106/174] ws, node-fetch: take TLS 1.3 suite names out of ciphers like node:tls nodeClientTlsToNative() forwarded the ciphers of an agent as written. BoringSSL's cipher list holds TLS 1.2 names only, so a list that names nothing but TLS 1.3 suites built no SSL_CTX and the connection failed, where it opened while the option was ignored. stripTls13CipherNames() moves from node:tls to internal/tls and the helper applies it the way SecureContext does: the TLS_ names are dropped, and when nothing is left minVersion becomes TLSv1.3. --- src/js/internal/tls.ts | 26 ++++++++ src/js/node/tls.ts | 18 +----- test/js/first_party/ws/ws-tls-options.test.ts | 59 ++++++++++++++++++- .../js/node/http/node-fetch-agent-tls.test.ts | 44 +++++++++++++- 4 files changed, 128 insertions(+), 19 deletions(-) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index 0146171f7150..4fedd909ce5f 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -2,6 +2,10 @@ const { isTypedArray, isArrayBuffer, isArrayBufferView } = require("node:util/ty const { validateString, validateBuffer } = require("internal/validators"); const StringPrototypeSplit = String.prototype.split; +const StringPrototypeStartsWith = String.prototype.startsWith; +const StringPrototypeIncludes = String.prototype.includes; +const ArrayPrototypeFilter = Array.prototype.filter; +const ArrayPrototypeJoin = Array.prototype.join; const ArrayPrototypeSome = Array.prototype.some; const ArrayPrototypeMap = Array.prototype.map; @@ -340,6 +344,22 @@ function normalizePemKeyOption(key, ctxPassphrase) { }); } +function tlsCipherFilter(a: string) { + return !StringPrototypeStartsWith.$call(a, "TLS_"); +} + +// Node's processCiphers splits into cipherList (<=1.2) and cipherSuites (1.3); +// when only 1.3 suites were given it forces minVersion = TLSv1.3 so the empty +// 1.2 list does not leave the handshake with nothing to offer: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L117 +function stripTls13CipherNames(ciphers: string): { cipherList: string; tls13Only: boolean } { + if (!StringPrototypeIncludes.$call(ciphers, "TLS_")) return { cipherList: ciphers, tls13Only: false }; + const parts = StringPrototypeSplit.$call(ciphers, ":"); + const kept = ArrayPrototypeFilter.$call(parts, tlsCipherFilter); + const cipherList = ArrayPrototypeJoin.$call(kept, ":"); + return { cipherList, tls13Only: cipherList === "" && kept.length !== parts.length }; +} + // The native `ca` replaces the default store, so the CAs of an archive can only extend a `ca` the caller gave. function unsealPfxForNative(tls) { if (tls.pfx == null) return tls; @@ -371,6 +391,11 @@ const nodeClientTlsKeys = [ // `options` of tls.connect(), as a null-prototype copy, to the `tls` option of fetch() and WebSocket. function nodeClientTlsToNative(options) { validateSecureContextOptions(options); + if (options.ciphers) { + const { cipherList, tls13Only } = stripTls13CipherNames(options.ciphers); + options.ciphers = cipherList; + if (tls13Only) options.minVersion = "TLSv1.3"; + } let tls; for (let i = 0; i < nodeClientTlsKeys.length; i++) { const name = nodeClientTlsKeys[i]; @@ -428,6 +453,7 @@ export { normalizePemKeyOption, processPfxOptions, secureProtocolToVersionRange, + stripTls13CipherNames, throwOnInvalidTLSArray, tlsHandshakeError, tlsStringToProtocolVersion, diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index c089e99afc2d..68d0ecec3cfa 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -14,6 +14,7 @@ const { secureProtocolToVersionRange, normalizePemKeyOption, processPfxOptions, + stripTls13CipherNames, validateSecureContextOptions, SSL_OP_CIPHER_SERVER_PREFERENCE, } = require("internal/tls"); @@ -223,7 +224,6 @@ const ArrayPrototypeForEach = Array.prototype.forEach; const ArrayPrototypePush = Array.prototype.push; const ArrayPrototypeSome = Array.prototype.some; const ArrayPrototypeReduce = Array.prototype.reduce; -const ArrayPrototypeFilter = Array.prototype.filter; const ArrayPrototypeSort = Array.prototype.sort; const ObjectFreeze = Object.freeze; @@ -1696,22 +1696,6 @@ function getCACertificates(type = "default") { } } -function tlsCipherFilter(a: string) { - return !StringPrototypeStartsWith.$call(a, "TLS_"); -} - -// Node's processCiphers splits into cipherList (<=1.2) and cipherSuites (1.3); -// when only 1.3 suites were given it forces minVersion = TLSv1.3 so the empty -// 1.2 list does not leave the handshake with nothing to offer: -// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L117 -function stripTls13CipherNames(ciphers: string): { cipherList: string; tls13Only: boolean } { - if (!StringPrototypeIncludes.$call(ciphers, "TLS_")) return { cipherList: ciphers, tls13Only: false }; - const parts = StringPrototypeSplit.$call(ciphers, ":"); - const kept = ArrayPrototypeFilter.$call(parts, tlsCipherFilter); - const cipherList = ArrayPrototypeJoin.$call(kept, ":"); - return { cipherList, tls13Only: cipherList === "" && kept.length !== parts.length }; -} - function getDefaultCiphers() { // TLS_ will always be present until SSL_CTX_set_cipher_list is supported see default_ciphers.h const ciphers = getTLSDefaultCiphers(); diff --git a/test/js/first_party/ws/ws-tls-options.test.ts b/test/js/first_party/ws/ws-tls-options.test.ts index 5bb6042c193a..69cf92a8fdb1 100644 --- a/test/js/first_party/ws/ws-tls-options.test.ts +++ b/test/js/first_party/ws/ws-tls-options.test.ts @@ -4,12 +4,13 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir, tls as serverIdentity } from "harness"; import { HttpsProxyAgent } from "https-proxy-agent"; import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; import { once } from "node:events"; import { readFileSync } from "node:fs"; import https from "node:https"; import net, { type AddressInfo } from "node:net"; import { join } from "node:path"; -import type { TLSSocket } from "node:tls"; +import tls, { type TLSSocket } from "node:tls"; import WebSocket, { WebSocketServer } from "ws"; const isBun = !!process.versions.bun; @@ -36,6 +37,33 @@ async function serve(options?: https.ServerOptions) { }; } +/** Tells each client the protocol version and the cipher of its connection. */ +async function serveNegotiated(options?: tls.TlsOptions) { + const server = tls.createServer({ ...serverIdentity, ...options }, socket => { + let head = ""; + socket.on("error", () => {}); + socket.on("data", chunk => { + head += chunk.toString("latin1"); + if (!head.includes("\r\n\r\n")) return; + const key = /sec-websocket-key: (.*)\r\n/i.exec(head)![1]; + const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); + const message = `${socket.getProtocol()} ${socket.getCipher().name}`; + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket.write(Buffer.concat([Buffer.from([0x81, message.length]), Buffer.from(message)])); + }); + }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + url: `wss://localhost:${(server.address() as AddressInfo).port}`, + [Symbol.dispose]() { + server.close(); + }, + }; +} + /** The server's message, or "refused". */ function dial(url: string, options: object) { const { promise, resolve } = Promise.withResolvers(); @@ -131,6 +159,35 @@ describe.concurrent("ws TLS options", () => { }).toEqual({ min13to12: "refused", max12to12: anonymous, max12to13: "refused", min13to13: anonymous }); }); + test("ciphers", async () => { + using server = await serveNegotiated(); + using tls12 = await serveNegotiated({ maxVersion: "TLSv1.2" }); + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + const version = (negotiated: string) => negotiated.split(" ")[0]; + expect({ + // BoringSSL has no list of TLS 1.3 suites to restrict, so only Node negotiates the one that is named. + only13: version(await dial(server.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384" })), + only13Agent: version( + await dial(server.url, { agent: new https.Agent({ ca, ciphers: "TLS_AES_256_GCM_SHA384" }) }), + ), + only13To12: await dial(tls12.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384" }), + only13Max12: await dial(server.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384", maxVersion: "TLSv1.2" }), + mixed: version(await dial(server.url, { ca, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` })), + mixedTo12: await dial(tls12.url, { ca, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), + only12: version(await dial(server.url, { ca, ciphers: aes256 })), + only12To12: await dial(tls12.url, { ca, ciphers: aes256 }), + }).toEqual({ + only13: "TLSv1.3", + only13Agent: "TLSv1.3", + only13To12: "refused", + only13Max12: "refused", + mixed: "TLSv1.3", + mixedTo12: `TLSv1.2 ${aes256}`, + only12: "TLSv1.3", + only12To12: `TLSv1.2 ${aes256}`, + }); + }); + test("an option that tls.connect() rejects throws", () => { const url = "wss://localhost:1"; // BoringSSL and OpenSSL word it differently. diff --git a/test/js/node/http/node-fetch-agent-tls.test.ts b/test/js/node/http/node-fetch-agent-tls.test.ts index 89e6adb2c28a..f5dc63fc4ede 100644 --- a/test/js/node/http/node-fetch-agent-tls.test.ts +++ b/test/js/node/http/node-fetch-agent-tls.test.ts @@ -9,7 +9,7 @@ import https from "node:https"; import type { AddressInfo } from "node:net"; import path from "node:path"; import { describe, test } from "node:test"; -import type { TLSSocket } from "node:tls"; +import tls, { type TLSSocket } from "node:tls"; const read = (name: string) => readFileSync(path.join(import.meta.dirname, "..", "test", "fixtures", "keys", name)); // ca1 signs agent1, ca2 signs agent3. No certificate has a SAN, so clients verify CN under `servername`. @@ -34,6 +34,26 @@ async function serve(options: https.ServerOptions = { key, cert }) { }; } +/** Answers each request with the protocol version and the cipher of its connection. */ +async function serveNegotiated(options?: tls.TlsOptions) { + const server = tls.createServer({ key, cert, ...options }, socket => { + socket.on("error", () => {}); + socket.once("data", () => { + const body = `${socket.getProtocol()} ${socket.getCipher().name}`; + socket.end(`HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n${body}`); + }); + }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + port: (server.address() as AddressInfo).port, + async [Symbol.asyncDispose]() { + server.close(); + await once(server, "close"); + }, + }; +} + type Init = { agent?: https.Agent | ((url: URL) => https.Agent); tls?: object }; /** The response body, or the code of the rejection. */ @@ -179,6 +199,28 @@ describe("node-fetch applies the TLS options of the agent", () => { } }); + test("ciphers that name TLS 1.3 suites", async () => { + await using server = await serveNegotiated(); + await using tls12 = await serveNegotiated({ maxVersion: "TLSv1.2" }); + const trust = { ca, servername }; + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + const refused = /EPROTO|PROTOCOL_VERSION|NO_CIPHERS_AVAILABLE|NO_PROTOCOLS_AVAILABLE/; + // BoringSSL has no list of TLS 1.3 suites to restrict, so only Node negotiates the one that is named. + assert.match(await outcome(server.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384" }), /^TLSv1\.3 /); + assert.match(await outcome(tls12.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384" }), refused); + assert.match( + await outcome(server.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384", maxVersion: "TLSv1.2" }), + refused, + ); + assert.match(await outcome(server.port, { ...trust, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), /^TLSv1\.3 /); + assert.strictEqual( + await outcome(tls12.port, { ...trust, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), + `TLSv1.2 ${aes256}`, + ); + assert.match(await outcome(server.port, { ...trust, ciphers: aes256 }), /^TLSv1\.3 /); + assert.strictEqual(await outcome(tls12.port, { ...trust, ciphers: aes256 }), `TLSv1.2 ${aes256}`); + }); + test("an agent that is a function of the request URL", async () => { await using server = await serve(); const agent = new https.Agent({ ca, servername }); From 05f7ebfef34999cf8e6b39d3e22e204d2e37061a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:57:36 +0000 Subject: [PATCH 107/174] ws, node-fetch: apply secureProtocol nodeClientTlsToNative() validated secureProtocol but did not forward it, so { secureProtocol: "TLSv1_2_method" } negotiated TLS 1.3. Translate it with secureProtocolToVersionRange(); it decides the range as in node:tls. --- src/js/internal/tls.ts | 6 +++++ test/js/first_party/ws/ws-tls-options.test.ts | 24 +++++++++++++++++++ .../js/node/http/node-fetch-agent-tls.test.ts | 16 +++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index 4fedd909ce5f..2560574c0b5f 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -412,6 +412,12 @@ function nodeClientTlsToNative(options) { } (tls ??= { __proto__: null })[name] = value; } + const range = secureProtocolToVersionRange(options.secureProtocol); + if (range) { + tls ??= { __proto__: null }; + tls.minVersion = range[0]; + tls.maxVersion = range[1]; + } return tls && unsealPfxForNative(tls); } diff --git a/test/js/first_party/ws/ws-tls-options.test.ts b/test/js/first_party/ws/ws-tls-options.test.ts index 69cf92a8fdb1..d8571d4af1b4 100644 --- a/test/js/first_party/ws/ws-tls-options.test.ts +++ b/test/js/first_party/ws/ws-tls-options.test.ts @@ -188,6 +188,30 @@ describe.concurrent("ws TLS options", () => { }); }); + test("secureProtocol", async () => { + using server = await serveNegotiated(); + const version = (negotiated: string) => negotiated.split(" ")[0]; + expect({ + tls12: version(await dial(server.url, { ca, secureProtocol: "TLSv1_2_method" })), + tls12Agent: version(await dial(server.url, { agent: new https.Agent({ ca, secureProtocol: "TLSv1_2_client_method" }) })), // prettier-ignore + tls11: await dial(server.url, { ca, secureProtocol: "TLSv1_1_method" }), + any: version(await dial(server.url, { ca, secureProtocol: "TLS_method" })), + }).toEqual({ tls12: "TLSv1.2", tls12Agent: "TLSv1.2", tls11: "refused", any: "TLSv1.3" }); + // There is no such method. + expect(() => new WebSocket(server.url, { agent: new https.Agent({ secureProtocol: "TLSv1_3_method" }) })).toThrow( + expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_METHOD" }), + ); + expect( + () => + new WebSocket(server.url, { + agent: new https.Agent({ secureProtocol: "TLSv1_2_method", minVersion: "TLSv1.3" }), + }), + ).toThrow( + // prettier-ignore + expect.objectContaining({ code: "ERR_TLS_PROTOCOL_VERSION_CONFLICT" }), + ); + }); + test("an option that tls.connect() rejects throws", () => { const url = "wss://localhost:1"; // BoringSSL and OpenSSL word it differently. diff --git a/test/js/node/http/node-fetch-agent-tls.test.ts b/test/js/node/http/node-fetch-agent-tls.test.ts index f5dc63fc4ede..e719fcfa9486 100644 --- a/test/js/node/http/node-fetch-agent-tls.test.ts +++ b/test/js/node/http/node-fetch-agent-tls.test.ts @@ -221,6 +221,22 @@ describe("node-fetch applies the TLS options of the agent", () => { assert.strictEqual(await outcome(tls12.port, { ...trust, ciphers: aes256 }), `TLSv1.2 ${aes256}`); }); + test("secureProtocol", async () => { + await using server = await serveNegotiated(); + const trust = { ca, servername }; + assert.match(await outcome(server.port, { ...trust, secureProtocol: "TLSv1_2_method" }), /^TLSv1\.2 /); + assert.match(await outcome(server.port, { ...trust, secureProtocol: "TLS_method" }), /^TLSv1\.3 /); + // There is no such method. + assert.strictEqual( + await outcome(server.port, { ...trust, secureProtocol: "TLSv1_3_method" }), + "ERR_TLS_INVALID_PROTOCOL_METHOD", + ); + assert.strictEqual( + await outcome(server.port, { ...trust, secureProtocol: "TLSv1_2_method", minVersion: "TLSv1.3" }), + "ERR_TLS_PROTOCOL_VERSION_CONFLICT", + ); + }); + test("an agent that is a function of the request URL", async () => { await using server = await serve(); const agent = new https.Agent({ ca, servername }); From 91030735747d95728707aa583e186f0efd97450c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:59:58 +0000 Subject: [PATCH 108/174] node:https: keep listening with the options Node takes and Bun ignored Since the https server runs validateSecureContextOptions(), options that Node accepts and Bun used to ignore made createServer() throw: dhparam: "auto", which Node's documentation recommends, and the falsy clientCertEngine, ciphers, sessionTimeout, ticketKeys and crl that tls.Server#setSecureContext() drops. The https path treats a falsy value as absent like Node and goes on ignoring dhparam, which BoringSSL has no DHE suites to apply to. So does the translation for ws and node-fetch. tls.createServer() and createSecureContext() are unchanged. What still throws and did not: values Node rejects too, and an ecdhCurve that BoringSSL cannot apply as written ("X448:prime256v1", ""), as in tls.createServer(). --- src/js/internal/tls.ts | 2 ++ src/js/node/_http_server.ts | 16 +++++++--- test/js/first_party/ws/ws-tls-options.test.ts | 10 ++++++- .../js/node/http/node-fetch-agent-tls.test.ts | 1 + test/js/node/http/node-https-server.test.ts | 30 +++++++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index 2560574c0b5f..cb5f0557081d 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -390,6 +390,8 @@ const nodeClientTlsKeys = [ // `options` of tls.connect(), as a null-prototype copy, to the `tls` option of fetch() and WebSocket. function nodeClientTlsToNative(options) { + // BoringSSL has no DHE suites to apply it to. + options.dhparam = undefined; validateSecureContextOptions(options); if (options.ciphers) { const { cipherList, tls13Only } = stripTls13CipherNames(options.ciphers); diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 1be83418aa6c..389e4a81ce4c 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -361,7 +361,7 @@ function Server(options, callback): void { tlsHelpers.throwOnInvalidTLSArray("options.ca", ca); } - let passphrase = options.passphrase; + let passphrase = options.passphrase || undefined; if (passphrase && typeof passphrase !== "string") { throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); } @@ -378,17 +378,25 @@ function Server(options, callback): void { if (tlsHelpers) { const { secureProtocolToVersionRange, tlsStringToProtocolVersion } = tlsHelpers; - // A falsy value counts as absent, like in tls.Server. + // A falsy value counts as absent: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1423-L1518 const secureProtocol = options.secureProtocol || undefined; const minVersionOption = options.minVersion || undefined; const maxVersionOption = options.maxVersion || undefined; + const sessionTimeout = options.sessionTimeout || undefined; tlsHelpers.validateSecureContextOptions({ ...options, secureProtocol, minVersion: minVersionOption, maxVersion: maxVersionOption, + sessionTimeout, + passphrase, + ciphers: options.ciphers || undefined, + clientCertEngine: options.clientCertEngine || undefined, + ticketKeys: options.ticketKeys || undefined, + // BoringSSL has no DHE suites to apply it to. + dhparam: undefined, }); - const crl = options.crl; + const crl = options.crl || undefined; if (crl) { tlsHelpers.throwOnInvalidTLSArray("options.crl", crl); } @@ -409,7 +417,7 @@ function Server(options, callback): void { cert, ca, crl, - sessionTimeout: options.sessionTimeout ?? 0, + sessionTimeout: sessionTimeout ?? 0, sigalgs: options.sigalgs, ecdhCurve: options.ecdhCurve ?? require("node:tls").DEFAULT_ECDH_CURVE, passphrase, diff --git a/test/js/first_party/ws/ws-tls-options.test.ts b/test/js/first_party/ws/ws-tls-options.test.ts index d8571d4af1b4..30d8d8bdea84 100644 --- a/test/js/first_party/ws/ws-tls-options.test.ts +++ b/test/js/first_party/ws/ws-tls-options.test.ts @@ -88,7 +88,15 @@ describe.concurrent("ws TLS options", () => { otherCa: await dial(server.url, { ca: ca1 }), emptyCa: await dial(server.url, { ca: "" }), unverified: await dial(server.url, { rejectUnauthorized: false, ALPNProtocols: ["http/1.1"] }), - }).toEqual({ none: "refused", ca: anonymous, otherCa: "refused", emptyCa: "refused", unverified: anonymous }); + serverOption: await dial(server.url, { agent: new https.Agent({ ca, dhparam: "auto" }) }), + }).toEqual({ + none: "refused", + ca: anonymous, + otherCa: "refused", + emptyCa: "refused", + unverified: anonymous, + serverOption: anonymous, + }); }); test("only an own rejectUnauthorized: false disables verification", async () => { diff --git a/test/js/node/http/node-fetch-agent-tls.test.ts b/test/js/node/http/node-fetch-agent-tls.test.ts index e719fcfa9486..ad982fb9b7f5 100644 --- a/test/js/node/http/node-fetch-agent-tls.test.ts +++ b/test/js/node/http/node-fetch-agent-tls.test.ts @@ -72,6 +72,7 @@ describe("node-fetch applies the TLS options of the agent", () => { test("ca and servername", async () => { await using server = await serve(); assert.strictEqual(await outcome(server.port, { ca, servername }), "authorized=false"); + assert.strictEqual(await outcome(server.port, { ca, servername, dhparam: "auto" }), "authorized=false"); assert.strictEqual(await outcome(server.port, { ca }), "ERR_TLS_CERT_ALTNAME_INVALID"); assert.strictEqual(await outcome(server.port, { servername }), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); }); diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index b72542de9644..e9d0c14421f8 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -247,6 +247,36 @@ describe("https.createServer forwards every TLS server option", () => { expect(() => https.createServer({ ...validCert, [name]: value })).toThrow(expect.objectContaining({ code })); expect(() => https.createServer({ [name]: value })).toThrow(expect.objectContaining({ code })); }); + + test.each([ + // BoringSSL has no DHE suites to apply it to. + ["dhparam", "auto"], + // tls.Server#setSecureContext() drops these when they are falsy. + ["clientCertEngine", 0], + ["clientCertEngine", ""], + ["ciphers", 0], + ["sessionTimeout", false], + ["ticketKeys", ""], + ["crl", ""], + ["crl", 0], + ["passphrase", 0], + ])("listens with %s: %p like Node", async (name, value) => { + await using server = https.createServer({ ...validCert, [name]: value }); + expect(await handshake(await listen(server), {})).toStartWith("TLS_"); + if (!process.versions.bun) return; + await using viaHttp = http.createServer({ ...validCert, [name]: value }); + expect(await handshake(await listen(viaHttp), {})).toStartWith("TLS_"); + }); + + // OpenSSL has X448, so Node listens. BoringSSL cannot apply the list as written. + test.skipIf(!process.versions.bun)( + "throws on an ecdhCurve that names a group BoringSSL lacks, like tls.createServer", + () => { + const error = expect.objectContaining({ code: "ERR_CRYPTO_OPERATION_FAILED" }); + expect(() => tls.createServer({ ...validCert, ecdhCurve: "X448:prime256v1" })).toThrow(error); + expect(() => https.createServer({ ...validCert, ecdhCurve: "X448:prime256v1" })).toThrow(error); + }, + ); }); describe("server.blockList", () => { From 9a0876846a20de70533f8e8d29c87e268d99ee53 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:00:34 +0000 Subject: [PATCH 109/174] node:https: a server takes TLS 1.3 suite names in ciphers https.createServer({ ciphers: "TLS_AES_256_GCM_SHA384" }) failed to listen with ERR_SSL_NO_CIPHER_MATCH: the list went to BoringSSL as written. Apply stripTls13CipherNames() like tls.Server does. --- src/js/node/_http_server.ts | 5 +++-- test/js/node/http/node-https-server.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 389e4a81ce4c..6384ab88f178 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -403,12 +403,13 @@ function Server(options, callback): void { if (options.honorCipherOrder !== false) secureOptions |= tlsHelpers.SSL_OP_CIPHER_SERVER_PREFERENCE; // secureProtocol, or else minVersion/maxVersion, as the integer range the native layer applies; 0 keeps its defaults. let minVersion, maxVersion; + const ciphers = options.ciphers ? tlsHelpers.stripTls13CipherNames(options.ciphers) : undefined; const range = secureProtocolToVersionRange(secureProtocol); if (range) { minVersion = range[0]; maxVersion = range[1]; } else { - minVersion = tlsStringToProtocolVersion(minVersionOption); + minVersion = tlsStringToProtocolVersion(ciphers?.tls13Only ? "TLSv1.3" : minVersionOption); maxVersion = tlsStringToProtocolVersion(maxVersionOption); } this[tlsSymbol] = normalizeServerTls({ @@ -424,7 +425,7 @@ function Server(options, callback): void { secureOptions, minVersion, maxVersion, - ciphers: typeof options.ciphers === "string" && options.ciphers ? options.ciphers : undefined, + ciphers: ciphers?.cipherList || undefined, requestCert: options.requestCert, rejectUnauthorized: options.rejectUnauthorized, }); diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index e9d0c14421f8..b363c4fca5dc 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -220,6 +220,19 @@ describe("https.createServer forwards every TLS server option", () => { expect(await negotiated(false)).toBe(aes128); }); + test("takes TLS 1.3 suite names in ciphers", async () => { + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + await using only13 = https.createServer({ ...validCert, ciphers: "TLS_AES_256_GCM_SHA384" }); + await using mixed = https.createServer({ ...validCert, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }); + const only13Port = await listen(only13); + const mixedPort = await listen(mixed); + + expect(await handshake(only13Port, {})).toStartWith("TLS_"); + expect(await handshake(only13Port, { maxVersion: "TLSv1.2" })).toContain("_ALERT_"); + expect(await handshake(mixedPort, {})).toStartWith("TLS_"); + expect(await handshake(mixedPort, { maxVersion: "TLSv1.2" })).toBe(aes256); + }); + test("takes a key as [{ pem, passphrase }]", async () => { const pem = readFileSync(join(import.meta.dirname, "fixtures", "cert.encrypted.key"), "utf8"); const cert = readFileSync(join(import.meta.dirname, "fixtures", "cert.pem"), "utf8"); From 961260d359a0671616eea67acb8ee1eb0d86d752 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:02:17 +0000 Subject: [PATCH 110/174] test: fold the new ws, node-fetch and https.Agent TLS tests into the existing files ws-tls-options.test.ts goes into ws.test.ts, node-fetch-agent-tls.test.ts into node-fetch.test.js and node-https-agent-getname.test.ts into node-http-agent-tls-options.test.mts. Every test and assertion is kept. The first two host files only run in Bun, so the guards for Node.js are gone. --- test/js/first_party/ws/ws-tls-options.test.ts | 367 ------------------ test/js/first_party/ws/ws.test.ts | 360 ++++++++++++++++- .../js/node/http/node-fetch-agent-tls.test.ts | 256 ------------ test/js/node/http/node-fetch.test.js | 248 +++++++++++- .../http/node-http-agent-tls-options.test.mts | 275 +++++++++++++ .../http/node-https-agent-getname.test.ts | 288 -------------- 6 files changed, 878 insertions(+), 916 deletions(-) delete mode 100644 test/js/first_party/ws/ws-tls-options.test.ts delete mode 100644 test/js/node/http/node-fetch-agent-tls.test.ts delete mode 100644 test/js/node/http/node-https-agent-getname.test.ts diff --git a/test/js/first_party/ws/ws-tls-options.test.ts b/test/js/first_party/ws/ws-tls-options.test.ts deleted file mode 100644 index 30d8d8bdea84..000000000000 --- a/test/js/first_party/ws/ws-tls-options.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -// npm `ws` gives every option of the constructor to https.request(), so TLS options are top-level options or options -// of the agent. Every test here also passes on Node.js with the ws package, but for the rows marked Bun only. -import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir, tls as serverIdentity } from "harness"; -import { HttpsProxyAgent } from "https-proxy-agent"; -import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { once } from "node:events"; -import { readFileSync } from "node:fs"; -import https from "node:https"; -import net, { type AddressInfo } from "node:net"; -import { join } from "node:path"; -import tls, { type TLSSocket } from "node:tls"; -import WebSocket, { WebSocketServer } from "ws"; - -const isBun = !!process.versions.bun; -const read = (name: string) => readFileSync(join(import.meta.dirname, "../../node/test/fixtures/keys", name)); -// The server's certificate is self-signed for localhost. ca1 signs agent1, the client, and nothing else here. -const ca = serverIdentity.cert; -const ca1 = read("ca1-cert.pem"); -// agent1's key and certificate, with ca1. -const pfx = read("agent1.pfx"); - -/** Tells each client whether it presented a certificate that ca1 signed. */ -async function serve(options?: https.ServerOptions) { - const server = https.createServer({ ...serverIdentity, ca: ca1, requestCert: true, rejectUnauthorized: false, ...options }); // prettier-ignore - server.on("tlsClientError", () => {}); - const wss = new WebSocketServer({ server }); - wss.on("connection", (ws, req) => ws.send(`authorized=${(req.socket as TLSSocket).authorized}`)); - await once(server.listen(0, "127.0.0.1"), "listening"); - return { - url: `wss://localhost:${(server.address() as AddressInfo).port}`, - [Symbol.dispose]() { - for (const client of wss.clients) client.terminate(); - server.close(); - }, - }; -} - -/** Tells each client the protocol version and the cipher of its connection. */ -async function serveNegotiated(options?: tls.TlsOptions) { - const server = tls.createServer({ ...serverIdentity, ...options }, socket => { - let head = ""; - socket.on("error", () => {}); - socket.on("data", chunk => { - head += chunk.toString("latin1"); - if (!head.includes("\r\n\r\n")) return; - const key = /sec-websocket-key: (.*)\r\n/i.exec(head)![1]; - const accept = createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); - const message = `${socket.getProtocol()} ${socket.getCipher().name}`; - socket.write( - `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, - ); - socket.write(Buffer.concat([Buffer.from([0x81, message.length]), Buffer.from(message)])); - }); - }); - server.on("tlsClientError", () => {}); - await once(server.listen(0, "127.0.0.1"), "listening"); - return { - url: `wss://localhost:${(server.address() as AddressInfo).port}`, - [Symbol.dispose]() { - server.close(); - }, - }; -} - -/** The server's message, or "refused". */ -function dial(url: string, options: object) { - const { promise, resolve } = Promise.withResolvers(); - const ws = new WebSocket(url, options); - ws.on("message", data => { - resolve(String(data)); - ws.terminate(); - }); - ws.on("error", () => resolve("refused")); - return promise; -} - -const anonymous = "authorized=false"; -const identified = "authorized=true"; - -describe.concurrent("ws TLS options", () => { - test("top-level ca and rejectUnauthorized", async () => { - using server = await serve(); - expect({ - none: await dial(server.url, {}), - ca: await dial(server.url, { ca }), - otherCa: await dial(server.url, { ca: ca1 }), - emptyCa: await dial(server.url, { ca: "" }), - unverified: await dial(server.url, { rejectUnauthorized: false, ALPNProtocols: ["http/1.1"] }), - serverOption: await dial(server.url, { agent: new https.Agent({ ca, dhparam: "auto" }) }), - }).toEqual({ - none: "refused", - ca: anonymous, - otherCa: "refused", - emptyCa: "refused", - unverified: anonymous, - serverOption: anonymous, - }); - }); - - test("only an own rejectUnauthorized: false disables verification", async () => { - using server = await serve(); - expect(await dial(server.url, { rejectUnauthorized: false })).toBe(anonymous); - for (const rejectUnauthorized of [0, null, "false", "", undefined, true]) { - expect(await dial(server.url, { rejectUnauthorized })).toBe("refused"); - expect(await dial(server.url, { agent: new https.Agent({ rejectUnauthorized } as any) })).toBe("refused"); - } - expect(await dial(server.url, Object.create({ rejectUnauthorized: false }))).toBe("refused"); - // Bun only: Node's Agent reads its own inherited `options`. - const inherited = Object.create(new https.Agent({ rejectUnauthorized: false })); - if (isBun) expect(await dial(server.url, { agent: inherited })).toBe("refused"); - }); - - test("the options of the agent win over the top-level options", async () => { - using server = await serve(); - const agent = (options: object) => new https.Agent(options); - expect({ - agentOff: await dial(server.url, { agent: agent({ rejectUnauthorized: false }), rejectUnauthorized: true }), - agentOn: await dial(server.url, { agent: agent({ rejectUnauthorized: true }), rejectUnauthorized: false }), - agentUndefined: await dial(server.url, { agent: agent({ rejectUnauthorized: undefined }), rejectUnauthorized: false }), // prettier-ignore - agentSilent: await dial(server.url, { agent: agent({}), rejectUnauthorized: false }), - agentCa: await dial(server.url, { agent: agent({ ca }), ca: ca1 }), - agentOtherCa: await dial(server.url, { agent: agent({ ca: ca1 }), ca }), - merged: await dial(server.url, { agent: agent({ ca }), cert: read("agent1-cert.pem"), key: read("agent1-key.pem") }), // prettier-ignore - }).toEqual({ - agentOff: anonymous, - agentOn: "refused", - agentUndefined: "refused", - agentSilent: anonymous, - agentCa: anonymous, - agentOtherCa: "refused", - merged: identified, - }); - }); - - test("the client identity, in every form", async () => { - using server = await serve(); - const cert = read("agent1-cert.pem"); - const key = read("agent1-key.pem"); - expect({ - pem: await dial(server.url, { ca, cert, key }), - pemObject: await dial(server.url, { ca, cert, key: [{ pem: key }] }), - pfx: await dial(server.url, { ca, pfx, passphrase: "sample" }), - pfxObject: await dial(server.url, { ca, pfx: [{ buf: pfx, passphrase: "sample" }] }), - agentPfx: await dial(server.url, { agent: new https.Agent({ ca, pfx, passphrase: "sample" }) }), - // Bun only. - tlsPfx: isBun ? await dial(server.url, { tls: { ca, pfx, passphrase: "sample" } }) : identified, - }).toEqual({ - pem: identified, - pemObject: identified, - pfx: identified, - pfxObject: identified, - agentPfx: identified, - tlsPfx: identified, - }); - }); - - test("minVersion and maxVersion", async () => { - using tls12 = await serve({ maxVersion: "TLSv1.2" }); - using tls13 = await serve({ minVersion: "TLSv1.3" }); - expect({ - min13to12: await dial(tls12.url, { ca, minVersion: "TLSv1.3" }), - max12to12: await dial(tls12.url, { ca, maxVersion: "TLSv1.2" }), - max12to13: await dial(tls13.url, { ca, maxVersion: "TLSv1.2" }), - min13to13: await dial(tls13.url, { ca, minVersion: "TLSv1.3" }), - }).toEqual({ min13to12: "refused", max12to12: anonymous, max12to13: "refused", min13to13: anonymous }); - }); - - test("ciphers", async () => { - using server = await serveNegotiated(); - using tls12 = await serveNegotiated({ maxVersion: "TLSv1.2" }); - const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; - const version = (negotiated: string) => negotiated.split(" ")[0]; - expect({ - // BoringSSL has no list of TLS 1.3 suites to restrict, so only Node negotiates the one that is named. - only13: version(await dial(server.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384" })), - only13Agent: version( - await dial(server.url, { agent: new https.Agent({ ca, ciphers: "TLS_AES_256_GCM_SHA384" }) }), - ), - only13To12: await dial(tls12.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384" }), - only13Max12: await dial(server.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384", maxVersion: "TLSv1.2" }), - mixed: version(await dial(server.url, { ca, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` })), - mixedTo12: await dial(tls12.url, { ca, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), - only12: version(await dial(server.url, { ca, ciphers: aes256 })), - only12To12: await dial(tls12.url, { ca, ciphers: aes256 }), - }).toEqual({ - only13: "TLSv1.3", - only13Agent: "TLSv1.3", - only13To12: "refused", - only13Max12: "refused", - mixed: "TLSv1.3", - mixedTo12: `TLSv1.2 ${aes256}`, - only12: "TLSv1.3", - only12To12: `TLSv1.2 ${aes256}`, - }); - }); - - test("secureProtocol", async () => { - using server = await serveNegotiated(); - const version = (negotiated: string) => negotiated.split(" ")[0]; - expect({ - tls12: version(await dial(server.url, { ca, secureProtocol: "TLSv1_2_method" })), - tls12Agent: version(await dial(server.url, { agent: new https.Agent({ ca, secureProtocol: "TLSv1_2_client_method" }) })), // prettier-ignore - tls11: await dial(server.url, { ca, secureProtocol: "TLSv1_1_method" }), - any: version(await dial(server.url, { ca, secureProtocol: "TLS_method" })), - }).toEqual({ tls12: "TLSv1.2", tls12Agent: "TLSv1.2", tls11: "refused", any: "TLSv1.3" }); - // There is no such method. - expect(() => new WebSocket(server.url, { agent: new https.Agent({ secureProtocol: "TLSv1_3_method" }) })).toThrow( - expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_METHOD" }), - ); - expect( - () => - new WebSocket(server.url, { - agent: new https.Agent({ secureProtocol: "TLSv1_2_method", minVersion: "TLSv1.3" }), - }), - ).toThrow( - // prettier-ignore - expect.objectContaining({ code: "ERR_TLS_PROTOCOL_VERSION_CONFLICT" }), - ); - }); - - test("an option that tls.connect() rejects throws", () => { - const url = "wss://localhost:1"; - // BoringSSL and OpenSSL word it differently. - expect(() => new WebSocket(url, { agent: new https.Agent({ pfx, passphrase: "wrong" }) })).toThrow(/mac verif/i); - expect(() => new WebSocket(url, { agent: new https.Agent({ minVersion: "TLSv9" as any }) })).toThrow( - expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_VERSION" }), - ); - }); - - test("top-level options reach the target through an HttpsProxyAgent", async () => { - using server = await serve(); - const connects: string[] = []; - const proxy = net.createServer(client => { - client.on("error", () => {}); - client.once("data", head => { - const target = head.toString("latin1").split(" ")[1]; - connects.push(target); - const upstream = net.connect(Number(target.split(":")[1]), "127.0.0.1", () => { - client.write("HTTP/1.1 200 Connection established\r\n\r\n"); - client.pipe(upstream).pipe(client); - }); - upstream.on("error", () => client.destroy()); - }); - }); - await once(proxy.listen(0, "127.0.0.1"), "listening"); - try { - const agent = new HttpsProxyAgent(`http://127.0.0.1:${(proxy.address() as AddressInfo).port}`); - expect(await dial(server.url, { agent, ca, pfx, passphrase: "sample" })).toBe(identified); - expect(connects).toEqual([server.url.slice("wss://".length)]); - } finally { - proxy.close(); - } - }); - - test.skipIf(!isBun)("an explicit tls option replaces the agent's and the top-level options", async () => { - using server = await serve(); - const off = { rejectUnauthorized: false }; - expect(await dial(server.url, { ...off })).toBe(anonymous); - expect(await dial(server.url, { ...off, agent: new https.Agent(off), tls: { ca: ca1 } })).toBe("refused"); - expect(await dial(server.url, { ca: ca1, agent: new https.Agent({ ca: ca1 }), tls: { ca } })).toBe(anonymous); - }); -}); - -describe.concurrent("ws with a pfx that bundles a CA", () => { - // agent1's key and certificate, with the server's certificate as the CA. From the repository root: - // bun -e 'await Bun.write("/tmp/server.pem", (await import("./test/harness.ts")).tls.cert)' - // openssl pkcs12 -export -passout pass:sample -certfile /tmp/server.pem \ - // -inkey test/js/node/test/fixtures/keys/agent1-key.pem -in test/js/node/test/fixtures/keys/agent1-cert.pem \ - // -out test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx - const pfxWithServerCa = readFileSync(join(import.meta.dirname, "fixtures/agent1-with-server-ca.pfx")); - - test("adds the CA to the ca option", async () => { - using server = await serve(); - expect(await dial(server.url, { ca: ca1, pfx: pfxWithServerCa, passphrase: "sample" })).toBe(identified); - expect(await dial(server.url, { ca: [ca1], pfx: [{ buf: pfxWithServerCa, passphrase: "sample" }] })).toBe(identified); // prettier-ignore - // Node adds it to the default store too. The native `ca` can only replace that store, so Bun leaves it out. - if (isBun) expect(await dial(server.url, { pfx: pfxWithServerCa, passphrase: "sample" })).toBe("refused"); - }); - - // `openssl x509 -subject_hash` of the server's certificate. - const hashedName = "c62891c1.0"; - test.each([ - ["NODE_EXTRA_CA_CERTS", "server.pem", true], - // Node reads these two with --use-openssl-ca only. - ["SSL_CERT_FILE", "server.pem", isBun], - ["SSL_CERT_DIR", "hashed", isBun], - ] as const)("keeps trusting %s", async (name, path, applies) => { - if (!applies) return; - using server = await serve(); - using dir = tempDir("ws-pfx-default-store", { "server.pem": ca, [`hashed/${hashedName}`]: ca }); - const child = spawn( - bunExe(), - [ - "-e", - ` - const WebSocket = require("ws"); - const ws = new WebSocket(process.env.TEST_URL, { pfx: require("fs").readFileSync(process.env.TEST_PFX), passphrase: "sample" }); - ws.on("message", data => { console.log(String(data)); ws.terminate(); }); - ws.on("error", err => console.log("refused", err.message)); - `, - ], - { - cwd: import.meta.dirname, - env: { - ...bunEnv, - TEST_URL: server.url, - TEST_PFX: join(import.meta.dirname, "../../node/test/fixtures/keys/agent1.pfx"), - [name]: join(String(dir), path), - }, - stdio: ["ignore", "pipe", "inherit"], - }, - ); - let stdout = ""; - child.stdout.on("data", chunk => (stdout += chunk)); - const [exitCode] = await once(child, "exit"); - expect(stdout.trim()).toBe(identified); - expect(exitCode).toBe(0); - }); -}); - -describe.concurrent("ws TLS options that name or pin the server", () => { - // CN=agent1, no subjectAltName, signed by ca1. - const agent1 = { key: read("agent1-key.pem"), cert: read("agent1-cert.pem") }; - - test("servername", async () => { - using server = await serve(agent1); - expect({ - none: await dial(server.url, { ca: ca1 }), - topLevel: await dial(server.url, { ca: ca1, servername: "agent1" }), - agent: await dial(server.url, { agent: new https.Agent({ ca: ca1, servername: "agent1" }) }), - agentWins: await dial(server.url, { agent: new https.Agent({ servername: "agent1" }), ca: ca1, servername: "other" }), // prettier-ignore - agentWinsWrong: await dial(server.url, { agent: new https.Agent({ servername: "other" }), ca: ca1, servername: "agent1" }), // prettier-ignore - }).toEqual({ - none: "refused", - topLevel: anonymous, - agent: anonymous, - agentWins: anonymous, - agentWinsWrong: "refused", - }); - }); - - test("checkServerIdentity", async () => { - using server = await serve(agent1); - const seen: unknown[] = []; - function accept(hostname: string, cert: { subject: { CN: string } }) { - seen.push(hostname, cert.subject.CN); - return undefined; - } - const refuse = () => new Error("not the pinned certificate"); - expect(await dial(server.url, { ca: ca1, checkServerIdentity: accept })).toBe(anonymous); - expect(seen).toEqual(["localhost", "agent1"]); - expect(await dial(server.url, { ca: ca1, servername: "agent1", checkServerIdentity: refuse })).toBe("refused"); - expect(await dial(server.url, { agent: new https.Agent({ ca: ca1, checkServerIdentity: accept as any }) })).toBe(anonymous); // prettier-ignore - // It does not replace the verification of the chain. - expect(await dial(server.url, { checkServerIdentity: accept })).toBe("refused"); - }); - - test("crl", async () => { - // ca2 signs agent3. ca2-crl.pem revokes agent4 only. - using server = await serve({ key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }); - const trust = { ca: read("ca2-cert.pem"), servername: "agent3" }; - expect(await dial(server.url, { ...trust, crl: read("ca2-crl.pem") })).toBe(anonymous); - expect(await dial(server.url, { ...trust, crl: read("ca2-crl-agent3.pem") })).toBe("refused"); - }); -}); diff --git a/test/js/first_party/ws/ws.test.ts b/test/js/first_party/ws/ws.test.ts index 87c991e3710c..ceccfc78e404 100644 --- a/test/js/first_party/ws/ws.test.ts +++ b/test/js/first_party/ws/ws.test.ts @@ -3,12 +3,15 @@ import { spawn } from "bun"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import crypto from "crypto"; import { EventEmitter, once } from "events"; -import { bunEnv, bunExe, isDebug } from "harness"; +import { bunEnv, bunExe, isDebug, tls as serverIdentity, tempDir } from "harness"; import { createServer, request } from "http"; -import { AddressInfo, connect } from "net"; +import https from "https"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import net, { AddressInfo, connect } from "net"; +import fs from "node:fs"; import path from "node:path"; -// @ts-expect-error @types/ws: no ESM `Server` -import { type ClientOptions, Server, WebSocket, WebSocketServer } from "ws"; +import tls, { type TLSSocket } from "tls"; +import { Server, WebSocket, WebSocketServer } from "ws"; const strings = [ { @@ -1750,3 +1753,352 @@ describe("module loading", () => { expect(exitCode).toBe(0); }); }); + +// npm `ws` gives every option of the constructor to https.request(), so TLS options are top-level options or options +// of the agent. The verdicts are those of Node.js v26.3.0 with ws 8.18.3, but where a comment says what Node does. +describe("client TLS options", () => { + const read = (name: string) => fs.readFileSync(path.join(import.meta.dirname, "../../node/test/fixtures/keys", name)); + // The server's certificate is self-signed for localhost. ca1 signs agent1, the client, and nothing else here. + const ca = serverIdentity.cert; + const ca1 = read("ca1-cert.pem"); + // agent1's key and certificate, with ca1. + const pfx = read("agent1.pfx"); + + /** Tells each client whether it presented a certificate that ca1 signed. */ + async function serve(options?: https.ServerOptions) { + const server = https.createServer({ ...serverIdentity, ca: ca1, requestCert: true, rejectUnauthorized: false, ...options }); // prettier-ignore + server.on("tlsClientError", () => {}); + const wss = new WebSocketServer({ server }); + wss.on("connection", (ws, req) => ws.send(`authorized=${(req.socket as TLSSocket).authorized}`)); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + url: `wss://localhost:${(server.address() as AddressInfo).port}`, + [Symbol.dispose]() { + for (const client of wss.clients) client.terminate(); + server.close(); + }, + }; + } + + /** Tells each client the protocol version and the cipher of its connection. */ + async function serveNegotiated(options?: tls.TlsOptions) { + const server = tls.createServer({ ...serverIdentity, ...options }, socket => { + let head = ""; + socket.on("error", () => {}); + socket.on("data", chunk => { + head += chunk.toString("latin1"); + if (!head.includes("\r\n\r\n")) return; + const key = /sec-websocket-key: (.*)\r\n/i.exec(head)![1]; + const accept = crypto.createHash("sha1").update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest("base64"); + const message = `${socket.getProtocol()} ${socket.getCipher().name}`; + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + socket.write(Buffer.concat([Buffer.from([0x81, message.length]), Buffer.from(message)])); + }); + }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + url: `wss://localhost:${(server.address() as AddressInfo).port}`, + [Symbol.dispose]() { + server.close(); + }, + }; + } + + /** The server's message, or "refused". */ + function dial(url: string, options: object) { + const { promise, resolve } = Promise.withResolvers(); + const ws = new WebSocket(url, options); + ws.on("message", data => { + resolve(String(data)); + ws.terminate(); + }); + ws.on("error", () => resolve("refused")); + return promise; + } + + const anonymous = "authorized=false"; + const identified = "authorized=true"; + + describe.concurrent("ws TLS options", () => { + it("top-level ca and rejectUnauthorized", async () => { + using server = await serve(); + expect({ + none: await dial(server.url, {}), + ca: await dial(server.url, { ca }), + otherCa: await dial(server.url, { ca: ca1 }), + emptyCa: await dial(server.url, { ca: "" }), + unverified: await dial(server.url, { rejectUnauthorized: false, ALPNProtocols: ["http/1.1"] }), + serverOption: await dial(server.url, { agent: new https.Agent({ ca, dhparam: "auto" }) }), + }).toEqual({ + none: "refused", + ca: anonymous, + otherCa: "refused", + emptyCa: "refused", + unverified: anonymous, + serverOption: anonymous, + }); + }); + + it("only an own rejectUnauthorized: false disables verification", async () => { + using server = await serve(); + expect(await dial(server.url, { rejectUnauthorized: false })).toBe(anonymous); + for (const rejectUnauthorized of [0, null, "false", "", undefined, true]) { + expect(await dial(server.url, { rejectUnauthorized })).toBe("refused"); + expect(await dial(server.url, { agent: new https.Agent({ rejectUnauthorized } as any) })).toBe("refused"); + } + expect(await dial(server.url, Object.create({ rejectUnauthorized: false }))).toBe("refused"); + // Node's Agent reads its own inherited `options`. + const inherited = Object.create(new https.Agent({ rejectUnauthorized: false })); + expect(await dial(server.url, { agent: inherited })).toBe("refused"); + }); + + it("the options of the agent win over the top-level options", async () => { + using server = await serve(); + const agent = (options: object) => new https.Agent(options); + expect({ + agentOff: await dial(server.url, { agent: agent({ rejectUnauthorized: false }), rejectUnauthorized: true }), + agentOn: await dial(server.url, { agent: agent({ rejectUnauthorized: true }), rejectUnauthorized: false }), + agentUndefined: await dial(server.url, { agent: agent({ rejectUnauthorized: undefined }), rejectUnauthorized: false }), // prettier-ignore + agentSilent: await dial(server.url, { agent: agent({}), rejectUnauthorized: false }), + agentCa: await dial(server.url, { agent: agent({ ca }), ca: ca1 }), + agentOtherCa: await dial(server.url, { agent: agent({ ca: ca1 }), ca }), + merged: await dial(server.url, { agent: agent({ ca }), cert: read("agent1-cert.pem"), key: read("agent1-key.pem") }), // prettier-ignore + }).toEqual({ + agentOff: anonymous, + agentOn: "refused", + agentUndefined: "refused", + agentSilent: anonymous, + agentCa: anonymous, + agentOtherCa: "refused", + merged: identified, + }); + }); + + it("the client identity, in every form", async () => { + using server = await serve(); + const cert = read("agent1-cert.pem"); + const key = read("agent1-key.pem"); + expect({ + pem: await dial(server.url, { ca, cert, key }), + pemObject: await dial(server.url, { ca, cert, key: [{ pem: key }] }), + pfx: await dial(server.url, { ca, pfx, passphrase: "sample" }), + pfxObject: await dial(server.url, { ca, pfx: [{ buf: pfx, passphrase: "sample" }] }), + agentPfx: await dial(server.url, { agent: new https.Agent({ ca, pfx, passphrase: "sample" }) }), + tlsPfx: await dial(server.url, { tls: { ca, pfx, passphrase: "sample" } }), + }).toEqual({ + pem: identified, + pemObject: identified, + pfx: identified, + pfxObject: identified, + agentPfx: identified, + tlsPfx: identified, + }); + }); + + it("minVersion and maxVersion", async () => { + using tls12 = await serve({ maxVersion: "TLSv1.2" }); + using tls13 = await serve({ minVersion: "TLSv1.3" }); + expect({ + min13to12: await dial(tls12.url, { ca, minVersion: "TLSv1.3" }), + max12to12: await dial(tls12.url, { ca, maxVersion: "TLSv1.2" }), + max12to13: await dial(tls13.url, { ca, maxVersion: "TLSv1.2" }), + min13to13: await dial(tls13.url, { ca, minVersion: "TLSv1.3" }), + }).toEqual({ min13to12: "refused", max12to12: anonymous, max12to13: "refused", min13to13: anonymous }); + }); + + it("ciphers", async () => { + using server = await serveNegotiated(); + using tls12 = await serveNegotiated({ maxVersion: "TLSv1.2" }); + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + const version = (negotiated: string) => negotiated.split(" ")[0]; + expect({ + // BoringSSL has no list of TLS 1.3 suites to restrict, so only Node negotiates the one that is named. + only13: version(await dial(server.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384" })), + only13Agent: version( + await dial(server.url, { agent: new https.Agent({ ca, ciphers: "TLS_AES_256_GCM_SHA384" }) }), + ), + only13To12: await dial(tls12.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384" }), + only13Max12: await dial(server.url, { ca, ciphers: "TLS_AES_256_GCM_SHA384", maxVersion: "TLSv1.2" }), + mixed: version(await dial(server.url, { ca, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` })), + mixedTo12: await dial(tls12.url, { ca, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), + only12: version(await dial(server.url, { ca, ciphers: aes256 })), + only12To12: await dial(tls12.url, { ca, ciphers: aes256 }), + }).toEqual({ + only13: "TLSv1.3", + only13Agent: "TLSv1.3", + only13To12: "refused", + only13Max12: "refused", + mixed: "TLSv1.3", + mixedTo12: `TLSv1.2 ${aes256}`, + only12: "TLSv1.3", + only12To12: `TLSv1.2 ${aes256}`, + }); + }); + + it("secureProtocol", async () => { + using server = await serveNegotiated(); + const version = (negotiated: string) => negotiated.split(" ")[0]; + expect({ + tls12: version(await dial(server.url, { ca, secureProtocol: "TLSv1_2_method" })), + tls12Agent: version(await dial(server.url, { agent: new https.Agent({ ca, secureProtocol: "TLSv1_2_client_method" }) })), // prettier-ignore + tls11: await dial(server.url, { ca, secureProtocol: "TLSv1_1_method" }), + any: version(await dial(server.url, { ca, secureProtocol: "TLS_method" })), + }).toEqual({ tls12: "TLSv1.2", tls12Agent: "TLSv1.2", tls11: "refused", any: "TLSv1.3" }); + // There is no such method. + expect(() => new WebSocket(server.url, { agent: new https.Agent({ secureProtocol: "TLSv1_3_method" }) })).toThrow( + expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_METHOD" }), + ); + expect( + () => + new WebSocket(server.url, { + agent: new https.Agent({ secureProtocol: "TLSv1_2_method", minVersion: "TLSv1.3" }), + }), + ).toThrow( + // prettier-ignore + expect.objectContaining({ code: "ERR_TLS_PROTOCOL_VERSION_CONFLICT" }), + ); + }); + + it("an option that tls.connect() rejects throws", () => { + const url = "wss://localhost:1"; + // BoringSSL and OpenSSL word it differently. + expect(() => new WebSocket(url, { agent: new https.Agent({ pfx, passphrase: "wrong" }) })).toThrow(/mac verif/i); + expect(() => new WebSocket(url, { agent: new https.Agent({ minVersion: "TLSv9" as any }) })).toThrow( + expect.objectContaining({ code: "ERR_TLS_INVALID_PROTOCOL_VERSION" }), + ); + }); + + it("top-level options reach the target through an HttpsProxyAgent", async () => { + using server = await serve(); + const connects: string[] = []; + const proxy = net.createServer(client => { + client.on("error", () => {}); + client.once("data", head => { + const target = head.toString("latin1").split(" ")[1]; + connects.push(target); + const upstream = net.connect(Number(target.split(":")[1]), "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection established\r\n\r\n"); + client.pipe(upstream).pipe(client); + }); + upstream.on("error", () => client.destroy()); + }); + }); + await once(proxy.listen(0, "127.0.0.1"), "listening"); + try { + const agent = new HttpsProxyAgent(`http://127.0.0.1:${(proxy.address() as AddressInfo).port}`); + expect(await dial(server.url, { agent, ca, pfx, passphrase: "sample" })).toBe(identified); + expect(connects).toEqual([server.url.slice("wss://".length)]); + } finally { + proxy.close(); + } + }); + + it("an explicit tls option replaces the agent's and the top-level options", async () => { + using server = await serve(); + const off = { rejectUnauthorized: false }; + expect(await dial(server.url, { ...off })).toBe(anonymous); + expect(await dial(server.url, { ...off, agent: new https.Agent(off), tls: { ca: ca1 } })).toBe("refused"); + expect(await dial(server.url, { ca: ca1, agent: new https.Agent({ ca: ca1 }), tls: { ca } })).toBe(anonymous); + }); + }); + + describe.concurrent("ws with a pfx that bundles a CA", () => { + // agent1's key and certificate, with the server's certificate as the CA. From the repository root: + // bun -e 'await Bun.write("/tmp/server.pem", (await import("./test/harness.ts")).tls.cert)' + // openssl pkcs12 -export -passout pass:sample -certfile /tmp/server.pem \ + // -inkey test/js/node/test/fixtures/keys/agent1-key.pem -in test/js/node/test/fixtures/keys/agent1-cert.pem \ + // -out test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx + const pfxWithServerCa = fs.readFileSync(path.join(import.meta.dirname, "fixtures/agent1-with-server-ca.pfx")); + + it("adds the CA to the ca option", async () => { + using server = await serve(); + expect(await dial(server.url, { ca: ca1, pfx: pfxWithServerCa, passphrase: "sample" })).toBe(identified); + expect(await dial(server.url, { ca: [ca1], pfx: [{ buf: pfxWithServerCa, passphrase: "sample" }] })).toBe(identified); // prettier-ignore + // Node adds it to the default store too. The native `ca` can only replace that store, so Bun leaves it out. + expect(await dial(server.url, { pfx: pfxWithServerCa, passphrase: "sample" })).toBe("refused"); + }); + + // `openssl x509 -subject_hash` of the server's certificate. + const hashedName = "c62891c1.0"; + it.each([ + ["NODE_EXTRA_CA_CERTS", "server.pem"], + ["SSL_CERT_FILE", "server.pem"], + ["SSL_CERT_DIR", "hashed"], + ])("keeps trusting %s", async (name, file) => { + using server = await serve(); + using dir = tempDir("ws-pfx-default-store", { "server.pem": ca, [`hashed/${hashedName}`]: ca }); + await using child = spawn({ + cmd: [ + bunExe(), + "-e", + ` + const WebSocket = require("ws"); + const ws = new WebSocket(process.env.TEST_URL, { pfx: require("fs").readFileSync(process.env.TEST_PFX), passphrase: "sample" }); + ws.on("message", data => { console.log(String(data)); ws.terminate(); }); + ws.on("error", err => console.log("refused", err.message)); + `, + ], + env: { + ...bunEnv, + TEST_URL: server.url, + TEST_PFX: path.join(import.meta.dirname, "../../node/test/fixtures/keys/agent1.pfx"), + [name]: path.join(String(dir), file), + }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([child.stdout.text(), child.exited]); + expect(stdout.trim()).toBe(identified); + expect(exitCode).toBe(0); + }); + }); + + describe.concurrent("ws TLS options that name or pin the server", () => { + // CN=agent1, no subjectAltName, signed by ca1. + const agent1 = { key: read("agent1-key.pem"), cert: read("agent1-cert.pem") }; + + it("servername", async () => { + using server = await serve(agent1); + expect({ + none: await dial(server.url, { ca: ca1 }), + topLevel: await dial(server.url, { ca: ca1, servername: "agent1" }), + agent: await dial(server.url, { agent: new https.Agent({ ca: ca1, servername: "agent1" }) }), + agentWins: await dial(server.url, { agent: new https.Agent({ servername: "agent1" }), ca: ca1, servername: "other" }), // prettier-ignore + agentWinsWrong: await dial(server.url, { agent: new https.Agent({ servername: "other" }), ca: ca1, servername: "agent1" }), // prettier-ignore + }).toEqual({ + none: "refused", + topLevel: anonymous, + agent: anonymous, + agentWins: anonymous, + agentWinsWrong: "refused", + }); + }); + + it("checkServerIdentity", async () => { + using server = await serve(agent1); + const seen: unknown[] = []; + function accept(hostname: string, cert: { subject: { CN: string } }) { + seen.push(hostname, cert.subject.CN); + return undefined; + } + const refuse = () => new Error("not the pinned certificate"); + expect(await dial(server.url, { ca: ca1, checkServerIdentity: accept })).toBe(anonymous); + expect(seen).toEqual(["localhost", "agent1"]); + expect(await dial(server.url, { ca: ca1, servername: "agent1", checkServerIdentity: refuse })).toBe("refused"); + expect(await dial(server.url, { agent: new https.Agent({ ca: ca1, checkServerIdentity: accept as any }) })).toBe(anonymous); // prettier-ignore + // It does not replace the verification of the chain. + expect(await dial(server.url, { checkServerIdentity: accept })).toBe("refused"); + }); + + it("crl", async () => { + // ca2 signs agent3. ca2-crl.pem revokes agent4 only. + using server = await serve({ key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }); + const trust = { ca: read("ca2-cert.pem"), servername: "agent3" }; + expect(await dial(server.url, { ...trust, crl: read("ca2-crl.pem") })).toBe(anonymous); + expect(await dial(server.url, { ...trust, crl: read("ca2-crl-agent3.pem") })).toBe("refused"); + }); + }); +}); diff --git a/test/js/node/http/node-fetch-agent-tls.test.ts b/test/js/node/http/node-fetch-agent-tls.test.ts deleted file mode 100644 index ad982fb9b7f5..000000000000 --- a/test/js/node/http/node-fetch-agent-tls.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -// @kubernetes/client-node passes an https.Agent (with ca/cert/key) to node-fetch: https://github.com/oven-sh/bun/issues/19754 -// Uses node:test so that it runs unchanged on Node.js with the node-fetch package. -import nodeFetch from "node-fetch"; -import assert from "node:assert"; -import { constants, createPrivateKey } from "node:crypto"; -import { once } from "node:events"; -import { readFileSync } from "node:fs"; -import https from "node:https"; -import type { AddressInfo } from "node:net"; -import path from "node:path"; -import { describe, test } from "node:test"; -import tls, { type TLSSocket } from "node:tls"; - -const read = (name: string) => readFileSync(path.join(import.meta.dirname, "..", "test", "fixtures", "keys", name)); -// ca1 signs agent1, ca2 signs agent3. No certificate has a SAN, so clients verify CN under `servername`. -const ca = read("ca1-cert.pem"); -const key = read("agent1-key.pem"); -const cert = read("agent1-cert.pem"); -const servername = "agent1"; - -async function serve(options: https.ServerOptions = { key, cert }) { - const server = https.createServer(options, (req, res) => { - res.writeHead(200, { connection: "close" }); - res.end(`authorized=${(req.socket as TLSSocket).authorized}`); - }); - server.on("tlsClientError", () => {}); - await once(server.listen(0, "127.0.0.1"), "listening"); - return { - port: (server.address() as AddressInfo).port, - async [Symbol.asyncDispose]() { - server.close(); - await once(server, "close"); - }, - }; -} - -/** Answers each request with the protocol version and the cipher of its connection. */ -async function serveNegotiated(options?: tls.TlsOptions) { - const server = tls.createServer({ key, cert, ...options }, socket => { - socket.on("error", () => {}); - socket.once("data", () => { - const body = `${socket.getProtocol()} ${socket.getCipher().name}`; - socket.end(`HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n${body}`); - }); - }); - server.on("tlsClientError", () => {}); - await once(server.listen(0, "127.0.0.1"), "listening"); - return { - port: (server.address() as AddressInfo).port, - async [Symbol.asyncDispose]() { - server.close(); - await once(server, "close"); - }, - }; -} - -type Init = { agent?: https.Agent | ((url: URL) => https.Agent); tls?: object }; - -/** The response body, or the code of the rejection. */ -async function outcome(port: number, options: https.AgentOptions | Init["agent"], init?: Init) { - const agent = typeof options === "function" || options instanceof https.Agent ? options : new https.Agent(options); - try { - return await (await nodeFetch(`https://127.0.0.1:${port}/`, { agent, ...init } as any)).text(); - } catch (err) { - return String((err as NodeJS.ErrnoException).code); - } finally { - if (typeof agent !== "function") agent.destroy(); - } -} - -describe("node-fetch applies the TLS options of the agent", () => { - test("ca and servername", async () => { - await using server = await serve(); - assert.strictEqual(await outcome(server.port, { ca, servername }), "authorized=false"); - assert.strictEqual(await outcome(server.port, { ca, servername, dhparam: "auto" }), "authorized=false"); - assert.strictEqual(await outcome(server.port, { ca }), "ERR_TLS_CERT_ALTNAME_INVALID"); - assert.strictEqual(await outcome(server.port, { servername }), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); - }); - - test("the client identity, in every form", async () => { - await using server = await serve({ key, cert, ca, requestCert: true, rejectUnauthorized: false }); - const trust = { ca, servername }; - const encryptedKey = createPrivateKey(key).export({ - type: "pkcs8", - format: "pem", - cipher: "aes-256-cbc", - passphrase: "sample", - }); - assert.deepStrictEqual( - { - none: await outcome(server.port, trust), - pem: await outcome(server.port, { ...trust, cert, key }), - pemObject: await outcome(server.port, { ...trust, cert, key: [{ pem: key }] }), - encrypted: await outcome(server.port, { - ...trust, - cert, - key: encryptedKey, - passphrase: "sample", - }), - pfx: await outcome(server.port, { ...trust, pfx: read("agent1.pfx"), passphrase: "sample" }), - pfxObject: await outcome(server.port, { ...trust, pfx: [{ buf: read("agent1.pfx"), passphrase: "sample" }] }), - }, - { - none: "authorized=false", - pem: "authorized=true", - pemObject: "authorized=true", - encrypted: "authorized=true", - pfx: "authorized=true", - pfxObject: "authorized=true", - }, - ); - }); - - test("rejectUnauthorized disables verification only when it is false", async () => { - await using server = await serve(); - assert.strictEqual(await outcome(server.port, { rejectUnauthorized: false }), "authorized=false"); - for (const rejectUnauthorized of [0, null, "false", undefined, true] as any[]) { - assert.strictEqual(await outcome(server.port, { rejectUnauthorized }), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); - } - if (process.versions.bun) { - // Node reads both through the prototype chain. - const off = new https.Agent({ rejectUnauthorized: false }); - assert.strictEqual(await outcome(server.port, Object.create(off)), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); - await assert.rejects(nodeFetch(`https://127.0.0.1:${server.port}/`, Object.create({ agent: off })), { - code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", - }); - const tls = { rejectUnauthorized: true }; - assert.strictEqual( - await outcome(server.port, { rejectUnauthorized: false }, { tls }), - "UNABLE_TO_VERIFY_LEAF_SIGNATURE", - ); - } - }); - - test("crl", async () => { - await using server = await serve({ key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }); - const trust = { ca: read("ca2-cert.pem"), servername: "agent3" }; - // ca2-crl.pem revokes agent4 only. - assert.strictEqual(await outcome(server.port, { ...trust, crl: read("ca2-crl.pem") }), "authorized=false"); - assert.strictEqual(await outcome(server.port, { ...trust, crl: read("ca2-crl-agent3.pem") }), "CERT_REVOKED"); - }); - - test("checkServerIdentity", async () => { - await using server = await serve(); - const seen: unknown[] = []; - function accept(hostname: string, peer: { subject: { CN: string } }) { - seen.push(hostname, peer.subject.CN); - return undefined; - } - function refuse() { - return Object.assign(new Error("not the pinned certificate"), { code: "ERR_TEST_PIN" }); - } - assert.strictEqual(await outcome(server.port, { ca, checkServerIdentity: accept as any }), "authorized=false"); - assert.deepStrictEqual(seen, ["127.0.0.1", "agent1"]); - assert.strictEqual(await outcome(server.port, { ca, servername, checkServerIdentity: refuse }), "ERR_TEST_PIN"); - }); - - test("minVersion and maxVersion", async () => { - await using server = await serve({ key, cert, minVersion: "TLSv1.3" }); - const trust = { ca, servername }; - assert.strictEqual(await outcome(server.port, { ...trust, minVersion: "TLSv1.2" }), "authorized=false"); - // Node reports the alert as EPROTO. - assert.match(await outcome(server.port, { ...trust, maxVersion: "TLSv1.2" }), /EPROTO|PROTOCOL_VERSION/); - assert.strictEqual( - await outcome(server.port, { ...trust, minVersion: "TLSv9" as any }), - "ERR_TLS_INVALID_PROTOCOL_VERSION", - ); - }); - - test("allowPartialTrustChain", async () => { - // ca1 signs ca3, ca3 signs agent6. The server sends agent6 and ca3. - await using server = await serve({ key: read("agent6-key.pem"), cert: read("agent6-cert.pem") }); - const trust = { ca: read("ca3-cert.pem"), checkServerIdentity: () => undefined }; - assert.strictEqual(await outcome(server.port, { ...trust, allowPartialTrustChain: true }), "authorized=false"); - assert.strictEqual(await outcome(server.port, trust), "UNABLE_TO_GET_ISSUER_CERT"); - // Node takes any truthy value. - if (process.versions.bun) { - assert.strictEqual( - await outcome(server.port, { ...trust, allowPartialTrustChain: 1 as any }), - "UNABLE_TO_GET_ISSUER_CERT", - ); - } - }); - - test("ciphers, sigalgs, ecdhCurve and secureOptions", async () => { - const trust = { ca, servername }; - const refused = /EPROTO|HANDSHAKE_FAILURE|PROTOCOL_VERSION|NO_PROTOCOLS_AVAILABLE/; - await using aes256 = await serve({ key, cert, maxVersion: "TLSv1.2", ciphers: "ECDHE-RSA-AES256-GCM-SHA384" }); - await using sha512 = await serve({ key, cert, sigalgs: "rsa_pss_rsae_sha512" }); - await using p384 = await serve({ key, cert, ecdhCurve: "P-384" }); - await using tls13 = await serve({ key, cert, minVersion: "TLSv1.3" }); - for (const [port, accepted, rejected] of [ - [aes256.port, { ciphers: "ECDHE-RSA-AES256-GCM-SHA384" }, { ciphers: "ECDHE-RSA-AES128-GCM-SHA256" }], - [sha512.port, { sigalgs: "rsa_pss_rsae_sha512" }, { sigalgs: "rsa_pss_rsae_sha256" }], - [p384.port, { ecdhCurve: "P-384" }, { ecdhCurve: "X25519" }], - [tls13.port, { secureOptions: constants.SSL_OP_NO_TLSv1_2 }, { secureOptions: constants.SSL_OP_NO_TLSv1_3 }], - ] as const) { - assert.strictEqual(await outcome(port, { ...trust, ...accepted }), "authorized=false"); - assert.match(await outcome(port, { ...trust, ...rejected }), refused, JSON.stringify(rejected)); - } - }); - - test("ciphers that name TLS 1.3 suites", async () => { - await using server = await serveNegotiated(); - await using tls12 = await serveNegotiated({ maxVersion: "TLSv1.2" }); - const trust = { ca, servername }; - const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; - const refused = /EPROTO|PROTOCOL_VERSION|NO_CIPHERS_AVAILABLE|NO_PROTOCOLS_AVAILABLE/; - // BoringSSL has no list of TLS 1.3 suites to restrict, so only Node negotiates the one that is named. - assert.match(await outcome(server.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384" }), /^TLSv1\.3 /); - assert.match(await outcome(tls12.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384" }), refused); - assert.match( - await outcome(server.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384", maxVersion: "TLSv1.2" }), - refused, - ); - assert.match(await outcome(server.port, { ...trust, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), /^TLSv1\.3 /); - assert.strictEqual( - await outcome(tls12.port, { ...trust, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), - `TLSv1.2 ${aes256}`, - ); - assert.match(await outcome(server.port, { ...trust, ciphers: aes256 }), /^TLSv1\.3 /); - assert.strictEqual(await outcome(tls12.port, { ...trust, ciphers: aes256 }), `TLSv1.2 ${aes256}`); - }); - - test("secureProtocol", async () => { - await using server = await serveNegotiated(); - const trust = { ca, servername }; - assert.match(await outcome(server.port, { ...trust, secureProtocol: "TLSv1_2_method" }), /^TLSv1\.2 /); - assert.match(await outcome(server.port, { ...trust, secureProtocol: "TLS_method" }), /^TLSv1\.3 /); - // There is no such method. - assert.strictEqual( - await outcome(server.port, { ...trust, secureProtocol: "TLSv1_3_method" }), - "ERR_TLS_INVALID_PROTOCOL_METHOD", - ); - assert.strictEqual( - await outcome(server.port, { ...trust, secureProtocol: "TLSv1_2_method", minVersion: "TLSv1.3" }), - "ERR_TLS_PROTOCOL_VERSION_CONFLICT", - ); - }); - - test("an agent that is a function of the request URL", async () => { - await using server = await serve(); - const agent = new https.Agent({ ca, servername }); - // node-fetch v2 passes a legacy url.parse() object and v3 a WHATWG URL. Both have these fields. - let calledWith: URL | undefined; - try { - assert.strictEqual(await outcome(server.port, url => ((calledWith = url), agent)), "authorized=false"); - assert.deepStrictEqual( - { protocol: calledWith?.protocol, hostname: calledWith?.hostname, port: String(calledWith?.port) }, - { protocol: "https:", hostname: "127.0.0.1", port: String(server.port) }, - ); - } finally { - agent.destroy(); - } - }); -}); diff --git a/test/js/node/http/node-fetch.test.js b/test/js/node/http/node-fetch.test.js index c8a464e0e044..4be59733df13 100644 --- a/test/js/node/http/node-fetch.test.js +++ b/test/js/node/http/node-fetch.test.js @@ -1,11 +1,17 @@ import * as vercelFetch from "@vercel/fetch"; import * as iso from "isomorphic-fetch"; import fetch2, { fetch, Headers, Request, Response } from "node-fetch"; +import assert from "node:assert"; +import { constants, createPrivateKey } from "node:crypto"; import { once } from "node:events"; +import { readFileSync } from "node:fs"; import http from "node:http"; +import https from "node:https"; +import path from "node:path"; +import tls from "node:tls"; import * as stream from "stream"; -import { afterEach, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; const originalResponse = globalThis.Response; const originalRequest = globalThis.Request; @@ -484,3 +490,243 @@ test("node-fetch request body streams properly", async () => { expect(allData).toBe("first chunksecond chunkthird chunk"); expect(requestBodyComplete).toBe(true); }); + +// @kubernetes/client-node passes an https.Agent (with ca/cert/key) to node-fetch: https://github.com/oven-sh/bun/issues/19754 +// The verdicts are those of Node.js v26.3.0 with node-fetch 2.6.13 and 3.3.2, but where a comment says what Node does. +describe("node-fetch applies the TLS options of the agent", () => { + const read = name => readFileSync(path.join(import.meta.dirname, "..", "test", "fixtures", "keys", name)); + // ca1 signs agent1, ca2 signs agent3. No certificate has a SAN, so clients verify CN under `servername`. + const ca = read("ca1-cert.pem"); + const key = read("agent1-key.pem"); + const cert = read("agent1-cert.pem"); + const servername = "agent1"; + + async function serve(options = { key, cert }) { + const server = https.createServer(options, (req, res) => { + res.writeHead(200, { connection: "close" }); + res.end(`authorized=${req.socket.authorized}`); + }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + port: server.address().port, + async [Symbol.asyncDispose]() { + server.close(); + await once(server, "close"); + }, + }; + } + + /** Answers each request with the protocol version and the cipher of its connection. */ + async function serveNegotiated(options) { + const server = tls.createServer({ key, cert, ...options }, socket => { + socket.on("error", () => {}); + socket.once("data", () => { + const body = `${socket.getProtocol()} ${socket.getCipher().name}`; + socket.end(`HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n${body}`); + }); + }); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { + port: server.address().port, + async [Symbol.asyncDispose]() { + server.close(); + await once(server, "close"); + }, + }; + } + + /** The response body, or the code of the rejection. */ + async function outcome(port, options, init) { + const agent = typeof options === "function" || options instanceof https.Agent ? options : new https.Agent(options); + try { + return await (await fetch2(`https://127.0.0.1:${port}/`, { agent, ...init })).text(); + } catch (err) { + return String(err.code); + } finally { + if (typeof agent !== "function") agent.destroy(); + } + } + + test("ca and servername", async () => { + await using server = await serve(); + assert.strictEqual(await outcome(server.port, { ca, servername }), "authorized=false"); + assert.strictEqual(await outcome(server.port, { ca, servername, dhparam: "auto" }), "authorized=false"); + assert.strictEqual(await outcome(server.port, { ca }), "ERR_TLS_CERT_ALTNAME_INVALID"); + assert.strictEqual(await outcome(server.port, { servername }), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + }); + + test("the client identity, in every form", async () => { + await using server = await serve({ key, cert, ca, requestCert: true, rejectUnauthorized: false }); + const trust = { ca, servername }; + const encryptedKey = createPrivateKey(key).export({ + type: "pkcs8", + format: "pem", + cipher: "aes-256-cbc", + passphrase: "sample", + }); + assert.deepStrictEqual( + { + none: await outcome(server.port, trust), + pem: await outcome(server.port, { ...trust, cert, key }), + pemObject: await outcome(server.port, { ...trust, cert, key: [{ pem: key }] }), + encrypted: await outcome(server.port, { + ...trust, + cert, + key: encryptedKey, + passphrase: "sample", + }), + pfx: await outcome(server.port, { ...trust, pfx: read("agent1.pfx"), passphrase: "sample" }), + pfxObject: await outcome(server.port, { ...trust, pfx: [{ buf: read("agent1.pfx"), passphrase: "sample" }] }), + }, + { + none: "authorized=false", + pem: "authorized=true", + pemObject: "authorized=true", + encrypted: "authorized=true", + pfx: "authorized=true", + pfxObject: "authorized=true", + }, + ); + }); + + test("rejectUnauthorized disables verification only when it is false", async () => { + await using server = await serve(); + assert.strictEqual(await outcome(server.port, { rejectUnauthorized: false }), "authorized=false"); + for (const rejectUnauthorized of [0, null, "false", undefined, true]) { + assert.strictEqual(await outcome(server.port, { rejectUnauthorized }), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + } + // Node reads both through the prototype chain. + const off = new https.Agent({ rejectUnauthorized: false }); + assert.strictEqual(await outcome(server.port, Object.create(off)), "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + await assert.rejects(fetch2(`https://127.0.0.1:${server.port}/`, Object.create({ agent: off })), { + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + const tls = { rejectUnauthorized: true }; + assert.strictEqual( + await outcome(server.port, { rejectUnauthorized: false }, { tls }), + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + ); + }); + + test("crl", async () => { + await using server = await serve({ key: read("agent3-key.pem"), cert: read("agent3-cert.pem") }); + const trust = { ca: read("ca2-cert.pem"), servername: "agent3" }; + // ca2-crl.pem revokes agent4 only. + assert.strictEqual(await outcome(server.port, { ...trust, crl: read("ca2-crl.pem") }), "authorized=false"); + assert.strictEqual(await outcome(server.port, { ...trust, crl: read("ca2-crl-agent3.pem") }), "CERT_REVOKED"); + }); + + test("checkServerIdentity", async () => { + await using server = await serve(); + const seen = []; + function accept(hostname, peer) { + seen.push(hostname, peer.subject.CN); + return undefined; + } + function refuse() { + return Object.assign(new Error("not the pinned certificate"), { code: "ERR_TEST_PIN" }); + } + assert.strictEqual(await outcome(server.port, { ca, checkServerIdentity: accept }), "authorized=false"); + assert.deepStrictEqual(seen, ["127.0.0.1", "agent1"]); + assert.strictEqual(await outcome(server.port, { ca, servername, checkServerIdentity: refuse }), "ERR_TEST_PIN"); + }); + + test("minVersion and maxVersion", async () => { + await using server = await serve({ key, cert, minVersion: "TLSv1.3" }); + const trust = { ca, servername }; + assert.strictEqual(await outcome(server.port, { ...trust, minVersion: "TLSv1.2" }), "authorized=false"); + // Node reports the alert as EPROTO. + assert.match(await outcome(server.port, { ...trust, maxVersion: "TLSv1.2" }), /EPROTO|PROTOCOL_VERSION/); + assert.strictEqual( + await outcome(server.port, { ...trust, minVersion: "TLSv9" }), + "ERR_TLS_INVALID_PROTOCOL_VERSION", + ); + }); + + test("allowPartialTrustChain", async () => { + // ca1 signs ca3, ca3 signs agent6. The server sends agent6 and ca3. + await using server = await serve({ key: read("agent6-key.pem"), cert: read("agent6-cert.pem") }); + const trust = { ca: read("ca3-cert.pem"), checkServerIdentity: () => undefined }; + assert.strictEqual(await outcome(server.port, { ...trust, allowPartialTrustChain: true }), "authorized=false"); + assert.strictEqual(await outcome(server.port, trust), "UNABLE_TO_GET_ISSUER_CERT"); + // Node takes any truthy value. + assert.strictEqual( + await outcome(server.port, { ...trust, allowPartialTrustChain: 1 }), + "UNABLE_TO_GET_ISSUER_CERT", + ); + }); + + test("ciphers, sigalgs, ecdhCurve and secureOptions", async () => { + const trust = { ca, servername }; + const refused = /EPROTO|HANDSHAKE_FAILURE|PROTOCOL_VERSION|NO_PROTOCOLS_AVAILABLE/; + await using aes256 = await serve({ key, cert, maxVersion: "TLSv1.2", ciphers: "ECDHE-RSA-AES256-GCM-SHA384" }); + await using sha512 = await serve({ key, cert, sigalgs: "rsa_pss_rsae_sha512" }); + await using p384 = await serve({ key, cert, ecdhCurve: "P-384" }); + await using tls13 = await serve({ key, cert, minVersion: "TLSv1.3" }); + for (const [port, accepted, rejected] of [ + [aes256.port, { ciphers: "ECDHE-RSA-AES256-GCM-SHA384" }, { ciphers: "ECDHE-RSA-AES128-GCM-SHA256" }], + [sha512.port, { sigalgs: "rsa_pss_rsae_sha512" }, { sigalgs: "rsa_pss_rsae_sha256" }], + [p384.port, { ecdhCurve: "P-384" }, { ecdhCurve: "X25519" }], + [tls13.port, { secureOptions: constants.SSL_OP_NO_TLSv1_2 }, { secureOptions: constants.SSL_OP_NO_TLSv1_3 }], + ]) { + assert.strictEqual(await outcome(port, { ...trust, ...accepted }), "authorized=false"); + assert.match(await outcome(port, { ...trust, ...rejected }), refused, JSON.stringify(rejected)); + } + }); + + test("ciphers that name TLS 1.3 suites", async () => { + await using server = await serveNegotiated(); + await using tls12 = await serveNegotiated({ maxVersion: "TLSv1.2" }); + const trust = { ca, servername }; + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + const refused = /EPROTO|PROTOCOL_VERSION|NO_CIPHERS_AVAILABLE|NO_PROTOCOLS_AVAILABLE/; + // BoringSSL has no list of TLS 1.3 suites to restrict, so only Node negotiates the one that is named. + assert.match(await outcome(server.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384" }), /^TLSv1\.3 /); + assert.match(await outcome(tls12.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384" }), refused); + assert.match( + await outcome(server.port, { ...trust, ciphers: "TLS_AES_256_GCM_SHA384", maxVersion: "TLSv1.2" }), + refused, + ); + assert.match(await outcome(server.port, { ...trust, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), /^TLSv1\.3 /); + assert.strictEqual( + await outcome(tls12.port, { ...trust, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), + `TLSv1.2 ${aes256}`, + ); + assert.match(await outcome(server.port, { ...trust, ciphers: aes256 }), /^TLSv1\.3 /); + assert.strictEqual(await outcome(tls12.port, { ...trust, ciphers: aes256 }), `TLSv1.2 ${aes256}`); + }); + + test("secureProtocol", async () => { + await using server = await serveNegotiated(); + const trust = { ca, servername }; + assert.match(await outcome(server.port, { ...trust, secureProtocol: "TLSv1_2_method" }), /^TLSv1\.2 /); + assert.match(await outcome(server.port, { ...trust, secureProtocol: "TLS_method" }), /^TLSv1\.3 /); + // There is no such method. + assert.strictEqual( + await outcome(server.port, { ...trust, secureProtocol: "TLSv1_3_method" }), + "ERR_TLS_INVALID_PROTOCOL_METHOD", + ); + assert.strictEqual( + await outcome(server.port, { ...trust, secureProtocol: "TLSv1_2_method", minVersion: "TLSv1.3" }), + "ERR_TLS_PROTOCOL_VERSION_CONFLICT", + ); + }); + + test("an agent that is a function of the request URL", async () => { + await using server = await serve(); + const agent = new https.Agent({ ca, servername }); + // node-fetch v2 passes a legacy url.parse() object and v3 a WHATWG URL. Both have these fields. + let calledWith; + try { + assert.strictEqual(await outcome(server.port, url => ((calledWith = url), agent)), "authorized=false"); + assert.deepStrictEqual( + { protocol: calledWith?.protocol, hostname: calledWith?.hostname, port: String(calledWith?.port) }, + { protocol: "https:", hostname: "127.0.0.1", port: String(server.port) }, + ); + } finally { + agent.destroy(); + } + }); +}); diff --git a/test/js/node/http/node-http-agent-tls-options.test.mts b/test/js/node/http/node-http-agent-tls-options.test.mts index 6fa19798efe8..1c93906cc469 100644 --- a/test/js/node/http/node-http-agent-tls-options.test.mts +++ b/test/js/node/http/node-http-agent-tls-options.test.mts @@ -872,6 +872,281 @@ describe( }, ); +// https.Agent#getName() names the socket pool and the TLS session cache, so two requests whose options make a different +// connection must get different names, whatever form the options take. Node fixed the `pfx: [{ buf, passphrase }]` form +// in nodejs/node 9f03017f38 (CVE-2026-56850). +const isBun = !!process.versions.bun; +// Node ships the fix from v22.23.2, v24.18.1 and v26.5.1. An older Node shares the pool, so +// the pfx cases only describe it from those versions on. Bun always runs them. +const runtimeHasFix = (() => { + if (isBun) return true; + const [major, minor, patch] = process.versions.node.split(".").map(Number); + const atLeast = (fixedMinor: number, fixedPatch: number) => + minor > fixedMinor || (minor === fixedMinor && patch >= fixedPatch); + if (major === 22) return atLeast(23, 2); + if (major === 24) return atLeast(18, 1); + if (major === 26) return atLeast(5, 1); + return major > 26; +})(); +const fixedTest = runtimeHasFix ? test : test.skip; +const bunTest = isBun ? test : test.skip; + +const keys = join(__dirname, "..", "test", "fixtures", "keys"); +const read = (name: string) => readFileSync(join(keys, name)); +const readArrayBuffer = (name: string) => { + const buffer = read(name); + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); +}; + +// agent1 is CN=agent1, agent10 is CN=agent10.example.com. Both .pfx files use "sample". +type Identity = "agent1" | "agent10"; +type Forms = Record object>; +const commonName = { agent1: "agent1", agent10: "agent10.example.com" }; + +const nodeForms: Forms = { + "pfx: [{ buf, passphrase }]": id => ({ pfx: [{ buf: read(`${id}.pfx`), passphrase: "sample" }] }), +}; +const bunForms: Forms = { + "pfx: { buf, passphrase }": id => ({ pfx: { buf: read(`${id}.pfx`), passphrase: "sample" } }), + "pfx: [ArrayBuffer]": id => ({ pfx: [readArrayBuffer(`${id}.pfx`)], passphrase: "sample" }), + "cert, key: ArrayBuffer": id => ({ + cert: readArrayBuffer(`${id}-cert.pem`), + key: readArrayBuffer(`${id}-key.pem`), + }), + "cert, key: BunFile": id => ({ + cert: Bun.file(join(keys, `${id}-cert.pem`)), + key: Bun.file(join(keys, `${id}-key.pem`)), + }), + "certFile, keyFile": id => ({ certFile: join(keys, `${id}-cert.pem`), keyFile: join(keys, `${id}-key.pem`) }), + // Node v26.5.1 shares the connection. + "secureContext": id => ({ + secureContext: tls.createSecureContext({ cert: read(`${id}-cert.pem`), key: read(`${id}-key.pem`) }), + }), +}; +const forms: Forms = isBun ? { ...nodeForms, ...bunForms } : nodeForms; + +// Answers every request with the CN of the client certificate the connection authenticated with. +async function listen() { + const server = tls.createServer( + { key: read("agent2-key.pem"), cert: read("agent2-cert.pem"), requestCert: true, rejectUnauthorized: false }, + socket => { + socket.on("error", () => {}); + socket.on("data", () => { + const body = String(socket.getPeerCertificate().subject?.CN); + socket.write(`HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n${body}`); + }); + }, + ); + await once(server.listen(0, "127.0.0.1"), "listening"); + return { server, port: (server.address() as AddressInfo).port }; +} + +async function get(agent: https.Agent, port: number, options: object) { + const req = https.get({ host: "127.0.0.1", port, agent, rejectUnauthorized: false, ...options }); + const [res] = await once(req, "response"); + res.setEncoding("utf8"); + let peer = ""; + res.on("data", (chunk: string) => (peer += chunk)); + await once(res, "end"); + return { peer, reusedSocket: req.reusedSocket }; +} + +// One agent per form, all forms at once: { [form]: the answers to `identities` in order }. +async function answersByForm(agentOptions: https.AgentOptions, identities: Identity[], reuseOptions: boolean) { + const { server, port } = await listen(); + try { + const entries = await Promise.all( + Object.entries(forms).map(async ([form, optionsFor]) => { + const agent = new https.Agent(agentOptions); + const cached: Partial> = {}; + try { + const answers: object[] = []; + for (const id of identities) { + const options = reuseOptions ? (cached[id] ??= optionsFor(id)) : optionsFor(id); + answers.push(await get(agent, port, options)); + } + return [form, answers]; + } finally { + agent.destroy(); + } + }), + ); + return Object.fromEntries(entries); + } finally { + server.close(); + } +} +const expectedByForm = (answers: object[]) => Object.fromEntries(Object.keys(forms).map(form => [form, answers])); + +describe("https.Agent keeps client certificates apart", () => { + fixedTest("a pooled socket is not shared across client certificates", async () => { + // The third request passes the first request's option values again, so it pools. + assert.deepStrictEqual( + await answersByForm({ keepAlive: true }, ["agent1", "agent10", "agent1"], true), + expectedByForm([ + { peer: commonName.agent1, reusedSocket: false }, + { peer: commonName.agent10, reusedSocket: false }, + { peer: commonName.agent1, reusedSocket: true }, + ]), + ); + }); + + // Without keepAlive every request opens a connection, but the agent still offers the + // session it cached under the same name, and a resumed session keeps its first identity. + fixedTest("a cached TLS session is not resumed across client certificates", async () => { + assert.deepStrictEqual( + await answersByForm({ keepAlive: false }, ["agent1", "agent10"], false), + expectedByForm([ + { peer: commonName.agent1, reusedSocket: false }, + { peer: commonName.agent10, reusedSocket: false }, + ]), + ); + }); +}); + +// The server sends agent6 and ca3, the intermediate that signed it. ca1 signed ca3, ca2 signed neither. +describe("https.Agent keeps trust settings apart", () => { + const rows: [string, () => object, () => object, string][] = [ + [ + "allowPartialTrustChain", + () => ({ ca: read("ca3-cert.pem"), allowPartialTrustChain: true }), + () => ({ ca: read("ca3-cert.pem") }), + "UNABLE_TO_GET_ISSUER_CERT", + ], + [ + "caFile", + () => ({ caFile: join(keys, "ca1-cert.pem") }), + () => ({ caFile: join(keys, "ca2-cert.pem") }), + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + ], + [ + "secureContext", + () => ({ secureContext: tls.createSecureContext({ ca: read("ca1-cert.pem") }) }), + () => ({ secureContext: tls.createSecureContext({ ca: read("ca2-cert.pem") }) }), + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + ], + ]; + for (const [option, trusting, distrusting, code] of rows) { + for (const keepAlive of [true, false]) { + bunTest(`${option}: the second request is verified again, keepAlive: ${keepAlive}`, async () => { + const server = tls.createServer({ key: read("agent6-key.pem"), cert: read("agent6-cert.pem") }, socket => { + socket.on("error", () => {}); + socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + // On the Agent: a request with its own checkServerIdentity never shares a connection. + const agent = new https.Agent({ keepAlive, checkServerIdentity: () => undefined }); + const outcome = async (options: object) => { + const req = https.get({ host: "127.0.0.1", port: (server.address() as AddressInfo).port, agent, ...options }); + try { + const [res] = await once(req, "response"); + res.resume(); + await once(res, "end"); + return res.statusCode; + } catch (error) { + return (error as NodeJS.ErrnoException).code; + } + }; + try { + assert.deepStrictEqual([await outcome(trusting()), await outcome(distrusting())], [200, code]); + } finally { + agent.destroy(); + server.close(); + } + }); + } + } +}); + +describe("https.Agent#getName", () => { + const agent = new https.Agent(); + const name = (options: object) => agent.getName({ host: "localhost", port: 443, ...options }); + + // The names Node v26.5.1 gives, from getPfxAgentKey(). + fixedTest("a pfx array has Node's name", () => { + assert.strictEqual( + name({ pfx: [Buffer.from("a"), { buf: "b", passphrase: "p" }], passphrase: "q" }), + "localhost:443::::::::a:q:b:p::::::::::::::", + ); + assert.strictEqual( + name({ pfx: [Buffer.from("a"), Buffer.from("b")] }), + "localhost:443::::::::a:undefined:b:undefined::::::::::::::", + ); + assert.strictEqual(name({ pfx: Buffer.from("a"), passphrase: "q" }), "localhost:443:::::::a::::::::::::::"); + }); + + fixedTest("`pfx: [{ buf, passphrase }]` is keyed by buf and passphrase", () => { + const buf = read("agent1.pfx"); + const base = name({ pfx: [{ buf, passphrase: "sample" }] }); + assert.strictEqual(name({ pfx: [{ buf: Buffer.from(buf), passphrase: "sample" }] }), base); + assert.strictEqual(name({ pfx: [{ buf }], passphrase: "sample" }), base); + assert.notStrictEqual(name({ pfx: [{ buf: read("agent10.pfx"), passphrase: "sample" }] }), base); + assert.notStrictEqual(name({ pfx: [{ buf, passphrase: "different" }] }), base); + assert.notStrictEqual( + name({ pfx: [{ __proto__: { buf, passphrase: "sample" } }] }), + name({ pfx: [{ __proto__: { buf: read("agent10.pfx"), passphrase: "sample" } }] }), + ); + }); + + for (const form of Object.keys(bunForms)) { + bunTest(`differs by content or identity, ${form}`, () => { + const agent1 = bunForms[form]("agent1"); + assert.strictEqual(name(agent1), name(agent1)); + assert.notStrictEqual(name(agent1), name(bunForms[form]("agent10"))); + }); + } + + bunTest("`key: [{ pem, passphrase }]` is keyed by pem and not by passphrase", () => { + const pem = read("agent1-key.pem"); + const base = name({ key: pem }); + assert.strictEqual(name({ key: [{ pem, passphrase: "a" }] }), base); + assert.strictEqual(name({ key: [{ pem: readArrayBuffer("agent1-key.pem"), passphrase: "b" }] }), base); + assert.strictEqual(name({ key: { pem } }), base); + assert.notStrictEqual(name({ key: [{ pem: read("agent10-key.pem"), passphrase: "a" }] }), base); + }); + + bunTest("equal bytes share a name across string, Buffer, ArrayBuffer, DataView and array forms", () => { + const cert = read("agent1-cert.pem"); + const base = name({ cert: cert.toString() }); + assert.strictEqual(name({ cert }), base); + assert.strictEqual(name({ cert: readArrayBuffer("agent1-cert.pem") }), base); + assert.strictEqual(name({ cert: [readArrayBuffer("agent1-cert.pem")] }), base); + assert.strictEqual( + name({ pfx: [{ buf: readArrayBuffer("agent1.pfx"), passphrase: "sample" }] }), + name({ pfx: [{ buf: read("agent1.pfx"), passphrase: "sample" }] }), + ); + const pfx = read("agent1.pfx"); + assert.strictEqual(name({ pfx: new DataView(pfx.buffer, pfx.byteOffset, pfx.byteLength) }), name({ pfx })); + // What Node names by contents keeps Node's name. + assert.strictEqual( + name({ ca: ["a", null, undefined, Buffer.from("b")], key: ["k1", new Uint8Array([1, 2])], pfx: "pfx" }), + "localhost:443::a,,,b::::k1,1,2:pfx::::::::::::::", + ); + }); + + bunTest("certFile, keyFile and caFile are labelled parts of the name", () => { + const base = name({}); + assert.strictEqual( + name({ certFile: "a", keyFile: "b", caFile: "c" }), + `${base}:certFile="a":keyFile="b":caFile="c"`, + ); + assert.notStrictEqual(name({ certFile: "a" }), name({ keyFile: "a" })); + assert.notStrictEqual(name({ caFile: "a" }), name({ caFile: "b" })); + // A path cannot spell the next part. + assert.notStrictEqual(name({ certFile: 'a":keyFile="b' }), name({ certFile: "a", keyFile: "b" })); + }); + + bunTest("allowPartialTrustChain and secureContext are parts of the name when they are set", () => { + const base = name({}); + assert.strictEqual(name({ allowPartialTrustChain: true }), `${base}:allowPartialTrustChain`); + assert.strictEqual(name({ allowPartialTrustChain: false, secureContext: undefined }), base); + const secureContext = tls.createSecureContext(); + assert.strictEqual(name({ secureContext }), name({ secureContext })); + assert.notStrictEqual(name({ secureContext }), name({ secureContext: tls.createSecureContext() })); + assert.notStrictEqual(name({ secureContext }), base); + }); +}); + // Only run in Bun to avoid infinite loop when Node.js runs this file if (typeof Bun !== "undefined") { const { bunEnv, bunExe, nodeExe } = await import("harness"); diff --git a/test/js/node/http/node-https-agent-getname.test.ts b/test/js/node/http/node-https-agent-getname.test.ts deleted file mode 100644 index 13553bb541c8..000000000000 --- a/test/js/node/http/node-https-agent-getname.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Runs in Bun and in Node.js, where the cases that need Bun-only values or a Node.js release with the fix are skipped. - * - * https.Agent#getName() names the socket pool and the TLS session cache, so two requests whose options make a - * different connection must get different names, whatever form the options take. Node fixed the - * `pfx: [{ buf, passphrase }]` form in nodejs/node 9f03017f38 (CVE-2026-56850). - */ -import assert from "node:assert"; -import { once } from "node:events"; -import { readFileSync } from "node:fs"; -import https from "node:https"; -import type { AddressInfo } from "node:net"; -import { dirname, join } from "node:path"; -import { describe, test } from "node:test"; -import tls from "node:tls"; -import { fileURLToPath } from "node:url"; - -const isBun = !!process.versions.bun; -// Node ships the fix from v22.23.2, v24.18.1 and v26.5.1. An older Node shares the pool, so -// the pfx cases only describe it from those versions on. Bun always runs them. -const runtimeHasFix = (() => { - if (isBun) return true; - const [major, minor, patch] = process.versions.node.split(".").map(Number); - const atLeast = (fixedMinor: number, fixedPatch: number) => - minor > fixedMinor || (minor === fixedMinor && patch >= fixedPatch); - if (major === 22) return atLeast(23, 2); - if (major === 24) return atLeast(18, 1); - if (major === 26) return atLeast(5, 1); - return major > 26; -})(); -const fixedTest = runtimeHasFix ? test : test.skip; -const bunTest = isBun ? test : test.skip; - -const keys = join(dirname(fileURLToPath(import.meta.url)), "..", "test", "fixtures", "keys"); -const read = (name: string) => readFileSync(join(keys, name)); -const readArrayBuffer = (name: string) => { - const buffer = read(name); - return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); -}; - -// agent1 is CN=agent1, agent10 is CN=agent10.example.com. Both .pfx files use "sample". -type Identity = "agent1" | "agent10"; -type Forms = Record object>; -const commonName = { agent1: "agent1", agent10: "agent10.example.com" }; - -const nodeForms: Forms = { - "pfx: [{ buf, passphrase }]": id => ({ pfx: [{ buf: read(`${id}.pfx`), passphrase: "sample" }] }), -}; -const bunForms: Forms = { - "pfx: { buf, passphrase }": id => ({ pfx: { buf: read(`${id}.pfx`), passphrase: "sample" } }), - "pfx: [ArrayBuffer]": id => ({ pfx: [readArrayBuffer(`${id}.pfx`)], passphrase: "sample" }), - "cert, key: ArrayBuffer": id => ({ - cert: readArrayBuffer(`${id}-cert.pem`), - key: readArrayBuffer(`${id}-key.pem`), - }), - "cert, key: BunFile": id => ({ - cert: Bun.file(join(keys, `${id}-cert.pem`)), - key: Bun.file(join(keys, `${id}-key.pem`)), - }), - "certFile, keyFile": id => ({ certFile: join(keys, `${id}-cert.pem`), keyFile: join(keys, `${id}-key.pem`) }), - // Node v26.5.1 shares the connection. - "secureContext": id => ({ - secureContext: tls.createSecureContext({ cert: read(`${id}-cert.pem`), key: read(`${id}-key.pem`) }), - }), -}; -const forms: Forms = isBun ? { ...nodeForms, ...bunForms } : nodeForms; - -// Answers every request with the CN of the client certificate the connection authenticated with. -async function listen() { - const server = tls.createServer( - { key: read("agent2-key.pem"), cert: read("agent2-cert.pem"), requestCert: true, rejectUnauthorized: false }, - socket => { - socket.on("error", () => {}); - socket.on("data", () => { - const body = String(socket.getPeerCertificate().subject?.CN); - socket.write(`HTTP/1.1 200 OK\r\nContent-Length: ${body.length}\r\n\r\n${body}`); - }); - }, - ); - await once(server.listen(0, "127.0.0.1"), "listening"); - return { server, port: (server.address() as AddressInfo).port }; -} - -async function get(agent: https.Agent, port: number, options: object) { - const req = https.get({ host: "127.0.0.1", port, agent, rejectUnauthorized: false, ...options }); - const [res] = await once(req, "response"); - res.setEncoding("utf8"); - let peer = ""; - res.on("data", (chunk: string) => (peer += chunk)); - await once(res, "end"); - return { peer, reusedSocket: req.reusedSocket }; -} - -// One agent per form, all forms at once: { [form]: the answers to `identities` in order }. -async function answersByForm(agentOptions: https.AgentOptions, identities: Identity[], reuseOptions: boolean) { - const { server, port } = await listen(); - try { - const entries = await Promise.all( - Object.entries(forms).map(async ([form, optionsFor]) => { - const agent = new https.Agent(agentOptions); - const cached: Partial> = {}; - try { - const answers: object[] = []; - for (const id of identities) { - const options = reuseOptions ? (cached[id] ??= optionsFor(id)) : optionsFor(id); - answers.push(await get(agent, port, options)); - } - return [form, answers]; - } finally { - agent.destroy(); - } - }), - ); - return Object.fromEntries(entries); - } finally { - server.close(); - } -} -const expectedByForm = (answers: object[]) => Object.fromEntries(Object.keys(forms).map(form => [form, answers])); - -describe("https.Agent keeps client certificates apart", () => { - fixedTest("a pooled socket is not shared across client certificates", async () => { - // The third request passes the first request's option values again, so it pools. - assert.deepStrictEqual( - await answersByForm({ keepAlive: true }, ["agent1", "agent10", "agent1"], true), - expectedByForm([ - { peer: commonName.agent1, reusedSocket: false }, - { peer: commonName.agent10, reusedSocket: false }, - { peer: commonName.agent1, reusedSocket: true }, - ]), - ); - }); - - // Without keepAlive every request opens a connection, but the agent still offers the - // session it cached under the same name, and a resumed session keeps its first identity. - fixedTest("a cached TLS session is not resumed across client certificates", async () => { - assert.deepStrictEqual( - await answersByForm({ keepAlive: false }, ["agent1", "agent10"], false), - expectedByForm([ - { peer: commonName.agent1, reusedSocket: false }, - { peer: commonName.agent10, reusedSocket: false }, - ]), - ); - }); -}); - -// The server sends agent6 and ca3, the intermediate that signed it. ca1 signed ca3, ca2 signed neither. -describe("https.Agent keeps trust settings apart", () => { - const rows: [string, () => object, () => object, string][] = [ - [ - "allowPartialTrustChain", - () => ({ ca: read("ca3-cert.pem"), allowPartialTrustChain: true }), - () => ({ ca: read("ca3-cert.pem") }), - "UNABLE_TO_GET_ISSUER_CERT", - ], - [ - "caFile", - () => ({ caFile: join(keys, "ca1-cert.pem") }), - () => ({ caFile: join(keys, "ca2-cert.pem") }), - "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", - ], - [ - "secureContext", - () => ({ secureContext: tls.createSecureContext({ ca: read("ca1-cert.pem") }) }), - () => ({ secureContext: tls.createSecureContext({ ca: read("ca2-cert.pem") }) }), - "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", - ], - ]; - for (const [option, trusting, distrusting, code] of rows) { - for (const keepAlive of [true, false]) { - bunTest(`${option}: the second request is verified again, keepAlive: ${keepAlive}`, async () => { - const server = tls.createServer({ key: read("agent6-key.pem"), cert: read("agent6-cert.pem") }, socket => { - socket.on("error", () => {}); - socket.on("data", () => socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")); - }); - await once(server.listen(0, "127.0.0.1"), "listening"); - // On the Agent: a request with its own checkServerIdentity never shares a connection. - const agent = new https.Agent({ keepAlive, checkServerIdentity: () => undefined }); - const outcome = async (options: object) => { - const req = https.get({ host: "127.0.0.1", port: (server.address() as AddressInfo).port, agent, ...options }); - try { - const [res] = await once(req, "response"); - res.resume(); - await once(res, "end"); - return res.statusCode; - } catch (error) { - return (error as NodeJS.ErrnoException).code; - } - }; - try { - assert.deepStrictEqual([await outcome(trusting()), await outcome(distrusting())], [200, code]); - } finally { - agent.destroy(); - server.close(); - } - }); - } - } -}); - -describe("https.Agent#getName", () => { - const agent = new https.Agent(); - const name = (options: object) => agent.getName({ host: "localhost", port: 443, ...options }); - - // The names Node v26.5.1 gives, from getPfxAgentKey(). - fixedTest("a pfx array has Node's name", () => { - assert.strictEqual( - name({ pfx: [Buffer.from("a"), { buf: "b", passphrase: "p" }], passphrase: "q" }), - "localhost:443::::::::a:q:b:p::::::::::::::", - ); - assert.strictEqual( - name({ pfx: [Buffer.from("a"), Buffer.from("b")] }), - "localhost:443::::::::a:undefined:b:undefined::::::::::::::", - ); - assert.strictEqual(name({ pfx: Buffer.from("a"), passphrase: "q" }), "localhost:443:::::::a::::::::::::::"); - }); - - fixedTest("`pfx: [{ buf, passphrase }]` is keyed by buf and passphrase", () => { - const buf = read("agent1.pfx"); - const base = name({ pfx: [{ buf, passphrase: "sample" }] }); - assert.strictEqual(name({ pfx: [{ buf: Buffer.from(buf), passphrase: "sample" }] }), base); - assert.strictEqual(name({ pfx: [{ buf }], passphrase: "sample" }), base); - assert.notStrictEqual(name({ pfx: [{ buf: read("agent10.pfx"), passphrase: "sample" }] }), base); - assert.notStrictEqual(name({ pfx: [{ buf, passphrase: "different" }] }), base); - assert.notStrictEqual( - name({ pfx: [{ __proto__: { buf, passphrase: "sample" } }] }), - name({ pfx: [{ __proto__: { buf: read("agent10.pfx"), passphrase: "sample" } }] }), - ); - }); - - for (const form of Object.keys(bunForms)) { - bunTest(`differs by content or identity, ${form}`, () => { - const agent1 = bunForms[form]("agent1"); - assert.strictEqual(name(agent1), name(agent1)); - assert.notStrictEqual(name(agent1), name(bunForms[form]("agent10"))); - }); - } - - bunTest("`key: [{ pem, passphrase }]` is keyed by pem and not by passphrase", () => { - const pem = read("agent1-key.pem"); - const base = name({ key: pem }); - assert.strictEqual(name({ key: [{ pem, passphrase: "a" }] }), base); - assert.strictEqual(name({ key: [{ pem: readArrayBuffer("agent1-key.pem"), passphrase: "b" }] }), base); - assert.strictEqual(name({ key: { pem } }), base); - assert.notStrictEqual(name({ key: [{ pem: read("agent10-key.pem"), passphrase: "a" }] }), base); - }); - - bunTest("equal bytes share a name across string, Buffer, ArrayBuffer, DataView and array forms", () => { - const cert = read("agent1-cert.pem"); - const base = name({ cert: cert.toString() }); - assert.strictEqual(name({ cert }), base); - assert.strictEqual(name({ cert: readArrayBuffer("agent1-cert.pem") }), base); - assert.strictEqual(name({ cert: [readArrayBuffer("agent1-cert.pem")] }), base); - assert.strictEqual( - name({ pfx: [{ buf: readArrayBuffer("agent1.pfx"), passphrase: "sample" }] }), - name({ pfx: [{ buf: read("agent1.pfx"), passphrase: "sample" }] }), - ); - const pfx = read("agent1.pfx"); - assert.strictEqual(name({ pfx: new DataView(pfx.buffer, pfx.byteOffset, pfx.byteLength) }), name({ pfx })); - // What Node names by contents keeps Node's name. - assert.strictEqual( - name({ ca: ["a", null, undefined, Buffer.from("b")], key: ["k1", new Uint8Array([1, 2])], pfx: "pfx" }), - "localhost:443::a,,,b::::k1,1,2:pfx::::::::::::::", - ); - }); - - bunTest("certFile, keyFile and caFile are labelled parts of the name", () => { - const base = name({}); - assert.strictEqual( - name({ certFile: "a", keyFile: "b", caFile: "c" }), - `${base}:certFile="a":keyFile="b":caFile="c"`, - ); - assert.notStrictEqual(name({ certFile: "a" }), name({ keyFile: "a" })); - assert.notStrictEqual(name({ caFile: "a" }), name({ caFile: "b" })); - // A path cannot spell the next part. - assert.notStrictEqual(name({ certFile: 'a":keyFile="b' }), name({ certFile: "a", keyFile: "b" })); - }); - - bunTest("allowPartialTrustChain and secureContext are parts of the name when they are set", () => { - const base = name({}); - assert.strictEqual(name({ allowPartialTrustChain: true }), `${base}:allowPartialTrustChain`); - assert.strictEqual(name({ allowPartialTrustChain: false, secureContext: undefined }), base); - const secureContext = tls.createSecureContext(); - assert.strictEqual(name({ secureContext }), name({ secureContext })); - assert.notStrictEqual(name({ secureContext }), name({ secureContext: tls.createSecureContext() })); - assert.notStrictEqual(name({ secureContext }), base); - }); -}); From f046bf8c1823ed22e18064f84f1210a24b3d65cb Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:16:45 +0000 Subject: [PATCH 111/174] ws, node-fetch: drop an OpenSSL security level from ciphers `ciphers: "DEFAULT@SECLEVEL=0"` is a common way to reach an old server from Node. The shims ignored `ciphers` before they forwarded it, so such a client connected; forwarded as written, BoringSSL refuses the string. It has no security levels, so the token is dropped. --- src/js/internal/tls.ts | 5 ++++- test/js/first_party/ws/ws.test.ts | 6 ++++++ test/js/node/http/node-fetch.test.js | 10 ++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index cb5f0557081d..69acb1f19c61 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -2,6 +2,7 @@ const { isTypedArray, isArrayBuffer, isArrayBufferView } = require("node:util/ty const { validateString, validateBuffer } = require("internal/validators"); const StringPrototypeSplit = String.prototype.split; +const StringPrototypeReplace = String.prototype.replace; const StringPrototypeStartsWith = String.prototype.startsWith; const StringPrototypeIncludes = String.prototype.includes; const ArrayPrototypeFilter = Array.prototype.filter; @@ -394,7 +395,9 @@ function nodeClientTlsToNative(options) { options.dhparam = undefined; validateSecureContextOptions(options); if (options.ciphers) { - const { cipherList, tls13Only } = stripTls13CipherNames(options.ciphers); + // BoringSSL has no security levels to set. + const leveled = StringPrototypeReplace.$call(options.ciphers, /:?@SECLEVEL=\d/g, ""); + const { cipherList, tls13Only } = stripTls13CipherNames(leveled); options.ciphers = cipherList; if (tls13Only) options.minVersion = "TLSv1.3"; } diff --git a/test/js/first_party/ws/ws.test.ts b/test/js/first_party/ws/ws.test.ts index ceccfc78e404..a3febe24b1d9 100644 --- a/test/js/first_party/ws/ws.test.ts +++ b/test/js/first_party/ws/ws.test.ts @@ -1926,6 +1926,9 @@ describe("client TLS options", () => { mixedTo12: await dial(tls12.url, { ca, ciphers: `TLS_AES_256_GCM_SHA384:${aes256}` }), only12: version(await dial(server.url, { ca, ciphers: aes256 })), only12To12: await dial(tls12.url, { ca, ciphers: aes256 }), + secLevel: version(await dial(server.url, { ca, ciphers: "DEFAULT@SECLEVEL=0" })), + secLevelAgent: version(await dial(server.url, { agent: new https.Agent({ ca, ciphers: "DEFAULT:@SECLEVEL=1" }) })), // prettier-ignore + secLevelTo12: await dial(tls12.url, { ca, ciphers: `${aes256}:@SECLEVEL=0` }), }).toEqual({ only13: "TLSv1.3", only13Agent: "TLSv1.3", @@ -1935,6 +1938,9 @@ describe("client TLS options", () => { mixedTo12: `TLSv1.2 ${aes256}`, only12: "TLSv1.3", only12To12: `TLSv1.2 ${aes256}`, + secLevel: "TLSv1.3", + secLevelAgent: "TLSv1.3", + secLevelTo12: `TLSv1.2 ${aes256}`, }); }); diff --git a/test/js/node/http/node-fetch.test.js b/test/js/node/http/node-fetch.test.js index 4be59733df13..e06def4e4cc4 100644 --- a/test/js/node/http/node-fetch.test.js +++ b/test/js/node/http/node-fetch.test.js @@ -698,6 +698,16 @@ describe("node-fetch applies the TLS options of the agent", () => { assert.strictEqual(await outcome(tls12.port, { ...trust, ciphers: aes256 }), `TLSv1.2 ${aes256}`); }); + test("ciphers with an OpenSSL security level", async () => { + await using server = await serveNegotiated(); + await using tls12 = await serveNegotiated({ maxVersion: "TLSv1.2" }); + const trust = { ca, servername }; + const aes256 = "ECDHE-RSA-AES256-GCM-SHA384"; + assert.match(await outcome(server.port, { ...trust, ciphers: "DEFAULT@SECLEVEL=0" }), /^TLSv1\.3 /); + assert.match(await outcome(server.port, { ...trust, ciphers: "DEFAULT:@SECLEVEL=1" }), /^TLSv1\.3 /); + assert.strictEqual(await outcome(tls12.port, { ...trust, ciphers: `${aes256}:@SECLEVEL=0` }), `TLSv1.2 ${aes256}`); + }); + test("secureProtocol", async () => { await using server = await serveNegotiated(); const trust = { ca, servername }; From c1a336e465c1d87e6f8164d3147f83ce2b5ce3ff Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:16:47 +0000 Subject: [PATCH 112/174] node:net: type the TLS-only _emitTLSError call --- src/js/node/net.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 15cb144a5973..8d60131bab04 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -95,6 +95,7 @@ interface TLSSocketInternals { _requestCert: boolean; _rejectUnauthorized: boolean; _releaseControl(): boolean; + _emitTLSError(err: Error): void; setSession(session: string | Buffer): void; _SNICallback?: SNICallback; _ALPNCallback?: ALPNCallback; @@ -1726,7 +1727,7 @@ const SocketHandlers2 = { callback(error); } else if (tlsError) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L467-L498 - self._emitTLSError(error); + (self as TLSSocketInstance)._emitTLSError(error); return; } From 18339476c740a435d1c0f737423d98aa7f52105b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:20:29 +0000 Subject: [PATCH 113/174] test(fetch): protocol: http3 is refused once tls.DEFAULT_CIPHERS is assigned (#41810) The HTTP/3 client has one context, so it refuses a request whose config needs its own, as it does for an explicit `ciphers` option. Pins that as a known limit. --- test/js/web/fetch/fetch-http3-client.test.ts | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/js/web/fetch/fetch-http3-client.test.ts b/test/js/web/fetch/fetch-http3-client.test.ts index 6b4051a0e0f2..dfbd650a0a2d 100644 --- a/test/js/web/fetch/fetch-http3-client.test.ts +++ b/test/js/web/fetch/fetch-http3-client.test.ts @@ -1278,3 +1278,27 @@ test("connects to a server with an Ed25519 certificate", async () => { server.stop(true); } }); + +test("protocol: http3 is refused once tls.DEFAULT_CIPHERS is assigned, as it is with a ciphers option", async () => { + const fixture = ` + import tls from "node:tls"; + const server = Bun.serve({ port: 0, tls: ${JSON.stringify(tls)}, http3: true, fetch: () => new Response("ok") }); + const attempt = options => + fetch(server.url, { protocol: "http3", tls: { rejectUnauthorized: false, ...options } }).then( + res => res.text(), + error => error.code, + ); + const ciphers = "ECDHE-RSA-AES256-GCM-SHA384"; + const results = { nothing: await attempt(), option: await attempt({ ciphers }) }; + tls.DEFAULT_CIPHERS = ciphers; + results.assigned = await attempt(); + console.log(JSON.stringify(results)); + process.exit(0); + `; + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + // The HTTP/3 client has one context, which takes no option. QUIC is TLS 1.3, so the list would not apply to it. + expect(JSON.parse(stdout)).toEqual({ nothing: "ok", option: "HTTP3Unsupported", assigned: "HTTP3Unsupported" }); + expect(exitCode).toBe(0); +}); From c808ca14cb23916e780c67df9d0a1a394d74e13a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:20:30 +0000 Subject: [PATCH 114/174] tls: the default client context follows tls.DEFAULT_CIPHERS (#41810) new WebSocket("wss://...") without a tls option and RedisClient tls: true share one SSL_CTX that the VM built once from fixed options and kept for its lifetime, so they ignored the assignment. An assignment now drops it, and the next connection builds it from the config of `tls: true`. Open connections hold their own reference. While nothing is assigned it is the same single context. That context cannot fail to build, so the setter lets BoringSSL check the list: "!aNULL" passed the JS check and now throws ERR_SSL_NO_CIPHER_MATCH, as a name that does not exist already did. --- src/js/node/tls.ts | 2 + src/jsc/rare_data.rs | 4 +- src/runtime/jsc_hooks.rs | 21 ++++--- test/js/node/tls/node-tls-server.test.ts | 28 +++++++++- test/js/node/tls/ssl-ctx-cache.test.ts | 71 ++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 68d0ecec3cfa..f130550aec05 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1717,6 +1717,8 @@ const tlsExports = { validateCiphers(value, "value"); // filter out TLS_ ciphers value = stripTls13CipherNames(value).cipherList; + // Throws ERR_SSL_NO_CIPHER_MATCH for a list such as "!aNULL": every default context is built from what is stored. + newNativeSecureContext({ ciphers: value }); } setTLSDefaultCiphers(value); }, diff --git a/src/jsc/rare_data.rs b/src/jsc/rare_data.rs index 795569b55ba6..bf86c154113c 100644 --- a/src/jsc/rare_data.rs +++ b/src/jsc/rare_data.rs @@ -229,7 +229,7 @@ pub struct RareData { /// CTX. Cached separately so the hot `tls:true` / `wss://` path skips even the /// SHA-256 + map lookup. Ref owned here. Lazy-init body lives in /// `bun_runtime` (it calls `SSLContextCache::get_or_create_opts`). - /// Held for the VM's lifetime so the weak-cache entry never tombstones. + /// Held until `tls.DEFAULT_CIPHERS` is assigned so the weak-cache entry never tombstones. pub default_client_ssl_ctx: Option, /// `bun_runtime::node::StatWatcherScheduler` — erased `RefPtr` payload; @@ -820,6 +820,8 @@ impl RareData { owned.extend_from_slice(ciphers); owned.push(0); self.tls_default_ciphers = Some(owned.into_boxed_slice()); + // Built from the previous list. Open connections hold their own reference. + self.default_client_ssl_ctx = None; } pub fn push_cleanup_hook( diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index fee548e001d3..09239c6db4df 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -216,10 +216,9 @@ pub(crate) unsafe fn runtime_state_of(vm: *mut VirtualMachine) -> *mut RuntimeSt /// Note: lives here (high tier) because the storage slot /// (`RareData.default_client_ssl_ctx`) is in `bun_jsc` but population requires /// `RuntimeState.ssl_ctx_cache` (this crate). The cached `SSL_CTX*` is held -/// for the VM's lifetime so the weak-cache entry never tombstones. +/// until `tls.DEFAULT_CIPHERS` is assigned so the weak-cache entry never tombstones. pub(crate) fn default_client_ssl_ctx(vm: &VirtualMachine) -> *mut bun_uws::SslCtx { - let rare = vm.as_mut().rare_data(); - if rare.default_client_ssl_ctx.is_none() { + if vm.as_mut().rare_data().default_client_ssl_ctx.is_none() { let mut err = bun_uws::create_bun_socket_error_t::none; let state = runtime_state(); debug_assert!( @@ -235,17 +234,23 @@ pub(crate) fn default_client_ssl_ctx(vm: &VirtualMachine) -> *mut bun_uws::SslCt // rejectUnauthorized:true) verifies real servers. Route through the // weak cache so a `tls.connect()` with default options later resolves // to the same CTX rather than building a second one with the same - // digest. The +1 ref returned here is held for the VM's lifetime, so - // the entry never tombstones. - match cache.get_or_create_opts(&Default::default(), &mut err) { - Some(ctx) => rare.default_client_ssl_ctx = Some(ctx), + // digest. The +1 ref returned here is held, so the entry never tombstones. + let ctx = if vm.tls_default_ciphers().is_some() { + cache.get_or_create(&crate::socket::tls_true_defaults(vm, false), &mut err) + } else { + cache.get_or_create_opts(&Default::default(), &mut err) + }; + match ctx { + Some(ctx) => vm.as_mut().rare_data().default_client_ssl_ctx = Some(ctx), None => bun_core::Output::panic(format_args!( "default client SSL_CTX init failed: {}", bun_core::fmt::s(err.message().unwrap_or(b"unknown")), )), } } - rare.default_client_ssl_ctx + vm.as_mut() + .rare_data() + .default_client_ssl_ctx .as_ref() .unwrap() .as_ptr() diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index f729240dcb12..2f4bacf2db88 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4174,6 +4174,7 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as const script = ` import tls from "node:tls"; import { once } from "node:events"; + import { Worker } from "node:worker_threads"; const cert = ${JSON.stringify({ key: cert1.key, cert: cert1.cert })}; const AES128 = "ECDHE-RSA-AES128-GCM-SHA256", AES256 = "ECDHE-RSA-AES256-GCM-SHA384"; @@ -4194,11 +4195,13 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as "fetch, tls: {}": () => fetch(url, { keepalive: false, tls: {} }), "fetch, rejectUnauthorized": () => fetch(url, { keepalive: false, tls: { rejectUnauthorized: false } }), "fetch, ca": () => fetch(url, { keepalive: false, tls: { ca: cert.cert } }), + "WebSocket": () => void new WebSocket(url.replace("https", "wss")), "WebSocket, rejectUnauthorized": () => void new WebSocket(url.replace("https", "wss"), { tls: { rejectUnauthorized: false } }), + "RedisClient, tls: true": () => new Bun.RedisClient("rediss://localhost:" + port, { tls: true, autoReconnect: false }).connect(), "Bun.connect, tls: true": () => Bun.connect({ hostname: "localhost", port, tls: true, socket: { data() {}, error() {} } }), }; const results = {}; - for (const list of [undefined, AES256, "TLS_AES_128_GCM_SHA256"]) { + for (const list of [undefined, AES256, AES128, "TLS_AES_128_GCM_SHA256"]) { if (list) tls.DEFAULT_CIPHERS = list; for (const [name, connect] of Object.entries(clients)) { seen = Promise.withResolvers(); @@ -4206,6 +4209,10 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as (results[name] ??= []).push(await seen.promise); } } + // The list is the thread's, as in Node.js. + seen = Promise.withResolvers(); + new Worker("new WebSocket(" + JSON.stringify(url.replace("https", "wss")) + ")", { eval: true }); + results.worker = await seen.promise; console.log(JSON.stringify(results)); process.exit(0); `; @@ -4217,19 +4224,34 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - // Nothing assigned, a TLS 1.2 suite, TLS 1.3 suites only (which this TLS 1.2 server cannot serve). - const row = ["ECDHE-RSA-AES128-GCM-SHA256", "ECDHE-RSA-AES256-GCM-SHA384", "ERR_SSL_UNSUPPORTED_PROTOCOL"]; + const AES128 = "ECDHE-RSA-AES128-GCM-SHA256"; + // Nothing assigned, a TLS 1.2 suite, another, TLS 1.3 suites only (which this TLS 1.2 server cannot serve). + const row = [AES128, "ECDHE-RSA-AES256-GCM-SHA384", AES128, "ERR_SSL_UNSUPPORTED_PROTOCOL"]; expect(JSON.parse(stdout)).toEqual({ "fetch": row, "fetch, tls: {}": row, "fetch, rejectUnauthorized": row, "fetch, ca": row, + "WebSocket": row, "WebSocket, rejectUnauthorized": row, + "RedisClient, tls: true": row, "Bun.connect, tls: true": row, + worker: AES128, }); expect(exitCode).toBe(0); }); +// Node.js v26.3.0 throws the same from the first context built after the assignment. +it("tls.DEFAULT_CIPHERS refuses a list that selects no cipher", () => { + const before = tls.DEFAULT_CIPHERS; + for (const list of ["!aNULL", "ALL:!ALL", "@STRENGTH"]) { + expect(() => { + tls.DEFAULT_CIPHERS = list; + }).toThrow(expect.objectContaining({ code: "ERR_SSL_NO_CIPHER_MATCH" })); + } + expect(tls.DEFAULT_CIPHERS).toBe(before); +}); + // https://github.com/oven-sh/bun/issues/33954 it.each([ [{ requestCert: true }, "agent1"], diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index 76a28cfed310..cb39d7eb4234 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -573,3 +573,74 @@ test("fetch takes a context of its own per tls.DEFAULT_CIPHERS, and none while n expect(JSON.parse(stdout)).toEqual([0, 0, 1, 0, 1, 0]); expect(exitCode).toBe(0); }); + +test("the default client context follows tls.DEFAULT_CIPHERS, and the one it replaces is released", async () => { + using dir = tempDir("ssl-ctx-default-client", { "ca.pem": tlsCerts.cert }); + const script = ` + import tls from "node:tls"; + import { once } from "node:events"; + import { sslCtxLiveCount } from "bun:internal-for-testing"; + using server = Bun.serve({ + port: 0, + tls: ${JSON.stringify(tlsCerts)}, + fetch: (req, server) => (server.upgrade(req) ? undefined : new Response("no")), + websocket: { message: (ws, message) => void ws.send(message) }, + }); + async function open() { + const ws = new WebSocket("wss://localhost:" + server.port + "/"); + await once(ws, "open"); + return ws; + } + async function close(ws) { + ws.close(); + await once(ws, "close"); + } + function assign(list) { + tls.DEFAULT_CIPHERS = list; + Bun.gc(true); // the context the setter validates the list with + } + const deltas = {}; + async function step(name, expected, run) { + const before = sslCtxLiveCount(); + await run(); + // A closed socket is freed a few turns of the loop after its 'close' event. + for (let i = 0; i < 1000 && sslCtxLiveCount() - before !== expected; i++) await new Promise(setImmediate); + deltas[name] = sslCtxLiveCount() - before; + } + let kept; + await step("first connection", 1, async () => void (kept = await open())); + await step("second connection", 0, async () => close(await open())); + await step("assignment while a connection is open", 0, () => assign("ECDHE-RSA-AES256-GCM-SHA384")); + await step("that connection still works", 0, async () => { + kept.send("ping"); + if ((await once(kept, "message"))[0].data !== "ping") throw new Error("no echo"); + }); + await step("connection after the assignment", 1, async () => close(await open())); + await step("another one", 0, async () => close(await open())); + await step("the first connection closes", -1, () => close(kept)); + await step("reassignment", -1, () => assign("ECDHE-RSA-AES128-GCM-SHA256")); + await step("connection after the reassignment", 1, async () => close(await open())); + console.log(JSON.stringify(deltas)); + process.exit(0); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(String(dir), "ca.pem") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "first connection": 1, + "second connection": 0, + "assignment while a connection is open": 0, + "that connection still works": 0, + "connection after the assignment": 1, + "another one": 0, + "the first connection closes": -1, + "reassignment": -1, + "connection after the reassignment": 1, + }); + expect(exitCode).toBe(0); +}); From 7c4d80421fdcea9334d7293abb56bcb088470762 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:08:22 +0000 Subject: [PATCH 115/174] usockets(tls): hold the client's final flight whatever the other sockets of the loop do (#43946) The flight is held across the handshake callback only while it is batched, and batching was gated on no connection of the loop holding a batch spill. With one stalled TLS neighbour the Certificate, CertificateVerify and Finished went to the wire before the callback ran, so a refusal there (a checkServerIdentity function, destroy() in 'secureConnect', terminate() in a Bun.connect handshake callback) still handed the client certificate to the server it refused. The gate dates from the loop's one spill slot, which a short flush of the flight could not have shared. Belongs with the per-connection spill commits and depends on them: the BIO takes every record now, so a flight the kernel refuses is held whole on its connection whether it was batched or not, and the bytes in userspace do not change. Its rest counts as a batch spill like any other flush. us_internal_ssl_writev keeps the gate: that is the loop-wide bound on application data. --- packages/bun-usockets/src/crypto/openssl.c | 8 +++---- test/js/bun/net/tls-low-prio-queue-fixture.ts | 8 +++---- test/js/node/tls/node-tls-connect.test.ts | 17 +++++++++++++- test/js/node/tls/tls-client-close-fixture.mjs | 23 +++++++++++++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 883a824e3ec1..57654ecbc537 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2562,11 +2562,11 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i * down right after the handshake (node's post-verify destroy, #40653) * close with the second segment unread, which turns its FIN teardown * into an RST and a bogus ECONNRESET at this side. Node's memory BIO - * drained once per cycle has the same single-segment shape. Same gate - * as us_internal_ssl_writev. */ + * drained once per cycle has the same single-segment shape. Not gated + * on ssl_batch_spills: a refusal in that dispatch must find the flight + * held, and a flight the kernel refuses spills whole either way. */ int hs_batching = s->ssl_handshake_state == HANDSHAKE_PENDING && - !loop_ssl_data->ssl_write_batching && - !loop_ssl_data->ssl_batch_spills; + !loop_ssl_data->ssl_write_batching; if (hs_batching) loop_ssl_data->ssl_write_batching = 1; unsigned char ssl_was_in_use = s->ssl_in_use; s->ssl_in_use = 1; diff --git a/test/js/bun/net/tls-low-prio-queue-fixture.ts b/test/js/bun/net/tls-low-prio-queue-fixture.ts index 4d012dc64219..eec986de0825 100644 --- a/test/js/bun/net/tls-low-prio-queue-fixture.ts +++ b/test/js/bun/net/tls-low-prio-queue-fixture.ts @@ -51,10 +51,8 @@ // that closes later was parked and re-enabled. The summary reports both per // wave and the test asserts the exact split. // -// One extra "primer" connection per round takes the first handshake flight: -// that flight is batched, and while its unsent rest waits in the primer's -// spill no other socket on the loop batches. So all N wave sockets take the -// same per-record path and the per-wave numbers are exact. +// One extra "primer" connection per round takes the first handshake flight +// and holds its unsent rest for the whole round, like a stalled neighbour. import { socketFaultInjection as fault, getEventLoopStats } from "bun:internal-for-testing"; import net from "node:net"; import tls from "node:tls"; @@ -349,7 +347,7 @@ async function round() { try { // Primer: see the header comment. Its ClientHello is processed alone and - // its flight becomes the spill owner. + // its flight spills. current = newBatch(1); send(`connect 1 ${server.port}`); await withDeadline(current.allOpen.promise, "primer open"); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 66d368aa1673..473f8110a1c6 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -22,7 +22,7 @@ import type { AddressInfo } from "net"; import { Duplex } from "node:stream"; import { pathToFileURL } from "node:url"; import * as clientWrap from "./node-tls-client-wrap-fixture.mjs"; -import { report as closeReport } from "./tls-client-close-fixture.mjs"; +import { report as closeReport, stalledConnection } from "./tls-client-close-fixture.mjs"; import { report as refuseReport } from "./tls-server-refuse-fixture.mjs"; const symbolConnectOptions = Symbol.for("::buntlsconnectoptions::"); @@ -3705,6 +3705,15 @@ describe("a TLS 1.3 Bun.connect client that closes once its handshake is done", it.each(["handshake", "open"] as const)("%s: terminate() sends no client certificate", async callback => { expect(await run(callback, "terminate")).toEqual({ server: "fail:ECONNRESET", sentAfterClientHello: false }); }); + + it("handshake: terminate() sends no client certificate beside a stalled TLS socket", async () => { + const stalled = await stalledConnection(); + try { + expect(await run("handshake", "terminate")).toEqual({ server: "fail:ECONNRESET", sentAfterClientHello: false }); + } finally { + stalled.close(); + } + }); }); // End-to-end shape of the issue: a Node TLS 1.3 server that rejects the @@ -4391,6 +4400,12 @@ describe("how a TLS client's way of closing reaches the server", () => { "checkServerIdentity function that writes to another TLS socket", turnedDown("error:ERR_PINNED_KEY", "close:true"), ], + [ + "TLSv1.3", + "checkServerIdentity function beside a stalled TLS socket", + turnedDown("error:ERR_PINNED_KEY", "close:true"), + ], + ["TLSv1.3", "destroy() beside a stalled TLS socket", turnedDown("close:false")], // A graceful close still sends the flight. ["TLSv1.3", "end()", delivered("", 1)], diff --git a/test/js/node/tls/tls-client-close-fixture.mjs b/test/js/node/tls/tls-client-close-fixture.mjs index d146192e13a1..1e7e143a5d6d 100644 --- a/test/js/node/tls/tls-client-close-fixture.mjs +++ b/test/js/node/tls/tls-client-close-fixture.mjs @@ -194,9 +194,31 @@ async function secondConnection() { }; } +// A TLS connection of this process whose peer never reads, with more written than the kernel takes. +export async function stalledConnection() { + const server = tls.createServer({ key: pem("agent1-key.pem"), cert: pem("agent1-cert.pem") }, socket => { + socket.on("error", () => {}); + socket.pause(); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const socket = tls.connect({ host: "127.0.0.1", port: server.address().port, rejectUnauthorized: false }); + socket.on("error", () => {}); + await once(socket, "secureConnect"); + socket.write(Buffer.alloc(32 * 1024 * 1024)); + return { + close() { + socket.destroy(); + server.close(); + }, + }; +} + const helloRequestMode = "a HelloRequest behind the server's Finished"; +const besideStalled = " beside a stalled TLS socket"; export async function report(mode, version) { + const stalled = mode.endsWith(besideStalled) ? await stalledConnection() : null; + if (stalled) mode = mode.slice(0, -besideStalled.length); const second = mode === "checkServerIdentity function that writes to another TLS socket" ? await secondConnection() : null; // agent1 is signed by ca1 and names only "agent1". @@ -432,6 +454,7 @@ export async function report(mode, version) { const [saw] = await Promise.all([serverSaw.promise, closed]); second?.close(); + stalled?.close(); relay.close(); server.close(); return { client, server: saw, ...wire(Buffer.concat(fromClient)) }; From f3d94cf4aba998169e577de9d3bd57f5e02187c3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:21:53 +0000 Subject: [PATCH 116/174] uws: HttpContext::onHandshake ignores the report of an established session's failure usockets reports a fatal TLS error after the handshake through on_handshake(0, EPROTO) and then closes. HttpContext read that second call as a connection that just opened: it overwrote isAuthorized and peerCertVerified, reset the timeout, marked a node:http connection with a request in flight idle, ran every filter with +1 again (one -1 follows on close) and re-entered the 'connection' callback. Nothing of that reaches JavaScript today, only because the close follows at once and _http_server.ts drops a handle it already wrapped. filteredOpen already says that this socket's handshake was reported. --- packages/bun-uws/src/HttpContext.h | 4 ++++ test/js/bun/net/tls-fatal-error-closes.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 15e16227b607..e3007ee51d22 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -143,6 +143,10 @@ struct HttpContext { HttpContextData *httpContextData = getSocketContextDataS(s); // Set per-socket authorization status auto *httpResponseData = reinterpret_cast *>(us_socket_ext(s)); + /* Opened already: this reports the failure of the established session, and usockets closes behind it. */ + if (httpResponseData->filteredOpen) { + return; + } /* The app-level flag reflects the default entry; a per-serverName * entry adds its own client-certificate policy for its name. */ bool rejectUnauthorized = httpContextData->flags.rejectUnauthorized || diff --git a/test/js/bun/net/tls-fatal-error-closes.test.ts b/test/js/bun/net/tls-fatal-error-closes.test.ts index a93304ea1d7a..275d2e13aeb9 100644 --- a/test/js/bun/net/tls-fatal-error-closes.test.ts +++ b/test/js/bun/net/tls-fatal-error-closes.test.ts @@ -15,6 +15,7 @@ import https from "node:https"; import net from "node:net"; import { Duplex } from "node:stream"; import tls from "node:tls"; +import { promisify } from "node:util"; import { listeningServer, MYSQL_CLIENT_LONG_PASSWORD, @@ -454,6 +455,7 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)("a bad record on an established % const nodeServer = bunServer ? undefined : https.createServer(cert, (request, response) => (requests.push(request.url!), response.end("ok"))); + for (const event of ["connection", "secureConnection"]) nodeServer?.on(event, () => requests.push(event)); if (nodeServer) await once(nodeServer.listen(0, "127.0.0.1"), "listening"); try { const relay = await faultRelay(bunServer?.port ?? (nodeServer!.address() as net.AddressInfo).port, "server"); @@ -472,7 +474,8 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)("a bad record on an established % client.write("GET /later HTTP/1.1\r\nHost: localhost\r\n\r\n"); await relay.closedByOwner; client.destroy(); - expect(requests).toEqual(["/first"]); + expect(requests).toEqual(nodeServer ? ["connection", "secureConnection", "/first"] : ["/first"]); + if (nodeServer) expect(await promisify(nodeServer.getConnections).call(nodeServer)).toBe(0); } finally { bunServer?.stop(true); nodeServer?.close(); From 9af7883be26504697d44ad73e30fa6046e056606 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:21:54 +0000 Subject: [PATCH 117/174] usockets: us_socket_close_now, one place for "send what the kernel takes, then close" Three owners that must not wait for a TLS peer each spelled out a private rule of us_internal_ssl_close (a close defers at most once) by closing twice: SQL's AnySocket::close_now (shutdown, fast shutdown, fast shutdown), the WebSocket client at its close timeout (shutdown, fast shutdown, re-arm the timeout) and HttpContext::onTimeout (close, fast shutdown). A change of the deferral would have stopped all three from closing, silently. us_socket_close_now runs the graceful close, which sends the unsent ciphertext and close_notify as far as the kernel takes them, then closes whatever that close waits for. The owners call it once. Gone: the two Rust helpers' bodies, the is_ssl_handshake_finished plumbing that only close_now used, and the WebSocket client's re-arm, which since the close deadline only moved that deadline from 10 s to the 30 s close timeout. --- packages/bun-usockets/src/libusockets.h | 2 ++ packages/bun-usockets/src/socket.c | 6 ++++ packages/bun-uws/src/HttpContext.h | 5 ++- src/http_jsc/websocket_client.rs | 12 +------ .../WebSocketUpgradeClient.rs | 2 +- src/uws_sys/socket.rs | 33 +++++-------------- src/uws_sys/us_socket_t.rs | 11 ++++--- 7 files changed, 27 insertions(+), 44 deletions(-) diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 4f7bd60e1852..bb78632bb6a0 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -712,6 +712,8 @@ int us_socket_ssl_handshake_callback_has_fired(us_socket_r s) nonnull_fn_decl; unsigned int us_socket_ssl_spill_pending(us_socket_r s) nonnull_fn_decl; struct us_socket_t *us_socket_close(us_socket_r s, int code, void *reason) __attribute__((nonnull(1))); +/* Closes before it returns. A TLS socket first sends what the kernel takes now: its unsent ciphertext, then close_notify. */ +struct us_socket_t *us_socket_close_now(us_socket_r s) nonnull_fn_decl; int us_socket_local_port(us_socket_r s) nonnull_fn_decl; int us_socket_remote_port(us_socket_r s) nonnull_fn_decl; diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index 576837653aca..4cb0f83b90e3 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -364,6 +364,12 @@ __attribute__((always_inline)) struct us_socket_t *us_socket_close(struct us_soc return us_internal_socket_close_raw(s, code, reason); } +struct us_socket_t *us_socket_close_now(struct us_socket_t *s) { + /* The graceful close sends what it can and may then wait, for the spill or for the peer. A close defers at most once. */ + us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + return us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, NULL); +} + struct us_socket_t *us_socket_pair(struct us_socket_group_t *group, unsigned char kind, int socket_ext_size, LIBUS_SOCKET_DESCRIPTOR *fds) { #if defined(LIBUS_USE_LIBUV) || defined(WIN32) return 0; diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index e3007ee51d22..1c5f4a8757c8 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -1141,9 +1141,8 @@ struct HttpContext { if (httpResponseData->onTimeout) { httpResponseData->onTimeout((HttpResponse *)s, httpResponseData->userData); } - asyncSocket->close(); - /* On TLS that close waits for unsent ciphertext, then for the peer's close_notify. */ - return us_socket_close(s, LIBUS_SOCKET_CLOSE_CODE_FAST_SHUTDOWN, nullptr); + asyncSocket->uncork(); + return us_socket_close_now(s); } /* Static .rodata vtables — one per (SSL, IsNodeHttp), shared by every diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index e873d0149c4c..100ec523480e 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -71,16 +71,6 @@ fn close_timeout_seconds() -> core::ffi::c_uint { ) } -/// Closes with a FIN: a reset would drop what the kernel still has to send behind a clean close event. -fn close_at_close_timeout(socket: Socket) { - socket.shutdown(); - socket.close(uws::CloseKind::FastShutdown); - // uSockets defers this close once behind unsent TLS ciphertext. - if !socket.is_closed() { - socket.set_timeout(close_timeout_seconds()); - } -} - #[derive(bun_ptr::CellRefCounted)] pub struct WebSocket { pub(crate) ref_count: Cell, @@ -1247,7 +1237,7 @@ impl WebSocket { pub fn handle_timeout(&self, socket: Socket) { if self.cpp_websocket().is_none() { - close_at_close_timeout(socket); + socket.close_now(); return; } self.terminate(ErrorCode::Timeout); diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 39761c6c17ca..e55e5a1abc53 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -1490,7 +1490,7 @@ where /// Takes `ThisPtr` because `terminate` may free `this`; see `fail`. pub fn handle_timeout(this: ThisPtr, socket: Socket) { if this.state.get() == State::Done { - super::close_at_close_timeout(socket); + socket.close_now(); return; } Self::terminate(this, ErrorCode::Timeout); diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index ce56c749b451..bc50a421c102 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -286,16 +286,6 @@ impl NewSocketHandler { ) } - /// Always true for a connected plain TCP socket. - pub fn is_ssl_handshake_finished(&self) -> bool { - on_socket!(self.socket; - connected s => s.is_ssl_handshake_finished(), - duplex d => d.is_established(), - pipe p => p.is_established(), - else => false, - ) - } - #[inline] pub fn is_closed_or_has_error(&self) -> bool { self.is_closed() || self.is_shutdown() || self.get_error() != 0 @@ -354,6 +344,14 @@ impl NewSocketHandler { ) } + /// Closes with a FIN before it returns; TLS sends close_notify but does not wait for the peer's, which `CloseCode::Normal` does. + pub fn close_now(&self) { + match self.socket { + InternalSocket::Connected(s) => sock(s).close_now(), + _ => self.close(CloseCode::FastShutdown), + } + } + /// The JS wrapper that owns this socket is being finalized: whatever the /// close below unwinds must not reach back into JS objects. pub fn prepare_for_finalize(&self) { @@ -972,8 +970,8 @@ impl AnySocket { fn is_closed(&self) -> bool; fn is_shutdown(&self) -> bool; fn is_established(&self) -> bool; - fn is_ssl_handshake_finished(&self) -> bool; fn close(&self, code: CloseCode); + fn close_now(&self); fn write(&self, data: &[u8]) -> i32; fn set_timeout(&self, seconds: c_uint); fn shutdown(&self); @@ -981,17 +979,4 @@ impl AnySocket { fn local_port(&self) -> Option; fn get_native_handle(&self) -> Option<*mut c_void>; } - - /// Sends close_notify and closes without waiting for the peer's, which `CloseCode::Normal` does. - pub fn close_now(&self) { - // usockets reports a FIN sent mid-handshake as a failed handshake with no reason. - if matches!(self, AnySocket::SocketTls(_)) && self.is_ssl_handshake_finished() { - self.shutdown(); - } - self.close(CloseCode::FastShutdown); - // usockets parks one fast shutdown behind ciphertext the kernel would not take. - if !self.is_closed() { - self.close(CloseCode::FastShutdown); - } - } } diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index 1ce25c107dc6..d10392132d24 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -97,6 +97,11 @@ impl us_socket_t { } } + pub(crate) fn close_now(&mut self) { + bun_core::scoped_log!(uws, "us_socket_close_now({:p})", self); + let _ = c::us_socket_close_now(self); + } + pub fn shutdown(&mut self) { bun_core::scoped_log!(uws, "us_socket_shutdown({:p})", self); c::us_socket_shutdown(self); @@ -471,10 +476,6 @@ impl us_socket_t { c::us_socket_is_established(self) > 0 } - pub(crate) fn is_ssl_handshake_finished(&self) -> bool { - c::us_socket_is_ssl_handshake_finished(self) > 0 - } - pub(crate) fn queued_input(&self) -> QueuedInput { match c::us_socket_queued_input(self) { LIBUS_QUEUED_INPUT_DATA => QueuedInput::Data, @@ -567,6 +568,7 @@ mod c { code: CloseCode, reason: *mut c_void, ) -> *mut us_socket_t; + pub(super) safe fn us_socket_close_now(s: &mut us_socket_t) -> *mut us_socket_t; pub(super) safe fn us_socket_shutdown(s: &mut us_socket_t); pub(super) safe fn us_socket_is_closed(s: &us_socket_t) -> i32; pub(super) fn us_socket_write_check_error( @@ -582,7 +584,6 @@ mod c { pub(super) safe fn us_socket_verify_error(s: &us_socket_t) -> us_bun_verify_error_t; pub(super) safe fn us_socket_get_error(s: &us_socket_t) -> c_int; pub(super) safe fn us_socket_is_established(s: &us_socket_t) -> i32; - pub(super) safe fn us_socket_is_ssl_handshake_finished(s: &us_socket_t) -> i32; pub(super) safe fn us_socket_queued_input(s: &us_socket_t) -> c_int; /// ssl_ctx is required (the whole point); sni may be null. From d7b42e128d12047868ec100eef02e19ab4dbdb54 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 03:30:31 +0000 Subject: [PATCH 118/174] node:tls: a write over a Duplex completes when the Duplex has completed it (#43877, #44394) UpgradedDuplex called duplex.write(chunk) with no callback and reported the SSL_write count, so write(cb) and end(data, cb) ran their callback while the ciphertext was still queued in the wrapped stream. A handler that exits or destroys in the callback truncated the response. Every duplex.write() and duplex.end() now gets one cached native callback and is counted. $write and $end report false while a call is outstanding, which parks the node:net callback as a partial write does. When the count reaches zero the existing writable path runs on the next tick, as node's JSStreamSocket defers finishWrite, so a stream that runs the callback inside write() cannot re-enter the sender that still holds its bytes. A write the stream fails is reported as node's errnoException(uv[`UV_${err.code}`] || UV_EPIPE, 'write'), also a tick later, so the stream still reports its own error. That callback takes the slot of the 'drain' listener on the wrapped stream, which node does not have. Its last job was a write parked during a renegotiation, which the end of the renegotiation now retries (#37094). A stream that emits 'drain' inside its own write() made the listener send the sender's bytes twice. --- src/js/node/_http2_upgrade.ts | 13 +- src/js/node/net.ts | 3 +- src/runtime/socket/UpgradedDuplex.rs | 115 ++++-- src/runtime/socket/socket_body.rs | 11 +- src/runtime/socket/sockets.classes.ts | 4 +- src/uws_sys/lib.rs | 5 + src/uws_sys/socket.rs | 8 + test/js/node/tls/node-tls-connect.test.ts | 443 ++++++++++++++++++++++ test/js/node/tls/renegotiation.test.ts | 35 ++ 9 files changed, 586 insertions(+), 51 deletions(-) diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index ce5567d5588a..172986418e4f 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -10,7 +10,7 @@ interface NativeHandle { } type UpgradeEventListener = (...args: any[]) => void; -type UpgradeEvents = [UpgradeEventListener, UpgradeEventListener, UpgradeEventListener, UpgradeEventListener]; +type UpgradeEvents = [UpgradeEventListener, UpgradeEventListener, UpgradeEventListener]; interface UpgradeContextType { connectionListener: (...args: any[]) => any; @@ -265,8 +265,7 @@ function onTlsClose(this: TLSProxySocket) { if (!ev) return; raw.removeListener("data", ev[0]); raw.removeListener("end", ev[1]); - raw.removeListener("drain", ev[2]); - raw.removeListener("close", ev[3]); + raw.removeListener("close", ev[2]); } // --------------------------------------------------------------------------- @@ -341,7 +340,7 @@ function upgradeRawSocketToH2( try { // upgradeDuplexToTLS wraps rawSocket with a TLS layer in server mode (isServer: true). // The native side will: - // 1. Read encrypted data from rawSocket via events[0..3] + // 1. Read encrypted data from rawSocket via events[0..2] // 2. Decrypt it through the TLS engine (with ALPN negotiation for "h2") // 3. Call our socket callbacks below with the decrypted plaintext // @@ -385,12 +384,10 @@ function upgradeRawSocketToH2( tlsSocket._ctx.events = events; // Wire up the raw TCP socket to feed encrypted data into the TLS layer. - // events[0..3] are native event handlers returned by upgradeDuplexToTLS that - // the native TLS engine expects to receive data/end/drain/close through. + // events[0..2] are the native TLS engine's listeners for data, end and close. rawSocket.on("data", events[0]); rawSocket.on("end", events[1]); - rawSocket.on("drain", events[2]); - rawSocket.on("close", events[3]); + rawSocket.on("close", events[2]); // When the TLS socket closes (e.g. H2 session destroyed), clean up the raw socket // listeners to prevent memory leaks and stale callback references. diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 8d60131bab04..3a4501d211a1 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -469,8 +469,7 @@ function attachTLSEngine(self, connection, options) { const [handle, events] = upgradeDuplexToTLS(connection, options); connection.on("data", events[0]); connection.on("end", events[1]); - connection.on("drain", events[2]); - connection.on("close", events[3]); + connection.on("close", events[2]); // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L120 connection.resume(); self._handle = handle; diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index f6640a0fa0a6..eb013fbc8ee8 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -17,6 +17,7 @@ use core::ffi::{CStr, c_uint, c_void}; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{CallFrame, GlobalRef, JSGlobalObject, JSValue, JsCell, JsResult, host_fn}; +use bun_sys::SystemErrno; use bun_uws::{us_bun_verify_error_t, uws_callback}; use super::ssl_wrapper::SSLWrapper; @@ -28,7 +29,7 @@ bun_output::declare_scope!(UpgradedDuplex, visible); pub(crate) struct UpgradedDuplex { pub wrapper: JsCell>, /// The owning `JSTLSSocket` wrapper. Its `values:` slots root `origin` and - /// the four listener thunks; the `JSValue` fields below are read-side + /// the four native thunks; the `JSValue` fields below are read-side /// shadows of those slots. pub js_wrapper: JSValue, pub origin: Cell, // any duplex @@ -42,8 +43,11 @@ pub(crate) struct UpgradedDuplex { pub handlers: Handlers, pub on_data_callback: Cell, pub on_end_callback: Cell, - pub on_writable_callback: Cell, pub on_close_callback: Cell, + /// The `cb` of every `origin.write(chunk, cb)` and `origin.end(null, cb)`. + pub on_write_done_callback: Cell, + /// Those calls whose `cb` has not run. + pub in_flight: Cell, pub event_loop_timer: JsCell, pub current_timeout: Cell, /// Transport bytes that arrived before the TLS engine existed. @@ -112,7 +116,7 @@ use crate::jsc_hooks::timer_all_mut as timer_all; /// Lazily create-and-cache a JS host-function callback in `shadow`, mirrored /// into the owning `JSTLSSocket` wrapper's visited `values:` slot (the GC -/// root). All four `get_js_handlers` slots follow the identical +/// root). All four slots follow the identical /// `NewFunctionWithData(global, null, 0, fn, self)` → `ensureStillAlive` → /// `setFunctionData(self)` → store pattern. #[inline] @@ -306,26 +310,38 @@ impl UpgradedDuplex { } }; - if let Some(data) = data { - let buffer = match bun_jsc::array_buffer::BinaryType::Buffer.to_js(data, &global) { + let chunk = match data { + Some(data) => match bun_jsc::array_buffer::BinaryType::Buffer.to_js(data, &global) { Ok(b) => b, Err(err) => { (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); return; } - }; - buffer.ensure_still_alive(); + }, + None => JSValue::NULL, + }; + chunk.ensure_still_alive(); + let done = lazy_js_handler( + &self.on_write_done_callback, + self.js_wrapper, + js_TLSSocket::duplex_on_write_done_set_cached, + &global, + __jsc_host_on_write_done, + std::ptr::from_ref(self).cast_mut().cast::(), + ); - if let Err(err) = write_or_end.call(&global, duplex, &[buffer]) { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); - } - } else { - if let Err(err) = write_or_end.call(&global, duplex, &[JSValue::NULL]) { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); - } + self.in_flight.set(self.in_flight.get() + 1); + if let Err(err) = write_or_end.call(&global, duplex, &[chunk, done]) { + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); } } + /// The wrapped stream has completed every write it was handed. + #[uws_callback(export = "UpgradedDuplex__transport_idle", no_catch)] + pub(crate) fn transport_idle(&self) -> bool { + self.in_flight.get() == 0 + } + /// Not the 'end' event, which a paused transport holds back. No public property tells. fn transport_got_eof(duplex: JSValue, global: &JSGlobalObject) -> bool { let ended = duplex @@ -457,8 +473,9 @@ impl UpgradedDuplex { ssl_error: JsCell::new(CertError::default()), on_data_callback: Cell::new(JSValue::ZERO), on_end_callback: Cell::new(JSValue::ZERO), - on_writable_callback: Cell::new(JSValue::ZERO), on_close_callback: Cell::new(JSValue::ZERO), + on_write_done_callback: Cell::new(JSValue::ZERO), + in_flight: Cell::new(0), event_loop_timer: JsCell::new(EventLoopTimer::init_paused( EventLoopTimerTag::UpgradedDuplex, )), @@ -470,7 +487,7 @@ impl UpgradedDuplex { } pub(crate) fn get_js_handlers(&self, global: &JSGlobalObject) -> JsResult { - let array = JSValue::create_empty_array(global, 4)?; + let array = JSValue::create_empty_array(global, 3)?; array.ensure_still_alive(); let this_ptr = std::ptr::from_ref(self).cast_mut().cast::(); @@ -502,18 +519,6 @@ impl UpgradedDuplex { array.put_index( global, 2, - lazy_js_handler( - &self.on_writable_callback, - js_wrapper, - js_TLSSocket::duplex_on_writable_set_cached, - global, - __jsc_host_on_writable, - this_ptr, - ), - )?; - array.put_index( - global, - 3, lazy_js_handler( &self.on_close_callback, js_wrapper, @@ -732,8 +737,8 @@ impl UpgradedDuplex { for cb in [ &self.on_data_callback, &self.on_end_callback, - &self.on_writable_callback, &self.on_close_callback, + &self.on_write_done_callback, ] { let value = cb.get(); if !value.is_empty() { @@ -741,6 +746,7 @@ impl UpgradedDuplex { cb.set(JSValue::ZERO); } } + self.in_flight.set(0); self.ssl_error.set(CertError::default()); self.pending_data.set(Vec::new()); self.pending_close.set(false); @@ -755,7 +761,7 @@ impl Drop for UpgradedDuplex { } // SAFETY (all four host fns): the function data is the `*mut UpgradedDuplex` -// installed by `get_js_handlers`; `teardown` clears it before the storage is +// installed by `lazy_js_handler`; `teardown` clears it before the storage is // freed, so a non-null data pointer is live for the call. #[bun_jsc::host_fn] @@ -809,24 +815,61 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { Ok(JSValue::UNDEFINED) } +/// Node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. #[bun_jsc::host_fn] -fn on_writable(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - bun_output::scoped_log!(UpgradedDuplex, "onWritable"); +fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + bun_output::scoped_log!(UpgradedDuplex, "onWriteDone"); let function = frame.callee(); + let [arg] = frame.arguments_as_array::<1>(); if let Some(self_ptr) = host_fn::get_function_data(function) { // SAFETY: see host-fn note above. let this = unsafe { &*self_ptr.cast::() }; - // flush pending data - this.flush(); - // call onWritable (will flush on demand) - (this.handlers.on_writable)(this.handlers.ctx); + if arg.is_number() { + let errno = arg.to_int32(); + if errno != 0 { + let err = bun_sys::Error::from_code_int(errno, bun_sys::Tag::write); + let mut err = ::to_system_error(&err); + // Node's errnoException(errCode, 'write'). + err.message = bun_core::String::create_format(format_args!("write {}", err.code)); + (this.handlers.on_error)(this.handlers.ctx, err.to_error_instance(global)); + } else if this.in_flight.get() == 0 { + (this.handlers.on_writable)(this.handlers.ctx); + } + return Ok(JSValue::UNDEFINED); + } + let in_flight = this.in_flight.get().saturating_sub(1); + this.in_flight.set(in_flight); + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L157-L159 + if !arg.is_undefined_or_null() && !this.is_shutdown() { + let errno = JSValue::js_number_from_int32(write_errno(global, arg)? as i32); + JSValue::call_next_tick_1(function, global, errno)?; + } else if in_flight == 0 { + JSValue::call_next_tick_1(function, global, JSValue::js_number_from_int32(0))?; + } } Ok(JSValue::UNDEFINED) } +/// ``uv[`UV_${err.code}`] || uv.UV_EPIPE`` +fn write_errno(global: &JSGlobalObject, err: JSValue) -> JsResult { + let code = if err.is_object() { + err.get(global, "code")?.filter(|code| code.is_string()) + } else { + None + }; + let Some(code) = code else { + return Ok(SystemErrno::EPIPE); + }; + Ok(core::str::from_utf8(&code.to_utf8(global)?) + .ok() + .and_then(|code| code.parse().ok()) + .filter(|&errno| errno != SystemErrno::SUCCESS) + .unwrap_or(SystemErrno::EPIPE)) +} + #[bun_jsc::host_fn] fn on_close_js(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { bun_output::scoped_log!(UpgradedDuplex, "onCloseJS"); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 7a1caaf01909..b87aa7e6bcb2 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -2728,7 +2728,9 @@ impl NewSocket { // node:net fails the write like Node's onWriteComplete; // -1 stays the legacy closed/shutdown sentinel. JSValue::js_number(f64::from(wrote)) - } else if usize::try_from(wrote.max(0)).expect("int cast") == total { + } else if usize::try_from(wrote.max(0)).expect("int cast") == total + && this.socket.get().transport_idle() + { JSValue::TRUE } else { JSValue::FALSE @@ -2760,7 +2762,10 @@ impl NewSocket { let _ = this.internal_flush(); } - JSValue::from(usize::try_from(wrote.max(0)).expect("int cast") == total) + JSValue::from( + usize::try_from(wrote.max(0)).expect("int cast") == total + && this.socket.get().transport_idle(), + ) } }; Ok(result) @@ -4997,7 +5002,7 @@ pub(crate) fn js_upgrade_duplex_to_tls( let array = JSValue::create_empty_array(global, 2)?; array.put_index(global, 0, tls_js_value)?; - // data, end, drain and close events must be reported + // data, end and close events must be reported array.put_index( global, 1, diff --git a/src/runtime/socket/sockets.classes.ts b/src/runtime/socket/sockets.classes.ts index 854e00ffebed..c531c79260f3 100644 --- a/src/runtime/socket/sockets.classes.ts +++ b/src/runtime/socket/sockets.classes.ts @@ -9,11 +9,11 @@ function generate(ssl) { memoryCost: true, // Visited slot holding the shared JSSocketHandlers cell, so the callbacks // stay alive as long as any socket that can still fire them. The duplex* - // slots carry the origin stream and the four native listener thunks for a + // slots carry the origin stream and the four native thunks for a // TLSSocket driven by an upgraded Duplex (UpgradedDuplex); plain TCP // sockets never populate them. values: ssl - ? ["handlers", "duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnWritable", "duplexOnClose"] + ? ["handlers", "duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnClose", "duplexOnWriteDone"] : ["handlers"], proto: { getAuthorizationError: { diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index b9dcf45b7fe7..139e5538ee41 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -222,6 +222,7 @@ unsafe extern "C" { safe fn UpgradedDuplex__is_established(this: &UpgradedDuplex) -> bool; safe fn UpgradedDuplex__is_closed(this: &UpgradedDuplex) -> bool; safe fn UpgradedDuplex__is_shutdown(this: &UpgradedDuplex) -> bool; + safe fn UpgradedDuplex__transport_idle(this: &UpgradedDuplex) -> bool; safe fn UpgradedDuplex__ssl(this: &UpgradedDuplex) -> *mut bun_boringssl_sys::SSL; safe fn UpgradedDuplex__set_inline_reject(this: &UpgradedDuplex); safe fn UpgradedDuplex__latest_session( @@ -260,6 +261,10 @@ impl UpgradedDuplex { UpgradedDuplex__is_shutdown(self) } #[inline] + pub(crate) fn transport_idle(&self) -> bool { + UpgradedDuplex__transport_idle(self) + } + #[inline] pub(crate) fn ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { let p = UpgradedDuplex__ssl(self); if p.is_null() { None } else { Some(p) } diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index bc50a421c102..c0fc5e5d5704 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -277,6 +277,14 @@ impl NewSocketHandler { ) } + /// False while a wrapped stream has not completed a write that `write` counted. + pub fn transport_idle(&self) -> bool { + match self.socket { + InternalSocket::UpgradedDuplex(d) => duplex(d).transport_idle(), + _ => true, + } + } + pub fn is_established(&self) -> bool { on_socket!(self.socket; connected s => s.is_established(), diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 473f8110a1c6..9021a199d90e 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -12,6 +12,7 @@ import { rejectUnauthorizedScope, tempDir, } from "harness"; +import http2 from "http2"; import https from "https"; import net from "net"; import { join } from "path"; @@ -6276,3 +6277,445 @@ describe("over a duplex, 'session' is emitted before the data that followed it o expect([...new Set(events)]).toEqual(["secureConnect", "session"]); }); }); + +describe("a write on a TLS socket over a Duplex completes when the Duplex has completed it", () => { + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L174-L224 + type Side = "client" | "server"; + type WriteCallback = (err?: Error | null) => void; + // Two in-memory Duplexes. From `stall()` on, the side named by `stalls` keeps each + // chunk and its write callback in `held` instead of completing the write. + function makePair(stalls: Side) { + const held: [Buffer, WriteCallback][] = []; + let stalled = false; + const makeSide = (peer: () => Duplex, name: Side) => + new Duplex({ + read() {}, + write(chunk: Buffer, _encoding, callback) { + if (stalled && name === stalls) { + held.push([chunk, callback]); + return; + } + peer().push(chunk); + callback(); + }, + final(callback) { + peer().push(null); + callback(); + }, + }); + const clientSide: Duplex = makeSide(() => serverSide, "client"); + const serverSide: Duplex = makeSide(() => clientSide, "server"); + return { + clientSide, + serverSide, + held, + stall: () => void (stalled = true), + release() { + stalled = false; + for (const [chunk, callback] of held.splice(0)) { + (stalls === "server" ? clientSide : serverSide).push(chunk); + callback(); + } + }, + }; + } + const turn = () => new Promise(resolve => setImmediate(resolve)); + // events.once() would reject on 'error'. + const closeOf = (socket: TLSSocket) => new Promise(resolve => socket.once("close", () => resolve())); + + // An established session over `pair`. `writer` is the socket on the side that can stall. + async function connectPair( + pair: { clientSide: Duplex; serverSide: Duplex }, + stalls: Side, + options: tls.ConnectionOptions = {}, + ) { + const server = new TLSSocket(pair.serverSide, { + isServer: true, + secureContext: tls.createSecureContext(COMMON_CERT_), + }); + const client = tls.connect({ socket: pair.clientSide, rejectUnauthorized: false, ...options }); + const [writer, reader] = stalls === "server" ? [server, client] : [client, server]; + const sockets = { + writer, + reader, + received: "", + [Symbol.dispose]() { + client.destroy(); + server.destroy(); + }, + }; + reader.on("data", chunk => (sockets.received += chunk)); + try { + await Promise.all([once(client, "secureConnect"), once(server, "secure")]); + // The session tickets of TLS 1.3 have left the server. + await turn(); + } catch (err) { + sockets[Symbol.dispose](); + throw err; + } + return sockets; + } + + describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s", maxVersion => { + describe.each(["client", "server"] as const)("%s", side => { + const issue = { + "write(data, cb)": (socket: TLSSocket, cb: WriteCallback) => void socket.write("x", cb), + "end(data, cb)": (socket: TLSSocket, cb: WriteCallback) => void socket.end("x", cb), + "end(cb) behind a write": (socket: TLSSocket, cb: WriteCallback) => void (socket.write("x"), socket.end(cb)), + "the last write of a cork()": (socket: TLSSocket, cb: WriteCallback) => { + socket.cork(); + socket.write("x"); + socket.write("", cb); + socket.uncork(); + }, + }; + it.each(Object.keys(issue) as (keyof typeof issue)[])("%s", async how => { + const pair = makePair(side); + using sockets = await connectPair(pair, side, { maxVersion }); + const { promise, resolve, reject } = Promise.withResolvers<{ err: unknown; received: string; held: number }>(); + sockets.writer.on("error", reject); + sockets.reader.on("error", reject); + + pair.stall(); + issue[how](sockets.writer, err => + resolve({ err: err ?? null, received: sockets.received, held: pair.held.length }), + ); + await turn(); + await turn(); + expect(pair.held.length).toBeGreaterThan(0); + pair.release(); + + expect(await promise).toEqual({ err: null, received: "x", held: 0 }); + }); + }); + }); + + it("on a socket of a tls.Server that was handed the Duplex", async () => { + const pair = makePair("server"); + const server = tls.createServer(COMMON_CERT_); + const accepted = once(server, "secureConnection"); + server.emit("connection", pair.serverSide); + const client = tls.connect({ socket: pair.clientSide, rejectUnauthorized: false }); + let received = ""; + client.on("data", chunk => (received += chunk)); + const [socket]: [TLSSocket] = await accepted; + try { + await turn(); + pair.stall(); + const { promise, resolve } = Promise.withResolvers<{ err: unknown; received: string }>(); + socket.write("x", err => resolve({ err: err ?? null, received })); + await turn(); + await turn(); + expect(pair.held.length).toBeGreaterThan(0); + pair.release(); + expect(await promise).toEqual({ err: null, received: "x" }); + } finally { + client.destroy(); + socket.destroy(); + } + }); + + describe.each(["client", "server"] as const)("a %s write that the Duplex fails", side => { + // ``uv[`UV_${err.code}`] || uv.UV_EPIPE`` + it.each([ + [undefined, "EPIPE"], + ["ERR_NOT_AN_ERRNO", "EPIPE"], + ["ECONNRESET", "ECONNRESET"], + ])("with code %p fails with %s, and the Duplex still reports its own error", async (code, expected) => { + const pair = makePair(side); + using sockets = await connectPair(pair, side); + const { writer, reader } = sockets; + const log: string[] = []; + reader.on("error", () => {}); + const transportFailed = once(pair[`${side}Side`], "error"); + const failed = Promise.withResolvers(); + // Node's tls.connect() also forwards the error of the Duplex. + writer.on("error", err => err.message !== "boom" && failed.resolve(err)); + const closed = closeOf(writer); + + pair.stall(); + writer.write("x", err => log.push(`write callback: ${err?.message}`)); + writer.write("y", err => log.push(`queued write callback: ${err?.message}`)); + await turn(); + await turn(); + expect(log).toEqual([]); + pair.held[0][1](Object.assign(new Error("boom"), { code })); + + const err = await failed.promise; + await closed; + expect({ name: err.name, code: err.code, syscall: err.syscall, message: err.message }).toEqual({ + name: "Error", + code: expected, + syscall: "write", + message: `write ${expected}`, + }); + expect(err.errno).toBeLessThan(0); + expect(log).toEqual([`write callback: write ${expected}`, `queued write callback: write ${expected}`]); + expect((await transportFailed)[0].message).toBe("boom"); + }); + }); + + describe.each(["client", "server"] as const)("a %s write in flight", side => { + it.each(["destroy()", "destroy(err)", "the destroy() of the Duplex"])( + "fails at %s, and a Duplex that completes it afterwards changes nothing", + async how => { + const pair = makePair(side); + using sockets = await connectPair(pair, side); + const { writer, reader } = sockets; + const log: string[] = []; + reader.on("error", () => {}); + writer.on("error", err => log.push(`'error': ${err.message}`)); + const closed = closeOf(writer); + + pair.stall(); + writer.write("x", err => log.push(`write callback: ${err ? "failed" : "completed"}`)); + await turn(); + await turn(); + expect(log).toEqual([]); + expect(pair.held.length).toBe(1); + if (how === "destroy()") writer.destroy(); + else if (how === "destroy(err)") writer.destroy(new Error("mine")); + else pair[`${side}Side`].destroy(); + await closed; + await turn(); + const atClose = log.filter(line => line.startsWith("write callback")); + expect(atClose).toEqual(["write callback: failed"]); + + const before = log.length; + for (const [, callback] of pair.held.splice(0)) callback(); + await turn(); + await turn(); + expect(log.length).toBe(before); + }, + ); + }); + + it("end(cb) completes with no error when the Duplex closes before it has taken the close_notify", async () => { + const pair = makePair("server"); + using sockets = await connectPair(pair, "server"); + const { writer, reader } = sockets; + reader.on("error", () => {}); + const log: string[] = []; + writer.on("error", (err: NodeJS.ErrnoException) => log.push(`error ${err.code}`)); + writer.on("finish", () => log.push("finish")); + const closed = closeOf(writer); + + pair.stall(); + writer.end((err?: Error | null) => log.push(`end callback ${err ?? null}`)); + await turn(); + await turn(); + expect(pair.held.length).toBe(1); + pair.serverSide.destroy(); + + await closed; + expect(log).toEqual(["end callback null", "finish"]); + }); + + it("a close_notify that the Duplex fails is no failed write", async () => { + const pair = makePair("server"); + using sockets = await connectPair(pair, "server"); + const { writer, reader } = sockets; + reader.on("error", () => {}); + const log: string[] = []; + pair.serverSide.on("error", err => log.push(`transport 'error': ${err.message}`)); + writer.on("error", err => log.push(`'error': ${err.message}`)); + const closed = new Promise(resolve => + writer.on("close", hadError => resolve(void log.push(`'close': ${hadError}`))), + ); + + pair.stall(); + writer.end(); + await turn(); + await turn(); + expect(pair.held.length).toBe(1); + pair.held[0][1](new Error("boom")); + + await closed; + await turn(); + expect(log).toEqual(["transport 'error': boom", "'close': false"]); + }); + + it("sockets destroyed with a write in flight leave no native socket behind", async () => { + // Bun only: node has no count of native sockets. + const nativeSockets = () => heapStats().objectTypeCounts.TLSSocket || 0; + Bun.gc(true); + const baseline = nativeSockets(); + const count = 10; + // The Duplex of a destroyed socket can keep the callback of its write for as long as it likes. + const held: WriteCallback[] = []; + await (async () => { + for (let i = 0; i < count; i++) { + const side = i % 2 === 0 ? "client" : "server"; + const pair = makePair(side); + using sockets = await connectPair(pair, side); + pair.stall(); + sockets.writer.write("x", () => {}); + await turn(); + held.push(pair.held[0][1]); + } + })(); + let alive = count * 2; + for (let i = 0; i < 10 && alive > 2; i++) { + await turn(); + Bun.gc(true); + alive = nativeSockets() - baseline; + } + expect(held.length).toBe(count); + expect(alive).toBeLessThanOrEqual(2); + }); + + it("end(cb) completes while an http2 session owns the socket", async () => { + const pair = makePair("server"); + const server = http2.createSecureServer(COMMON_CERT_); + server.on("sessionError", () => {}); + server.emit("connection", pair.serverSide); + const client = tls.connect({ socket: pair.clientSide, ALPNProtocols: ["h2"], rejectUnauthorized: false }); + const session = http2.connect("https://localhost", { createConnection: () => client }); + try { + session.on("error", () => {}); + await once(session, "remoteSettings"); + // No reply of the peer can complete it. + pair.stall(); + const log: string[] = []; + const finished = once(client, "finish"); + client.end((err?: Error | null) => log.push(`end callback ${err ?? null}`)); + await finished; + expect(log).toEqual(["end callback null"]); + } finally { + session.destroy(); + client.destroy(); + pair.serverSide.destroy(); + } + }); + + // Not a stream.Writable's write(): that one never runs the callback, or emits 'drain', before it returns. + class SyncSide extends Duplex { + peer!: SyncSide; + writes = 0; + constructor(readonly inWrite: "callback" | "drain and callback") { + super(); + } + _read() {} + write(chunk: Buffer, encoding?: unknown, callback?: unknown): boolean { + this.writes++; + if (this.inWrite === "drain and callback") this.emit("drain"); + this.peer.push(chunk); + (typeof encoding === "function" ? encoding : (callback as WriteCallback | undefined))?.(); + return true; + } + _final(callback: () => void) { + this.peer.push(null); + callback(); + } + } + it.each(["callback", "drain and callback"] as const)( + "a Duplex whose write() runs the %s before it returns gets each byte once", + async inWrite => { + const payload = Buffer.alloc(256 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = (i * 31 + (i >> 8)) & 0xff; + const serverSide = new SyncSide(inWrite); + const clientSide = new SyncSide(inWrite); + serverSide.peer = clientSide; + clientSide.peer = serverSide; + const server = new TLSSocket(serverSide, { + isServer: true, + secureContext: tls.createSecureContext(COMMON_CERT_), + }); + const client = tls.connect({ socket: clientSide, rejectUnauthorized: false }); + try { + const { promise: ended, resolve, reject } = Promise.withResolvers(); + server.on("error", reject); + client.on("error", reject); + const received: Buffer[] = []; + server.on("data", chunk => received.push(chunk)); + server.on("end", resolve); + // Waits for the handshake, whose completion flushes it. + client.write(payload, () => client.write(payload, () => client.end())); + await ended; + expect(Buffer.concat(received).equals(Buffer.concat([payload, payload]))).toBe(true); + } finally { + client.destroy(); + server.destroy(); + } + }, + ); + + it("a write queued before the handshake goes out once over a Duplex that emits 'drain' in its _write()", async () => { + const payload = Buffer.alloc(256 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = (i * 31 + (i >> 8)) & 0xff; + const pair = makePair("server"); + const write = pair.clientSide._write; + pair.clientSide._write = function (chunk, encoding, callback) { + this.emit("drain"); + write.call(this, chunk, encoding, callback); + }; + const server = new TLSSocket(pair.serverSide, { + isServer: true, + secureContext: tls.createSecureContext(COMMON_CERT_), + }); + const client = tls.connect({ socket: pair.clientSide, rejectUnauthorized: false }); + try { + const { promise: ended, resolve, reject } = Promise.withResolvers(); + server.on("error", reject); + client.on("error", reject); + const received: Buffer[] = []; + server.on("data", chunk => received.push(chunk)); + server.on("end", resolve); + client.write(payload, () => client.end()); + await ended; + const all = Buffer.concat(received); + expect(all.length).toBe(payload.length); + expect(all.equals(payload)).toBe(true); + } finally { + client.destroy(); + server.destroy(); + } + }); + + it("an HTTP/2 request completes over a Duplex that emits 'drain' in its _write()", async () => { + const server = http2.createSecureServer(COMMON_CERT_); + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + await once(raw, "connect"); + const transport = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + this.emit("drain"); + raw.write(chunk, callback); + }, + }); + raw.on("data", chunk => transport.push(chunk)); + + const { promise, resolve, reject } = Promise.withResolvers<{ status: unknown; body: string }>(); + raw.on("error", reject); + raw.on("close", () => reject(new Error("the connection closed before the response ended"))); + // A second copy of the client preface reaches the server as a protocol error. + server.on("sessionError", reject); + const session = http2.connect("https://localhost", { + createConnection: () => tls.connect({ socket: transport, ALPNProtocols: ["h2"], rejectUnauthorized: false }), + }); + try { + session.on("error", reject); + const request = session.request({ ":path": "/" }); + request.on("error", reject); + request.on("close", () => reject(new Error("the request closed before its response ended"))); + request.setEncoding("utf8"); + request.on("response", headers => { + let body = ""; + request.on("data", chunk => (body += chunk)); + request.on("end", () => resolve({ status: headers[":status"], body })); + }); + request.end(); + expect(await promise).toEqual({ status: 200, body: "ok" }); + } finally { + session.destroy(); + raw.destroy(); + server.close(); + } + }); +}); diff --git a/test/js/node/tls/renegotiation.test.ts b/test/js/node/tls/renegotiation.test.ts index 7fdde3ada6ba..9f59cfe68696 100644 --- a/test/js/node/tls/renegotiation.test.ts +++ b/test/js/node/tls/renegotiation.test.ts @@ -1397,6 +1397,41 @@ it.concurrent( }, ); +it.concurrent("a write made in the middle of a renegotiation leaves when it ends, over a Duplex", async () => { + const tcp = netConnect(quiet.staysOpen, "127.0.0.1"); + tcp.on("error", () => {}); + const APPLICATION_DATA = 23; + const sent: number[] = []; + // The first thing an established session writes here is the ClientHello of the renegotiation. + const clientHello = Promise.withResolvers<() => void>(); + let established = false; + const duplex = new Duplex({ + read() {}, + write(chunk: Buffer, _encoding: string, callback: () => void) { + const forward = () => void tcp.write(chunk, callback); + if (!established) return forward(); + if (sent.push(chunk[0]) === 1) clientHello.resolve(forward); + else forward(); + }, + }); + tcp.on("data", (chunk: Buffer) => duplex.push(chunk)); + const socket = tlsConnect({ ca: tls.cert, servername: "localhost", socket: duplex }); + try { + const failed = Promise.withResolvers(); + socket.on("error", failed.reject); + socket.once("secureConnect", () => (established = true)); + const forward = await Promise.race([clientHello.promise, failed.promise]); + const written = Promise.withResolvers(); + socket.write("parked", err => (err ? written.reject(err) : written.resolve())); + forward(); + await Promise.race([written.promise, failed.promise]); + expect(sent.at(-1)).toBe(APPLICATION_DATA); + } finally { + socket.destroy(); + tcp.destroy(); + } +}); + it.concurrent("a transport that closes in the middle of a renegotiation is no report over a Duplex", async () => { const events = await nodeTlsEvents("a Duplex", quiet.cut, Infinity, false); // Bun also emits 'end'. From 56435646f16819492a0c97f964cfe404e45782dc Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:08:14 +0000 Subject: [PATCH 119/174] node:tls: report what the EOF probe of a wrapped Duplex throws (#42332) transport_got_eof took the exception of a throwing `_readableState` or `ended` getter and dropped it, which test/internal/source-lints/jsresult-swallow.test.ts counts as a new swallowed JsResult. It goes to the error handler, like what the lookup of write() or end() throws a few lines below. --- src/runtime/socket/UpgradedDuplex.rs | 31 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index eb013fbc8ee8..3c67c56901d9 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -286,8 +286,15 @@ impl UpgradedDuplex { // throws writeAfterFIN (EPIPE). The trailing end() is not a write // and still goes through the writableEnded probe below, so a // half-open transport sees our FIN. - if data.is_some() && Self::transport_got_eof(duplex, &global) { - return; + if data.is_some() { + match Self::transport_got_eof(duplex, &global) { + Ok(false) => {} + Ok(true) => return, + Err(err) => { + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + return; + } + } } // Node ends no destroyed stream. for property in ["writableEnded", "destroyed"] { @@ -343,20 +350,12 @@ impl UpgradedDuplex { } /// Not the 'end' event, which a paused transport holds back. No public property tells. - fn transport_got_eof(duplex: JSValue, global: &JSGlobalObject) -> bool { - let ended = duplex - .get(global, "_readableState") - .and_then(|state| match state { - Some(state) if state.is_object() => state.get(global, "ended"), - _ => Ok(None), - }); - match ended { - Ok(ended) => ended.is_some_and(|ended| ended.to_boolean()), - Err(err) => { - let _ = global.take_exception(err); - false - } - } + fn transport_got_eof(duplex: JSValue, global: &JSGlobalObject) -> JsResult { + let ended = match duplex.get(global, "_readableState")? { + Some(state) if state.is_object() => state.get(global, "ended")?, + _ => None, + }; + Ok(ended.is_some_and(|ended| ended.to_boolean())) } fn internal_write(this: *mut Self, encoded_data: &[u8]) { From 8c93d506743f84073d50413d855443939f3ec205 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:20:29 +0000 Subject: [PATCH 120/174] tls: SSLWrapper queues its ciphertext in one BIO of its own (#44464) SSLWrapper kept its ciphertext in two BIO_s_mem. A writable memory BIO moves every unread byte to its front after each read, so both directions cost time quadratic in what was queued: a write was copied out 64 KiB at a time, a chunk from the transport was taken out one TLS record at a time. One 32 MB chunk into a TLS socket over a Duplex took 12 s of CPU, a 64 MB fetch() body through a CONNECT proxy 1.9 s. The buffer of a memory BIO never shrinks either, so a pooled tunnel kept an allocation the size of the largest body it had sent. One custom BIO is now both ends of the SSL. BoringSSL reads incoming ciphertext from the front of a ring, and handle_writing takes the whole outgoing buffer and gives it to the `write` handler in one call. The frame that hands bytes out owns them, so a write made from inside the handler queues behind them. A queue that empties with more than 128 KiB of capacity gives it back. All four owners (TLS over a Duplex, Windows named pipes, fetch and WebSocket through a CONNECT proxy) now get one `write` per flush instead of 64 KiB pieces. init_with_ctx has no fallible step left between SSL_new and the wrapper that owns the SSL, so its two scopeguards go. BIO and BIO_METHOD become opaque: their by-value layouts did not match the vendored BoringSSL. --- Cargo.lock | 1 - src/boringssl_sys/boringssl.rs | 81 ++--- src/runtime/socket/UpgradedDuplex.rs | 18 +- src/runtime/socket/socket_body.rs | 3 +- src/uws/Cargo.toml | 1 - src/uws/lib.rs | 316 ++++++++++-------- test/js/bun/http/proxy.test.ts | 62 +++- test/js/node/tls/node-tls-connect.test.ts | 212 ++++++++++++ test/js/node/tls/node-tls-namedpipes.test.ts | 47 +++ test/js/web/websocket/websocket-proxy.test.ts | 36 ++ 10 files changed, 563 insertions(+), 214 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3cc4ffbb335..a549a229a3c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1719,7 +1719,6 @@ dependencies = [ "bun_core", "bun_jsc_macros", "bun_uws_sys", - "scopeguard", "strum", ] diff --git a/src/boringssl_sys/boringssl.rs b/src/boringssl_sys/boringssl.rs index 8dd78f0040d3..151c3db492a3 100644 --- a/src/boringssl_sys/boringssl.rs +++ b/src/boringssl_sys/boringssl.rs @@ -769,55 +769,27 @@ pub const SSL_OP_LEGACY_SERVER_CONNECT: u32 = 0; pub const RSA_PKCS1_OAEP_PADDING: c_int = 4; // ═══════════════════════════════════════════════════════════════════════════ -// BIO — opaque-ish handle + method vtable +// BIO // (`vendor/boringssl/include/openssl/bio.h`) // ═══════════════════════════════════════════════════════════════════════════ -/// `CRYPTO_refcount_t` (`openssl/thread.h`) — atomic-ish u32 in BoringSSL. -pub(crate) type CRYPTO_refcount_t = u32; - /// `ossl_ssize_t` — signed counterpart of `size_t` for BoringSSL "length or -1" /// parameters. pub(crate) type ossl_ssize_t = isize; -/// `bio_info_cb` — callback type for `BIO_METHOD.callback_ctrl`. -pub(crate) type bio_info_cb = - Option c_long>; - -/// `struct bio_method_st` — vtable for a BIO implementation. Laid out by-value -/// so callers can construct custom BIO methods on the Rust side. -#[repr(C)] -#[derive(Copy, Clone)] -pub struct BIO_METHOD { - pub r#type: c_int, - pub name: *const c_char, - pub bwrite: Option c_int>, - pub bread: Option c_int>, - pub bputs: Option c_int>, - pub bgets: Option c_int>, - pub ctrl: Option c_long>, - pub create: Option c_int>, - pub destroy: Option c_int>, - pub callback_ctrl: Option c_long>, -} +opaque!( + /// `struct bio_method_st` (`typedef ... BIO_METHOD`). + BIO_METHOD +); +opaque!( + /// `struct bio_st` (`typedef ... BIO`). + BIO +); -/// `struct bio_st` — exposed by-value because callers reach into -/// `flags`/`num`/`ptr` directly when implementing custom BIO backends. -#[repr(C)] -#[derive(Copy, Clone)] -pub struct BIO { - pub method: *const BIO_METHOD, - pub init: c_int, - pub shutdown: c_int, - pub flags: c_int, - pub retry_reason: c_int, - pub num: c_int, - pub references: CRYPTO_refcount_t, - pub ptr: *mut c_void, - pub next_bio: *mut BIO, - pub num_read: usize, - pub num_write: usize, -} +/// `#define BIO_CTRL_FLUSH 11` +pub const BIO_CTRL_FLUSH: c_int = 11; +/// `#define BIO_TYPE_SOURCE_SINK 0x0400` +pub const BIO_TYPE_SOURCE_SINK: c_int = 0x0400; // ═══════════════════════════════════════════════════════════════════════════ // Additional opaque handles @@ -884,8 +856,6 @@ unsafe extern "C" { pub fn SSL_set_connect_state(ssl: *mut SSL); pub fn SSL_set_accept_state(ssl: *mut SSL); pub fn SSL_set_bio(ssl: *mut SSL, rbio: *mut BIO, wbio: *mut BIO); - pub fn SSL_get_rbio(ssl: *const SSL) -> *mut BIO; - pub fn SSL_get_wbio(ssl: *const SSL) -> *mut BIO; pub fn SSL_do_handshake(ssl: *mut SSL) -> c_int; pub fn SSL_read(ssl: *mut SSL, buf: *mut c_void, num: c_int) -> c_int; pub fn SSL_pending(ssl: *const SSL) -> c_int; @@ -969,13 +939,26 @@ unsafe extern "C" { // ── BIO ────────────────────────────────────────────────────────────── pub fn BIO_new(method: *const BIO_METHOD) -> *mut BIO; pub fn BIO_free(bio: *mut BIO) -> c_int; - pub fn BIO_read(bio: *mut BIO, data: *mut c_void, len: c_int) -> c_int; - pub fn BIO_write(bio: *mut BIO, data: *const c_void, len: c_int) -> c_int; - pub fn BIO_ctrl_pending(bio: *const BIO) -> usize; - pub fn BIO_reset(bio: *mut BIO) -> c_int; - pub safe fn BIO_s_mem() -> *const BIO_METHOD; pub fn BIO_new_mem_buf(buf: *const c_void, len: ossl_ssize_t) -> *mut BIO; - pub fn BIO_set_mem_eof_return(bio: *mut BIO, eof_value: c_int) -> c_int; + pub safe fn BIO_get_new_index() -> c_int; + pub fn BIO_meth_new(r#type: c_int, name: *const c_char) -> *mut BIO_METHOD; + pub fn BIO_meth_set_write( + method: *mut BIO_METHOD, + write_func: Option c_int>, + ) -> c_int; + pub fn BIO_meth_set_read( + method: *mut BIO_METHOD, + read_func: Option c_int>, + ) -> c_int; + pub fn BIO_meth_set_ctrl( + method: *mut BIO_METHOD, + ctrl_func: Option c_long>, + ) -> c_int; + pub fn BIO_set_data(bio: *mut BIO, ptr: *mut c_void); + pub fn BIO_get_data(bio: *mut BIO) -> *mut c_void; + pub fn BIO_set_init(bio: *mut BIO, init: c_int); + pub fn BIO_set_retry_read(bio: *mut BIO); + pub fn BIO_clear_retry_flags(bio: *mut BIO); // ── RAND ───────────────────────────────────────────────────────────── /// Fills `buf[0..len]` from BoringSSL's thread-local CTR-DRBG and returns 1. diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 3c67c56901d9..c5717b13f7f9 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -407,23 +407,11 @@ impl UpgradedDuplex { self.drain_pending_shutdown(); return; } - // Taking ownership is load-bearing: a re-entrant `teardown()` clears - // `pending_data`, and BoringSSL must not have the slice freed under it. + // Taking ownership is load-bearing: a re-entrant `teardown()` clears `pending_data`. let staged = self.pending_data.replace(Vec::new()); self.reset_timeout(); - // Feed in bounded slices rather than one concatenated buffer. Each JS - // chunk was originally delivered on its own; `receive_data` casts the - // length to `c_int` with a panicking `expect`, so handing it the sum of - // every chunk staged in the window would turn a large pre-start burst - // into a process abort. Re-check the engine each round: BoringSSL can - // re-enter and tear it down partway through, and `teardown()` neuters - // in place (frees the SSL, keeps the Option `Some`), so the live - // signal is the SSL handle, not the Option. - for chunk in staged.chunks(64 * 1024) { - match self.wrapper_ref() { - Some(w) if w.ssl.get().is_some() => w.receive_data(chunk), - _ => break, - } + if let Some(w) = self.wrapper_ref() { + w.receive_data(&staged); } self.drain_pending_shutdown(); } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index b87aa7e6bcb2..202c62f8d0b4 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -155,8 +155,7 @@ extern "C" fn select_alpn_callback( // handler, the selection's `toString`, the scope's checkpoint), and // restore it on every path back to BoringSSL — after the scope guard // below has exited, since this guard is declared first. Connected - // usockets only: UpgradedDuplex/Pipe own mem BIOs whose BIO_get_data - // is a BUF_MEM*, not loop_ssl_data. + // usockets only: the BIO_get_data of an UpgradedDuplex/Pipe is not loop_ssl_data. const LOOP_STATE_SLOTS: usize = 6; // US_SSL_LOOP_STATE_SLOTS debug_assert_eq!( LOOP_STATE_SLOTS as core::ffi::c_int, diff --git a/src/uws/Cargo.toml b/src/uws/Cargo.toml index fe7326717be5..547e1a73238b 100644 --- a/src/uws/Cargo.toml +++ b/src/uws/Cargo.toml @@ -12,7 +12,6 @@ workspace = true [dependencies] strum.workspace = true bstr.workspace = true -scopeguard.workspace = true bun_boringssl.workspace = true bun_core.workspace = true bun_jsc_macros.workspace = true diff --git a/src/uws/lib.rs b/src/uws/lib.rs index fde89d2ccae3..180ac3e79b34 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -153,24 +153,30 @@ pub use ssl_wrapper::check_server_identity; pub mod ssl_wrapper { use core::cell::{Cell, RefCell}; - use core::ffi::{c_char, c_int, c_void}; + use core::ffi::{c_char, c_int, c_long, c_void}; use core::ptr::NonNull; + use core::sync::atomic::{AtomicPtr, Ordering}; use std::collections::VecDeque; + use std::sync::OnceLock; + + use bun_core::UnwrapOrOom; // Re-export the canonical BoringSSL FFI surface; the lower-tier crate now // declares every symbol SSLWrapper needs, so the old local shim is gone. mod boring_sys { pub(super) use bun_boringssl::c::{ - BIO_ctrl_pending, BIO_free, BIO_new, BIO_read, BIO_reset, BIO_s_mem, - BIO_set_mem_eof_return, BIO_write, ERR_clear_error, OwnedSslCtx, SSL, SSL_CTX, - SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, - SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_SESSION, - SSL_SESSION_free, SSL_VERIFY_FAIL_IF_NO_PEER_CERT, SSL_VERIFY_NONE, SSL_VERIFY_PEER, - SSL_do_handshake, SSL_free, SSL_get_error, SSL_get_rbio, SSL_get_shutdown, - SSL_get_verify_result, SSL_get_wbio, SSL_is_init_finished, SSL_new, SSL_pending, - SSL_read, SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, - SSL_set_renegotiate_mode, SSL_set_session_id_context, SSL_set_verify, SSL_shutdown, - SSL_write, X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never, + BIO, BIO_CTRL_FLUSH, BIO_METHOD, BIO_TYPE_SOURCE_SINK, BIO_clear_retry_flags, + BIO_get_data, BIO_get_new_index, BIO_meth_new, BIO_meth_set_ctrl, BIO_meth_set_read, + BIO_meth_set_write, BIO_new, BIO_set_data, BIO_set_init, BIO_set_retry_read, + ERR_clear_error, OwnedSslCtx, SSL, SSL_CTX, SSL_ERROR_SSL, SSL_ERROR_SYSCALL, + SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, SSL_ERROR_WANT_WRITE, + SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_SESSION, SSL_SESSION_free, + SSL_VERIFY_FAIL_IF_NO_PEER_CERT, SSL_VERIFY_NONE, SSL_VERIFY_PEER, SSL_do_handshake, + SSL_free, SSL_get_error, SSL_get_shutdown, SSL_get_verify_result, SSL_is_init_finished, + SSL_new, SSL_pending, SSL_read, SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, + SSL_set_connect_state, SSL_set_renegotiate_mode, SSL_set_session_id_context, + SSL_set_verify, SSL_shutdown, SSL_write, X509_STORE_CTX, ssl_renegotiate_explicit, + ssl_renegotiate_never, }; } @@ -194,21 +200,102 @@ pub mod ssl_wrapper { // the network. write passes the encrypted data that we want to send to // the network. on_close is triggered when we want the network connection // to be closed (remember to flush before closing). - // - // Notes: - // SSL_read() reads unencrypted data which is stored in the input BIO. - // SSL_write() writes unencrypted data into the output BIO. - // BIO_write() writes encrypted data into the input BIO. - // BIO_read() reads encrypted data from the output BIO. - - /// 64kb nice buffer size for SSL reads and writes, should be enough for - /// most cases. In reads we loop until we have no more data to read and in - /// writes we loop until we have no more data to write/backpressure. + + /// 64kb nice buffer size for SSL reads: we loop until we have no more data to read. const BUFFER_SIZE: usize = 65536; - /// Stack scratch shared by `SSL_read` / `BIO_read`. + /// Stack scratch of `SSL_read`. type IoBuffer = bun_core::vec::UninitBuf; + /// A queue that empties with more capacity than this gives it back. + const QUEUE_RETAIN_LIMIT: usize = 2 * BUFFER_SIZE; + + /// `SSL3_RT_MAX_PLAIN_LENGTH` + const MAX_RECORD_PLAINTEXT: usize = 16384; + /// `SSL3_RT_HEADER_LENGTH + SSL3_RT_SEND_MAX_ENCRYPTED_OVERHEAD` + const MAX_RECORD_OVERHEAD: usize = 5 + 88; + + /// What the one BIO of the `SSL` reads and writes. No borrow spans an `SSL_*` call or a handler. + #[derive(Default)] + struct Ciphertext { + /// Sealed, not yet given to `Handlers::write`. + outgoing: RefCell>, + /// From the peer, not yet read by BoringSSL. + incoming: RefCell>, + } + + extern "C" fn bio_write(bio: *mut boring_sys::BIO, data: *const c_char, len: c_int) -> c_int { + let Ok(count) = usize::try_from(len) else { + return -1; + }; + // SAFETY: `init_with_ctx` pointed this live BIO at the `Ciphertext` of the box that outlives it; BoringSSL passes `len` readable bytes. + let (ciphertext, data) = unsafe { + ( + &*boring_sys::BIO_get_data(bio).cast::(), + core::slice::from_raw_parts(data.cast::(), count), + ) + }; + let mut outgoing = ciphertext.outgoing.borrow_mut(); + outgoing.try_reserve(count).unwrap_or_oom(); + outgoing.extend_from_slice(data); + len + } + + extern "C" fn bio_read(bio: *mut boring_sys::BIO, out: *mut c_char, len: c_int) -> c_int { + // SAFETY: as in `bio_write`. + let ciphertext = unsafe { + boring_sys::BIO_clear_retry_flags(bio); + &*boring_sys::BIO_get_data(bio).cast::() + }; + let mut incoming = ciphertext.incoming.borrow_mut(); + // BoringSSL reads again for what lies past the point where the ring wraps. + let (front, _) = incoming.as_slices(); + let count = front.len().min(usize::try_from(len).unwrap_or(0)); + if count == 0 { + // SAFETY: `bio` is live. + unsafe { boring_sys::BIO_set_retry_read(bio) }; + return -1; + } + // SAFETY: BoringSSL passes `len` writable bytes of its own, and `count <= len`. + unsafe { core::ptr::copy_nonoverlapping(front.as_ptr(), out.cast::(), count) }; + incoming.drain(..count); + if incoming.is_empty() && incoming.capacity() > QUEUE_RETAIN_LIMIT { + *incoming = VecDeque::new(); + } + c_int::try_from(count).unwrap_or(len) + } + + /// BoringSSL fails a flight whose `BIO_flush` reports 0. + extern "C" fn bio_ctrl( + _: *mut boring_sys::BIO, + cmd: c_int, + _: c_long, + _: *mut c_void, + ) -> c_long { + c_long::from(cmd == boring_sys::BIO_CTRL_FLUSH) + } + + fn bio_method() -> *const boring_sys::BIO_METHOD { + static METHOD: OnceLock> = OnceLock::new(); + METHOD + .get_or_init(|| { + // A type of its own: see `us_ssl_is_socket` in openssl.c. + let r#type = boring_sys::BIO_get_new_index() | boring_sys::BIO_TYPE_SOURCE_SINK; + // SAFETY: the name is static, and a null `method` does not get past the check. + unsafe { + let method = boring_sys::BIO_meth_new(r#type, c"SSLWrapper".as_ptr()); + if method.is_null() { + bun_core::out_of_memory(); + } + boring_sys::BIO_meth_set_write(method, Some(bio_write)); + boring_sys::BIO_meth_set_read(method, Some(bio_read)); + boring_sys::BIO_meth_set_ctrl(method, Some(bio_ctrl)); + AtomicPtr::new(method) + } + }) + .load(Ordering::Relaxed) + } + /// Cap on peer-initiated TLS renegotiations per /// [`MAX_RENEGOTIATION_WINDOW`]. Mirrors the `us_reneg_policy` defaults in /// the uSockets C path (openssl.c) and Node's @@ -270,6 +357,7 @@ pub mod ssl_wrapper { pub(crate) renegotiation_count: Cell, pub(crate) renegotiation_window_start: Cell>, traffic: Cell, + ciphertext: Ciphertext, } /// Re-entrancy state of [`SSLWrapper::handle_traffic`]. @@ -448,11 +536,9 @@ pub mod ssl_wrapper { // SAFETY: ctx is a live SSL_CTX; SSL_new returns null on OOM. let ssl = NonNull::new(unsafe { boring_sys::SSL_new(ctx.as_ptr()) }) .ok_or(InitError::OutOfMemory)?; - // errdefer BoringSSL.SSL_free(ssl) — FFI cleanup on early return - let ssl_guard = scopeguard::guard(ssl, |ssl| { - // SAFETY: ssl was created by SSL_new above and is solely owned by this guard until disarmed. - unsafe { boring_sys::SSL_free(ssl.as_ptr()) }; - }); + // SAFETY: the method lives as long as the process; BIO_new returns null on OOM. + let bio = NonNull::new(unsafe { boring_sys::BIO_new(bio_method()) }) + .unwrap_or_else(|| bun_core::out_of_memory()); // OpenSSL enables TLS renegotiation by default and accepts // renegotiation requests from the peer transparently. @@ -495,30 +581,6 @@ pub mod ssl_wrapper { boring_sys::SSL_set_accept_state(ssl.as_ptr()); } } - // SAFETY: BIO_s_mem returns a static method table; BIO_new returns null on OOM. - let input = NonNull::new(unsafe { boring_sys::BIO_new(boring_sys::BIO_s_mem()) }) - .ok_or(InitError::OutOfMemory)?; - // errdefer _ = BoringSSL.BIO_free(input) - let input_guard = scopeguard::guard(input, |bio| { - // SAFETY: bio was created by BIO_new above and not yet transferred to SSL_set_bio. - unsafe { - let _ = boring_sys::BIO_free(bio.as_ptr()); - } - }); - // SAFETY: same as above. - let output = NonNull::new(unsafe { boring_sys::BIO_new(boring_sys::BIO_s_mem()) }) - .ok_or(InitError::OutOfMemory)?; - // Set the EOF return value to -1 so that we can detect when the BIO is empty using BIO_ctrl_pending - // SAFETY: input/output are valid BIOs we just created; ssl is valid. - unsafe { - let _ = boring_sys::BIO_set_mem_eof_return(input.as_ptr(), -1); - let _ = boring_sys::BIO_set_mem_eof_return(output.as_ptr(), -1); - // Set the input and output BIOs - boring_sys::SSL_set_bio(ssl.as_ptr(), input.as_ptr(), output.as_ptr()); - } - // Ownership of input/output transferred to ssl via SSL_set_bio; disarm guards. - let _ = scopeguard::ScopeGuard::into_inner(input_guard); - let ssl = scopeguard::ScopeGuard::into_inner(ssl_guard); let flags = Flags::default(); flags.set_is_client(is_client); @@ -545,10 +607,17 @@ pub mod ssl_wrapper { renegotiation_count: Cell::new(0), renegotiation_window_start: Cell::new(None), traffic: Cell::new(Traffic::Idle), + ciphertext: Ciphertext::default(), }); let this = Self { inner }; - // SAFETY: `ssl` is live; the box outlives it, `deinit` frees `ssl` first. + // SAFETY: `ssl` and `bio` are live; the box outlives both, `deinit` frees `ssl` first. One BIO for both ends gives `ssl` its one reference. unsafe { + boring_sys::BIO_set_data( + bio.as_ptr(), + core::ptr::from_ref(&this.ciphertext).cast_mut().cast(), + ); + boring_sys::BIO_set_init(bio.as_ptr(), 1); + boring_sys::SSL_set_bio(ssl.as_ptr(), bio.as_ptr(), bio.as_ptr()); us_ssl_set_wrapper( ssl.as_ptr(), core::ptr::from_ref(&this.callbacks).cast_mut().cast(), @@ -753,48 +822,32 @@ pub mod ssl_wrapper { return false; } } - // SSL_shutdown only queues close_notify into the write BIO; nothing - // else pumps it on the memory-BIO paths (duplex / named pipe), so - // drain it now or the peer never sees our shutdown. - let mut buffer = IoBuffer::uninit(); - self.handle_writing(&mut buffer); + // SSL_shutdown only queues close_notify, and nothing else hands it to the owner. + self.handle_writing(); ret == 1 // truly closed } /// flush buffered data and returns amount of pending data to write pub fn flush(&self) -> usize { - // handle_traffic may trigger a close callback which frees ssl, - // so we must not capture the ssl pointer before calling it. self.handle_traffic(); - let Some(ssl) = self.ssl.get() else { return 0 }; - // SAFETY: ssl is a live SSL*; SSL_get_wbio returns the BIO bound in init_with_ctx. - unsafe { boring_sys::BIO_ctrl_pending(boring_sys::SSL_get_wbio(ssl.as_ptr())) } + self.ciphertext.outgoing.borrow().len() } - /// Return if we have pending data to be read or write. Covers both - /// BIOs and `SSL_pending` — decrypted bytes of a partially-returned - /// record are buffered inside the SSL, invisible to either BIO. + /// Ciphertext is queued in either direction, or the SSL holds the rest of a partially-returned record. pub fn has_pending_data(&self) -> bool { let Some(ssl) = self.ssl.get() else { return false; }; - // SAFETY: ssl is a live SSL*; rbio/wbio bound in init_with_ctx. - unsafe { - boring_sys::SSL_pending(ssl.as_ptr()) > 0 - || boring_sys::BIO_ctrl_pending(boring_sys::SSL_get_wbio(ssl.as_ptr())) > 0 - || boring_sys::BIO_ctrl_pending(boring_sys::SSL_get_rbio(ssl.as_ptr())) > 0 - } + // SAFETY: ssl is a live SSL*. + let decrypted = unsafe { boring_sys::SSL_pending(ssl.as_ptr()) }; + decrypted > 0 + || !self.ciphertext.outgoing.borrow().is_empty() + || self.has_pending_read() } - /// Return if we buffered data inside the BIO read buffer, not - /// necessarily will return data to read. This dont reflect - /// SSL_pending(). + /// Ciphertext from the peer is queued. This dont reflect SSL_pending(). fn has_pending_read(&self) -> bool { - let Some(ssl) = self.ssl.get() else { - return false; - }; - // SAFETY: ssl is a live SSL*. - unsafe { boring_sys::BIO_ctrl_pending(boring_sys::SSL_get_rbio(ssl.as_ptr())) > 0 } + !self.ciphertext.incoming.borrow().is_empty() } /// We sent or received a shutdown (closing or closed) @@ -811,24 +864,15 @@ pub mod ssl_wrapper { /// Receive data from the network (encrypted data) pub fn receive_data(&self, data: &[u8]) { - let Some(ssl) = self.ssl.get() else { return }; - - // SAFETY: ssl is a live SSL*; rbio bound in init_with_ctx. - let Some(input) = NonNull::new(unsafe { boring_sys::SSL_get_rbio(ssl.as_ptr()) }) - else { + if self.ssl.get().is_none() { return; - }; - // SAFETY: input is a valid BIO*; data is a valid &[u8] for len bytes. - let written = unsafe { - boring_sys::BIO_write( - input.as_ptr(), - data.as_ptr().cast::(), - c_int::try_from(data.len()).expect("int cast"), - ) - }; - if written > -1 { - self.handle_traffic(); } + { + let mut incoming = self.ciphertext.incoming.borrow_mut(); + incoming.try_reserve(data.len()).unwrap_or_oom(); + incoming.extend(data); + } + self.handle_traffic(); } /// Send data to the network (unencrypted data) @@ -857,12 +901,20 @@ pub mod ssl_wrapper { self.handle_traffic(); return Err(WriteDataError::WantRead); } + let data = &data[..data.len().min(c_int::MAX as usize)]; + // One growth for all the records of this write. + let records = data.len() / MAX_RECORD_PLAINTEXT + 1; + self.ciphertext + .outgoing + .borrow_mut() + .try_reserve(data.len() + records * MAX_RECORD_OVERHEAD) + .unwrap_or_oom(); // SAFETY: ssl is a live SSL*; data is a valid &[u8] for len bytes. let written = unsafe { boring_sys::SSL_write( ssl.as_ptr(), data.as_ptr().cast::(), - c_int::try_from(data.len()).unwrap_or(c_int::MAX), + data.len() as c_int, ) }; if written <= 0 { @@ -903,7 +955,7 @@ pub mod ssl_wrapper { fn free(ssl: Option>) { if let Some(ssl) = ssl { - // SAFETY: ssl was created by SSL_new and the caller took it out of `self.ssl`; SSL_free also frees the input and output BIOs. + // SAFETY: ssl was created by SSL_new and the caller took it out of `self.ssl`; SSL_free also frees its BIO. unsafe { boring_sys::SSL_free(ssl.as_ptr()) }; } } @@ -1033,12 +1085,8 @@ pub mod ssl_wrapper { && unsafe { boring_sys::SSL_get_verify_result(ssl.as_ptr()) } != 0 { boring_sys::ERR_clear_error(); - // Reset, not only skip the flush below: a re-entered - // `handle_traffic` flushes the write BIO and never gets here. - // SAFETY: wbio is the mem BIO bound in init_with_ctx. - unsafe { - let _ = boring_sys::BIO_reset(boring_sys::SSL_get_wbio(ssl.as_ptr())); - } + // Not only skipped below: a re-entered `handle_traffic` flushes the queue and never gets here. + self.ciphertext.outgoing.borrow_mut().clear(); // The peer never gets our Finished, so it cannot read a close_notify. self.flags.set_fatal_error(true); self.flags @@ -1077,7 +1125,7 @@ pub mod ssl_wrapper { self.flags .set_handshake_state(HandshakeState::HandshakeCompleted); // Our fatal alert leaves before the owner tears the transport down. - self.handle_writing(&mut IoBuffer::uninit()); + self.handle_writing(); self.trigger_handshake_callback(HandshakeOutcome::HandshakeError(fatal_reason)); if self.flags.fatal_error() { @@ -1211,7 +1259,7 @@ pub mod ssl_wrapper { } if is_fatal { // Our fatal alert leaves before the owner tears the transport down. - self.handle_writing(buffer); + self.handle_writing(); } if reason.is_some() { self.trigger_handshake_callback(HandshakeOutcome::HandshakeError( @@ -1261,39 +1309,19 @@ pub mod ssl_wrapper { self.ssl.get().is_some() && !self.flags.closed_notified() } - fn handle_writing(&self, buffer: &mut IoBuffer) { - let mut read: usize = 0; - loop { - let Some(ssl) = self.ssl.get() else { return }; - // SAFETY: ssl is a live SSL*; wbio bound in init_with_ctx. - let Some(output) = NonNull::new(unsafe { boring_sys::SSL_get_wbio(ssl.as_ptr()) }) - else { - return; - }; - // SAFETY: write-only view of the unfilled tail; BIO_read only stores into it. - let available = unsafe { &mut buffer.as_bytes_mut()[read..] }; - // SAFETY: output is a valid BIO*; available is a valid mutable slice. - let just_read = unsafe { - boring_sys::BIO_read( - output.as_ptr(), - available.as_mut_ptr().cast::(), - c_int::try_from(available.len()).expect("int cast"), - ) - }; - if just_read > 0 { - read += usize::try_from(just_read).expect("int cast"); - if read == BUFFER_SIZE { - // SAFETY: the BIO_read calls above wrote `[0..read]` contiguously. - self.trigger_wanna_write_callback(unsafe { buffer.filled(read) }); - read = 0; - } - } else { - break; - } + /// This frame owns what it hands out: a write made from inside `write` queues behind it. + fn handle_writing(&self) { + if self.ssl.get().is_none() { + return; } - if read > 0 { - // SAFETY: the BIO_read calls above wrote `[0..read]` contiguously. - self.trigger_wanna_write_callback(unsafe { buffer.filled(read) }); + let mut pending = self.ciphertext.outgoing.take(); + if !pending.is_empty() { + self.trigger_wanna_write_callback(&pending); + pending.clear(); + } + let mut outgoing = self.ciphertext.outgoing.borrow_mut(); + if outgoing.capacity() == 0 && pending.capacity() <= QUEUE_RETAIN_LIMIT { + *outgoing = pending; } } @@ -1305,8 +1333,7 @@ pub mod ssl_wrapper { fn handle_traffic(&self) { if self.traffic.get() != Traffic::Idle { log!("handleTraffic re-entered, flushing and deferring to the outer pass"); - let mut buffer = IoBuffer::uninit(); - self.handle_writing(&mut buffer); + self.handle_writing(); self.traffic.set(Traffic::RerunRequested); return; } @@ -1327,13 +1354,12 @@ pub mod ssl_wrapper { fn traffic_pass(&self) { // always handle the handshake first if self.update_handshake_state() { - // shared stack buffer for reading and writing + // send what the handshake queued first + self.handle_writing(); + // PERF: 64KiB on-stack array — verify stack-size headroom. let mut buffer = IoBuffer::uninit(); - // drain the input BIO first - self.handle_writing(&mut buffer); - - // drain the output BIO in loop, because read can trigger writing and vice versa + // drain the incoming queue in loop, because read can trigger writing and vice versa // Once a callback re-entered, the next pass takes over: the bytes it fed in // may belong to the handshake, which only update_handshake_state reports. while self.traffic.get() == Traffic::Running @@ -1341,7 +1367,7 @@ pub mod ssl_wrapper { && self.handle_reading(&mut buffer) { // read data can trigger writing so we need to handle it - self.handle_writing(&mut buffer); + self.handle_writing(); } if self.traffic.get() != Traffic::Running { return; diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index c3f8e4061e6b..106f8ba0c64d 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -2,7 +2,7 @@ import axios from "axios"; import type { Server } from "bun"; import { proxyInternals } from "bun:internal-for-testing"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isIPv6, isWindows, tempDir, tls as tlsCert } from "harness"; +import { bunEnv, bunExe, expectRssDeltaBelow, isASAN, isIPv6, isWindows, tempDir, tls as tlsCert } from "harness"; import { HttpsProxyAgent } from "https-proxy-agent"; import { once } from "node:events"; import http from "node:http"; @@ -1189,6 +1189,66 @@ test("HTTPS over HTTP proxy preserves TLS record order with large bodies", async } }); +test("HTTPS over an HTTPS proxy preserves TLS record order with a large body", async () => { + // The tunnel gets all the ciphertext of the body at once, and the TLS socket to the proxy takes it in parts. + using origin = Bun.serve({ + port: 0, + tls: tlsCert, + fetch: async req => new Response(Bun.hash(await req.arrayBuffer()).toString()), + }); + const body = Buffer.alloc(16 * 1024 * 1024, Buffer.from(Array.from({ length: 251 }, (_, i) => i))); + const response = await fetch(origin.url, { + method: "POST", + proxy: httpsProxyServer.url, + body, + keepalive: false, + tls: { ca: tlsCert.cert, rejectUnauthorized: false }, + }); + expect(await response.text()).toBe(Bun.hash(body).toString()); +}); + +test("a pooled HTTPS proxy tunnel does not keep the memory of a large request body", async () => { + using origin = Bun.serve({ + port: 0, + tls: tlsCert, + async fetch(req) { + let received = 0; + for await (const chunk of req.body!) received += chunk.byteLength; + return new Response(String(received)); + }, + }); + const MiB = 1024 * 1024; + const bodyMiB = 64; + const fixture = ` + async function post(size) { + const res = await fetch(${JSON.stringify(origin.url.href)}, { + method: "POST", + body: Buffer.alloc(size, "a"), + proxy: ${JSON.stringify(httpProxyServer.url)}, + tls: { rejectUnauthorized: false }, + }); + const received = await res.text(); + if (received !== String(size)) throw new Error("the origin received " + received + " of " + size + " bytes"); + } + // The first request opens the tunnel. Every later one reuses it. + await post(1024); + Bun.gc(true); + const before = process.memoryUsage.rss(); + await post(${bodyMiB * MiB}); + let kept = Infinity; + for (let attempt = 0; attempt < 100 && kept > ${(bodyMiB / 4) * MiB}; attempt++) { + await post(1024); + Bun.gc(true); + kept = Math.min(kept, process.memoryUsage.rss() - before); + } + console.log(JSON.stringify({ deltaMiB: Math.round(kept / ${MiB}) })); + `; + httpProxyServer.log.length = 0; + // A tunnel that keeps the ciphertext queue of the body keeps more than the body. + await expectRssDeltaBelow(["-e", fixture], { release: bodyMiB / 2, debug: bodyMiB / 2 }); + expect(httpProxyServer.log.filter(line => line === `CONNECT localhost:${origin.port}`).length).toBe(1); +}); + test("HTTPS origin close-delimited body via HTTP proxy does not ECONNRESET", async () => { // Inline raw HTTPS origin: 200 + no Content-Length then close const originServer = tls.createServer( diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 9021a199d90e..b0f510fb138b 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -8,6 +8,7 @@ import { bunRun, tls as COMMON_CERT_, isASAN, + isWindows, nodeExe, rejectUnauthorizedScope, tempDir, @@ -6719,3 +6720,214 @@ describe("a write on a TLS socket over a Duplex completes when the Duplex has co } }); }); + +describe("large writes and reads of a TLS socket over a Duplex", () => { + const pattern = Buffer.from(Array.from({ length: 251 }, (_, i) => i)); + + // A client and a server TLSSocket joined in memory. `wire` sees every chunk of + // ciphertext the named side writes and returns what to deliver to the peer. + function connectedPair(wire: (from: "client" | "server", chunk: Buffer) => Buffer | null = (_, chunk) => chunk) { + const makeSide = (name: "client" | "server", peer: () => Duplex) => + new Duplex({ + read() {}, + write(chunk: Buffer, _encoding, callback) { + const forwarded = wire(name, chunk); + if (forwarded !== null) peer().push(forwarded); + callback(); + }, + final(callback) { + peer().push(null); + callback(); + }, + }); + const clientSide: Duplex = makeSide("client", () => serverSide); + const serverSide: Duplex = makeSide("server", () => clientSide); + const server = new TLSSocket(serverSide, { isServer: true, secureContext: tls.createSecureContext(COMMON_CERT_) }); + server.on("error", () => {}); + server.on("end", () => server.end()); + const client = tls.connect({ socket: clientSide, rejectUnauthorized: false }); + return { + client, + server, + clientSide, + [Symbol.dispose]() { + client.destroy(); + server.destroy(); + }, + }; + } + + // Resolves with the next `length` bytes `socket` emits. + function receive(socket: TLSSocket, length: number) { + const { promise, resolve, reject } = Promise.withResolvers(); + const chunks: Buffer[] = []; + let received = 0; + const onData = (chunk: Buffer) => { + chunks.push(chunk); + received += chunk.length; + if (received < length) return; + socket.off("data", onData); + socket.off("error", reject); + resolve(Buffer.concat(chunks)); + }; + socket.on("data", onData); + socket.once("error", reject); + return promise; + } + + // The ciphertext of `payload`, which the server's transport keeps from the client. + async function seal(pair: ReturnType, hold: (held: Buffer[] | null) => void, payload: Buffer) { + const held: Buffer[] = []; + hold(held); + const { promise, resolve, reject } = Promise.withResolvers(); + pair.server.write(payload, err => (err ? reject(err) : resolve())); + await promise; + hold(null); + return Buffer.concat(held); + } + function holdingPair() { + let held: Buffer[] | null = null; + const pair = connectedPair((from, chunk) => { + if (held === null || from === "client") return chunk; + held.push(chunk); + return null; + }); + return { pair, hold: (to: Buffer[] | null) => void (held = to) }; + } + + it("one write() reaches the Duplex in a few chunks, not one for each 64 KiB", async () => { + const payload = Buffer.alloc(1024 * 1024, pattern); + const transportWrites: number[] = []; + let recording = false; + using pair = connectedPair((from, chunk) => { + if (recording && from === "client") transportWrites.push(chunk.length); + return chunk; + }); + const { client, server } = pair; + await once(client, "secureConnect"); + + const received = receive(server, payload.length); + recording = true; + client.write(payload); + const plaintext = await received; + recording = false; + + expect(plaintext.equals(payload)).toBe(true); + // Node v26.3.0: 3. + expect(transportWrites.length).toBeLessThanOrEqual(3); + }); + + // process.cpuUsage() advances in ticks of about 15.6 ms on Windows. + it.skipIf(isWindows)( + "one large chunk from the Duplex costs the CPU time of the same bytes in small chunks", + async () => { + const payload = Buffer.alloc(8 * 1024 * 1024, pattern); + const { pair, hold } = holdingPair(); + using _ = pair; + const { client, clientSide } = pair; + await once(client, "secureConnect"); + + async function cpuTimeToReceive(pieceSize: number) { + const ciphertext = await seal(pair, hold, payload); + const received = receive(client, payload.length); + const before = process.cpuUsage(); + for (let offset = 0; offset < ciphertext.length; offset += pieceSize) { + clientSide.push(ciphertext.subarray(offset, offset + pieceSize)); + } + const plaintext = await received; + const { user, system } = process.cpuUsage(before); + expect(plaintext.equals(payload)).toBe(true); + return user + system; + } + + // The best of two interleaved runs each, so that a busy machine does not decide the ratio. + let small = Infinity; + let large = Infinity; + for (let run = 0; run < 2; run++) { + small = Math.min(small, await cpuTimeToReceive(64 * 1024)); + large = Math.min(large, await cpuTimeToReceive(Infinity)); + } + // About 1 when the cost is linear in the chunk, 16 and up when it is quadratic. + expect(large / small).toBeLessThan(4); + }, + ); + + it("writes made from 'data' and from the write() of the Duplex keep both streams in order", async () => { + const fromServer = Buffer.alloc(320 * 1024, pattern); + const fromData = Buffer.alloc(200 * 1024, pattern.subarray(7)); + const fromTransportWrite = Buffer.alloc(150 * 1024, pattern.subarray(13)); + + let writeFromTransport = false; + using pair = connectedPair((from, chunk) => { + if (from === "client" && writeFromTransport) { + writeFromTransport = false; + client.write(fromTransportWrite); + } + return chunk; + }); + const { client, server } = pair; + await once(client, "secureConnect"); + + server.once("data", () => server.write(fromServer)); + const atServer = receive(server, 2 + fromData.length + fromTransportWrite.length); + + const chunks: Buffer[] = []; + let depth = 0; + let nestedDataEvents = 0; + let received = 0; + let repliedFromData = false; + const allAtClient = Promise.withResolvers(); + client.on("data", (chunk: Buffer) => { + depth++; + if (depth > 1) nestedDataEvents++; + chunks.push(chunk); + received += chunk.length; + if (!repliedFromData) { + repliedFromData = true; + writeFromTransport = true; + client.write(fromData); + } + if (received >= fromServer.length) allAtClient.resolve(); + depth--; + }); + client.on("error", allAtClient.reject); + client.write("go"); + + const [serverGot] = await Promise.all([atServer, allAtClient.promise]); + expect({ + nestedDataEvents, + clientGotAll: Buffer.concat(chunks).equals(fromServer), + serverGotAll: serverGot.equals(Buffer.concat([Buffer.from("go"), fromData, fromTransportWrite])), + }).toEqual({ nestedDataEvents: 0, clientGotAll: true, serverGotAll: true }); + }); + + it("ciphertext that arrives from inside 'data' queues behind what is not decrypted yet", async () => { + const payload = Buffer.alloc(1024 * 1024, pattern); + const { pair, hold } = holdingPair(); + using _ = pair; + const { client, clientSide } = pair; + await once(client, "secureConnect"); + const wire = await seal(pair, hold, payload); + + // What starts it ends with its first record of data (node's session tickets come first): node feeds a + // chunk to its engine in pieces, and a chunk pushed from a 'data' event between two of them overtakes the rest. + let first = 0; + for (let length = 0; length < 16384; first += length) length = 5 + wire.readUInt16BE(first + 3); + const pieces: Buffer[] = []; + for (let offset = first; offset < wire.length; offset += 7001) pieces.push(wire.subarray(offset, offset + 7001)); + + const received = receive(client, payload.length); + let next = 0; + // More than one pass of the engine decrypts, so the next event finds some of it still queued. + client.on("data", () => { + for (let pushed = 0; pushed < 20 && next < pieces.length; pushed++) clientSide.push(pieces[next++]); + }); + clientSide.push(wire.subarray(0, first)); + const plaintext = await received; + + expect({ length: plaintext.length, intact: plaintext.equals(payload) }).toEqual({ + length: payload.length, + intact: true, + }); + }); +}); diff --git a/test/js/node/tls/node-tls-namedpipes.test.ts b/test/js/node/tls/node-tls-namedpipes.test.ts index 14b61c5a34f2..689da3e185b5 100644 --- a/test/js/node/tls/node-tls-namedpipes.test.ts +++ b/test/js/node/tls/node-tls-namedpipes.test.ts @@ -58,6 +58,53 @@ it.if(isWindows)("should work with named pipes and tls", async () => { await expectMaxObjectTypeCount(expect, "TLSSocket", 3); }); +it.if(isWindows)("a write larger than 64 KiB round-trips over a TLS named pipe", async () => { + // The TLS engine over a pipe hands the pipe all the ciphertext of one write at once. + const payload = Buffer.alloc(1024 * 1024, Buffer.from(Array.from({ length: 251 }, (_, i) => i))); + // Resolves with the first `payload.length` bytes `socket` emits. + const receive = (socket: ReturnType) => { + const { promise, resolve, reject } = Promise.withResolvers(); + const chunks: Buffer[] = []; + let received = 0; + socket.on("data", (chunk: Buffer) => { + chunks.push(chunk); + received += chunk.length; + if (received >= payload.length) resolve(Buffer.concat(chunks)); + }); + socket.on("error", reject); + return promise; + }; + + const atServer = Promise.withResolvers(); + let client: ReturnType | null = null; + const server = createServer(tls, socket => { + receive(socket).then(all => { + socket.write(all); + atServer.resolve(all); + }, atServer.reject); + }); + server.on("tlsClientError", atServer.reject); + try { + const pipeName = `\\\\.\\pipe\\test\\${randomUUID()}`; + server.listen(pipeName); + await once(server, "listening"); + + const socket = connect({ path: pipeName, ca: tls.cert }); + client = socket; + const atClient = receive(socket); + socket.on("secureConnect", () => socket.write(payload)); + + const [serverGot, clientGot] = await Promise.all([atServer.promise, atClient]); + expect({ serverGotPayload: serverGot.equals(payload), clientGotPayload: clientGot.equals(payload) }).toEqual({ + serverGotPayload: true, + clientGotPayload: true, + }); + } finally { + client?.destroy(); + server.close(); + } +}); + describe.each(["TLSv1.2", "TLSv1.3"] as const)( "%s over a named pipe: write() issued before the handshake completes", version => { diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 964678e30c19..2b4098a7e29b 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -314,6 +314,42 @@ describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => { }); }); + test("a multi-megabyte message round-trips through the TLS tunnel", async () => { + // The tunnel's TLS engine hands all the ciphertext of one send() to the tunnel at once. + using recorded = await startRecordingProxy(); + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { + proxy: `http://127.0.0.1:${recorded.port}`, + tls: { rejectUnauthorized: false }, + }); + ws.binaryType = "arraybuffer"; + const payload = Buffer.alloc(4 * 1024 * 1024, Buffer.from(Array.from({ length: 251 }, (_, i) => i))); + const echo = Promise.withResolvers(); + const closed = Promise.withResolvers<{ code: number; wasClean: boolean }>(); + ws.addEventListener("open", () => ws.send(payload)); + ws.addEventListener("message", event => { + if (typeof event.data !== "string") echo.resolve(Buffer.from(event.data)); + }); + ws.addEventListener("close", event => { + echo.reject(new Error(`closed before the echo: ${event.code} ${event.reason}`)); + closed.resolve({ code: event.code, wasClean: event.wasClean }); + }); + + const echoedBack = await echo.promise; + ws.close(1000); + expect({ + length: echoedBack.length, + intact: echoedBack.equals(payload), + closed: await closed.promise, + requests: recorded.requests, + }).toEqual({ + length: payload.length, + intact: true, + closed: { code: 1000, wasClean: true }, + requests: [connectRequest(wssPort)], + }); + gc(); + }); + test("server-initiated ping survives through TLS tunnel proxy", async () => { // Regression test: sendPong checked socket.isClosed() on the detached tcp // field instead of using hasTCP(). For wss:// through HTTP proxy, the From 20698bfb2224ed347e2afbd593844c17cbfac2e7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:41:35 +0000 Subject: [PATCH 121/174] sql: read options.ssl once --- src/js/internal/sql/shared.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 5e02d51846be..a9a5cc8a97af 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -2168,8 +2168,9 @@ function parseOptions( } // `tls` is read before its alias; a mode the alias names still counts. - if (options.tls && typeof options.ssl === "string") { - const aliasMode = normalizeSSLMode(options.ssl); + const alias = options.ssl; + if (options.tls && typeof alias === "string") { + const aliasMode = normalizeSSLMode(alias); if (aliasMode > sslMode) sslMode = aliasMode; } From 44b34b4042beab7fdadcafd689f31e9ea2b50053 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:13:24 +0000 Subject: [PATCH 122/174] node:tls: a tls.Server has one SSL_CTX, which its listener serves too setSecureContext() builds the server's shared SecureContext, and listen() then built a second SSL_CTX from the raw key and certificate, as did every setSecureContext() on a listening server. A server parsed its material twice, kept two contexts, and the two had separate session caches and ticket keys, so a session issued on an accepted connection did not resume on one injected with emit('connection'), which it does on Node. listen() and setListenerSecureContext() now take the native SecureContext, the way connect() already did, and up_ref its SSL_CTX. That context is no longer interned: it is the server's alone, as the listener's was, so no other server and no client shares its session cache, its ticket keys or its verify mode. A SecureContext object passed to setSecureContext() is not handed to the listener: accepted sockets take their verify mode from the context, so the listener only gets one built from the server's own policy. listen()'s scopeguard becomes a named Drop type. --- src/js/node/tls.ts | 62 +++---- src/runtime/socket/Listener.rs | 135 ++++++++------- test/js/node/tls/node-tls-server.test.ts | 199 ++++++++++++++++++++--- test/js/node/tls/ssl-ctx-cache.test.ts | 67 ++------ 4 files changed, 298 insertions(+), 165 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index f130550aec05..38031ffedadc 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1050,32 +1050,30 @@ TLSSocket.prototype[buntls] = function (port, host) { let CLIENT_RENEG_LIMIT = 3, CLIENT_RENEG_WINDOW = 600; +// Not interned: one SSL_CTX is one session cache and one set of ticket keys, and no other server or client may share them. function buildSharedCreds(server, options) { - return new InternalSecureContext( - { - ...options, - pfx: undefined, - _pfxExtraCACerts: undefined, - key: server.key, - cert: server.cert, - ca: server.ca, - crl: server.crl, - ciphers: server.ciphers, - secureOptions: server.secureOptions, - allowPartialTrustChain: server.allowPartialTrustChain, - sessionTimeout: server.sessionTimeout, - sigalgs: server.sigalgs, - ecdhCurve: server.ecdhCurve ?? DEFAULT_ECDH_CURVE, - passphrase: server.passphrase, - secureProtocol: server.secureProtocol, - minVersion: server.minVersion, - maxVersion: server.maxVersion, - // Part of the interning key: one SSL_CTX is one session cache. - requestCert: server._requestCert === true, - rejectUnauthorized: server._rejectUnauthorized, - }, - true, - ); + return new InternalSecureContext({ + ...options, + pfx: undefined, + _pfxExtraCACerts: undefined, + key: server.key, + cert: server.cert, + ca: server.ca, + crl: server.crl, + ciphers: server.ciphers, + secureOptions: server.secureOptions, + allowPartialTrustChain: server.allowPartialTrustChain, + sessionTimeout: server.sessionTimeout, + sigalgs: server.sigalgs, + ecdhCurve: server.ecdhCurve ?? DEFAULT_ECDH_CURVE, + passphrase: server.passphrase, + secureProtocol: server.secureProtocol, + minVersion: server.minVersion, + maxVersion: server.maxVersion, + // The verify mode accepted sockets inherit. setSecureContext() does not name them. + requestCert: server._requestCert === true, + rejectUnauthorized: server._rejectUnauthorized, + }); } // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1599-L1611 @@ -1168,6 +1166,8 @@ function Server(options, secureConnectionListener): void { contexts.$set(hostname, context); }; + let listenerContext; + this.setSecureContext = function (options) { const serverTLSOptions = options; const next: Record = { __proto__: null }; @@ -1292,13 +1292,13 @@ function Server(options, secureConnectionListener): void { next.maxVersion = options.maxVersion; } const isContext = serverTLSOptions instanceof InternalSecureContext; - // [buntls] and buildSharedCreds read the staged fields over the server's own. - const staged = { __proto__: this, ...next }; - // Both throw on material BoringSSL rejects, so they run before the fields change. - const sharedCreds = isContext ? serverTLSOptions : buildSharedCreds(staged, serverTLSOptions); + // Reads the staged fields over the server's own. Throws on material BoringSSL rejects, so it runs before the fields change. + const sharedCreds = isContext ? serverTLSOptions : buildSharedCreds({ __proto__: this, ...next }, serverTLSOptions); + // Accepted sockets take their verify mode from the context, so a listener only gets one built from this server's policy. + if (!isContext) listenerContext = sharedCreds.context; const handle = this._handle; if (handle && options && !isContext) { - setListenerSecureContext(handle, staged[buntls](0, undefined, false)[0]); + setListenerSecureContext(handle, listenerContext); } if (options) { this.cert = next.cert; @@ -1325,6 +1325,8 @@ function Server(options, secureConnectionListener): void { return [ { serverName: sniName(this.servername || host || "localhost"), + // What a socket listener serves. Only a named pipe's still builds its own from the fields below. + secureContext: listenerContext, key: normalizePemKeyOption(this.key, this.passphrase), cert: this.cert, ca: this.ca, diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index f305a5fd582c..78e5807795b4 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -137,6 +137,53 @@ impl Listener { } } +/// The `SSL_CTX` of the native `SecureContext` node:tls passes as `tls.secureContext`. +fn prebuilt_ssl_ctx( + global: &JSGlobalObject, + opts: JSValue, +) -> JsResult> { + let Some(tls_js) = opts.get_truthy(global, "tls")? else { + return Ok(None); + }; + if !tls_js.is_object() { + return Ok(None); + } + let Some(sc_js) = tls_js.get_truthy(global, "secureContext")? else { + return Ok(None); + }; + Ok(sc_js + .as_class_ref::() + .map(|sc| sc.ctx.clone())) +} + +/// Tears down the half-built `Listener` of `listen()`, unless `disarm()` runs first. +struct HalfBuiltListener(Option<*mut Listener>); + +impl HalfBuiltListener { + fn disarm(mut self) -> *mut Listener { + self.0.take().expect("guard already disarmed") + } +} + +impl Drop for HalfBuiltListener { + fn drop(&mut self) { + let Some(this) = self.0.take() else { return }; + // SAFETY: this is still the sole owner on the error path; the + // fields below are `Cell`/`JsCell`, so a shared borrow suffices. + let this_ref = unsafe { &*this }; + this_ref.secure_ctx.set(None); + // protos: Box drops automatically when Listener is dropped below + bun_core::asan::unregister_root_region( + this_ref.group.as_ptr().cast::(), + size_of::(), + ); + // SAFETY: group was init'd above; not concurrently walked. + unsafe { uws::SocketGroup::destroy(this_ref.group.as_ptr()) }; + // SAFETY: reclaim the Box we leaked via into_raw + drop(unsafe { bun_core::heap::take(this) }); + } +} + #[derive(Clone)] pub(crate) enum UnixOrHost { Unix(Box<[u8]>), @@ -212,6 +259,11 @@ impl Listener { let ssl_enabled = socket_config.ssl.is_some(); let socket_flags = socket_config.socket_flags(); let pause_on_connect = socket_config.pause_on_connect; + let prebuilt_ctx = if ssl_enabled { + prebuilt_ssl_ctx(cx.global(), opts)? + } else { + None + }; #[cfg(windows)] if port.is_none() { @@ -407,25 +459,11 @@ impl Listener { this_ref.group.as_ptr().cast::(), size_of::(), ); - // Cleanup guard: on any early return below, tear down the half-built Listener. - // Disarmed via `into_inner` once ownership transfers to the JS wrapper. - let cleanup = scopeguard::guard(this, |this| { - // SAFETY: this is still the sole owner on the error path; the - // fields below are `Cell`/`JsCell`, so a shared borrow suffices. - let this_ref = unsafe { &*this }; - this_ref.secure_ctx.set(None); - // protos: Box drops automatically when Listener is dropped below - bun_core::asan::unregister_root_region( - this_ref.group.as_ptr().cast::(), - size_of::(), - ); - // SAFETY: group was init'd above; not concurrently walked. - unsafe { uws::SocketGroup::destroy(this_ref.group.as_ptr()) }; - // SAFETY: reclaim the Box we leaked via into_raw - drop(unsafe { bun_core::heap::take(this) }); - }); + let cleanup = HalfBuiltListener(Some(this)); - if let Some(ssl_cfg) = ssl_cfg_taken.as_ref() { + if prebuilt_ctx.is_some() { + this_ref.secure_ctx.set(prebuilt_ctx); + } else if let Some(ssl_cfg) = ssl_cfg_taken.as_ref() { let mut create_err = uws::create_bun_socket_error_t::none; let ctx_opts = ssl_cfg.as_usockets(); match ctx_opts.create_ssl_context_with_digest(&ctx_opts.digest(), &mut create_err) { @@ -588,7 +626,7 @@ impl Listener { } } - let this = scopeguard::ScopeGuard::into_inner(cleanup); // ownership transfers to JS wrapper + let this = cleanup.disarm(); // ownership transfers to JS wrapper // SAFETY: `global` is live; ownership of `this` (heap-allocated above) // transfers to the C++ wrapper (freed via `ListenerClass__finalize` → // `Listener::finalize` → `deinit`). @@ -800,27 +838,12 @@ impl Listener { /// `tls.Server#setSecureContext()` while listening. Accepted sockets keep their context. pub(crate) fn set_secure_context( this: &Self, - global: &JSGlobalObject, - tls: JSValue, - ) -> JsResult { + secure_context: &SecureContext, + ) -> JSValue { if !this.ssl { - return Ok(JSValue::UNDEFINED); + return JSValue::UNDEFINED; } - // SAFETY: per-thread VM; valid for program lifetime. - let vm = VirtualMachine::get().as_mut(); - let Some(ssl_config) = SSLConfig::from_js(vm, global, tls, true)? else { - return Ok(JSValue::UNDEFINED); - }; - let mut create_err = uws::create_bun_socket_error_t::none; - let Some(ctx) = ssl_config.as_usockets().create_ssl_context(&mut create_err) else { - return Err( - global.throw_value(crate::socket::uws_jsc::create_bun_socket_error_to_js( - create_err, global, - )), - ); - }; - - // `from_js` runs getters on `tls`, so the listener is read only now. + let ctx = secure_context.ctx.clone(); match this.listener.get() { ListenerType::Uws(ls) => { // S008: `ListenSocket` is an `opaque_ffi!` ZST — safe deref. @@ -834,7 +857,7 @@ impl Listener { } ListenerType::None => {} } - Ok(JSValue::UNDEFINED) + JSValue::UNDEFINED } #[bun_jsc::host_fn(method)] @@ -1167,24 +1190,11 @@ impl Listener { // Resolve the prebuilt SSL_CTX before the platform branches so the Windows // named-pipe path can adopt it. node:tls passes the native SecureContext as // `tls.secureContext` so we share its already-built SSL_CTX. - let mut owned_ssl_ctx: Option = None; - if ssl_enabled { - let native_sc: Option<&SecureContext> = 'blk: { - let Some(tls_js) = opts.get_truthy(cx.global(), "tls")? else { - break 'blk None; - }; - if !tls_js.is_object() { - break 'blk None; - } - let Some(sc_js) = tls_js.get_truthy(cx.global(), "secureContext")? else { - break 'blk None; - }; - sc_js.as_class_ref::() - }; - if let Some(sc) = native_sc { - owned_ssl_ctx = Some(sc.ctx.clone()); - } - } + let mut owned_ssl_ctx = if ssl_enabled { + prebuilt_ssl_ctx(cx.global(), opts)? + } else { + None + }; #[cfg(windows)] let mut connection = connection; @@ -1745,7 +1755,7 @@ pub(crate) fn js_set_secure_context( ) -> JsResult { jsc::mark_binding!(); - let [listener, tls] = frame.arguments_as_array::<2>(); + let [listener, secure_context] = frame.arguments_as_array::<2>(); if frame.arguments_count() < 2 { return Err(global.throw_not_enough_arguments( "setSecureContext", @@ -1754,9 +1764,12 @@ pub(crate) fn js_set_secure_context( )); } // A cluster worker's `_handle` is no `Listener`: JS wraps its connections. - match listener.as_class_ref::() { - Some(this) => Listener::set_secure_context(this, global, tls), - None => Ok(JSValue::UNDEFINED), + match ( + listener.as_class_ref::(), + secure_context.as_class_ref::(), + ) { + (Some(this), Some(sc)) => Ok(Listener::set_secure_context(this, sc)), + _ => Ok(JSValue::UNDEFINED), } } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 2f4bacf2db88..e491ab3d890d 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1805,35 +1805,47 @@ describe("setSecureContext() on a listening server", () => { const { sslCtxLiveCount } = require("bun:internal-for-testing"); const tls = require("node:tls"), { once } = require("node:events"); const [agent1, agent3] = ${JSON.stringify([agent1, agent3])}; - const server = tls.createServer(agent1); - // The shared context of a Server is interned and dies on GC. This one keeps agent3's - // referenced, so no call below leaves a context to the GC and every count is exact. - const holder = tls.createServer(agent3); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const listening = sslCtxLiveCount(); + const before = sslCtxLiveCount(); - for (let i = 0; i < 20; i++) server.setSecureContext(i % 2 ? agent1 : agent3); - let threw = false; - try { - server.setSecureContext({ key: agent3.key, cert: "-----BEGIN CERTIFICATE-----\\nnope\\n-----END CERTIFICATE-----" }); - } catch { - threw = true; + // The wrapper of a replaced context dies on GC, so wait for the condition. + async function settled(expected) { + for (let i = 0; i < 10 && sslCtxLiveCount() - before !== expected; i++) { + Bun.gc(true); + await new Promise(resolve => setImmediate(resolve)); + } + return sslCtxLiveCount() - before; } - // A leak adds one live SSL_CTX per call. - const swapped = sslCtxLiveCount() - listening; - server.close(); - await once(server, "close"); - // The listener lets go of the last one. - console.log(JSON.stringify({ threw, swapped, closed: sslCtxLiveCount() - listening })); - holder.close(); + // Nothing here outlives the call. + async function swap() { + const server = tls.createServer(agent1); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const listening = sslCtxLiveCount() - before; + + for (let i = 0; i < 20; i++) server.setSecureContext(i % 2 ? agent1 : agent3); + let threw = false; + try { + server.setSecureContext({ key: agent3.key, cert: "-----BEGIN CERTIFICATE-----\\nnope\\n-----END CERTIFICATE-----" }); + } catch { + threw = true; + } + // A reference the listener kept outlives the wrapper: one more live SSL_CTX per call. + const swapped = await settled(1); + server.close(); + await once(server, "close"); + return { threw, listening, swapped }; + } + + const result = await swap(); + console.log(JSON.stringify({ ...result, left: await settled(0) })); `; await using proc = Bun.spawn({ cmd: [bunExe(), "-e", script], env: bunEnv, stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect({ stderr, result: JSON.parse(stdout || "null") }).toEqual({ stderr: "", - result: { threw: true, swapped: 0, closed: -1 }, + // One context per server, whatever it went through: listen() builds none. + result: { threw: true, listening: 1, swapped: 1, left: 0 }, }); expect(exitCode).toBe(0); }); @@ -3955,9 +3967,9 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names after close() (%s)" expect({ stderr, result: JSON.parse(stdout || "null") }).toEqual({ stderr: "", result: { - // The 4 entries, the listener's default (each accepted SSL holds it), and what the Server the - // connections keep alive owns: _sharedCreds and the context the accepted TLSSockets share. - open: 7, + // The 4 entries, the server's own (_sharedCreds, which the listener served and each accepted + // SSL holds), and the one the accepted TLSSockets share. + open: 6, served: ["agent1", "agent1", "agent1", "agent1"], left: 0, }, @@ -4867,3 +4879,142 @@ describe("fatal TLS error after the handshake", () => { } }); }); + +// Node serves both from server._sharedCreds, so they share one session cache and one set of ticket keys. +describe.each(["TLSv1.2", "TLSv1.3"] as const)("accepted and injected connections share a context (%s)", version => { + it("a session issued on one resumes on the other", async () => { + const server: Server = createServer(COMMON_CERT, socket => socket.on("error", () => {}).end("x")); + const front = net.createServer(raw => server.emit("connection", raw)); + async function dial({ port }: AddressInfo, session?: Buffer) { + const options = { port, host: "127.0.0.1", rejectUnauthorized: false, minVersion: version, maxVersion: version }; + const client = connect({ ...options, session }); + let issued: Buffer | undefined; + client.on("session", s => (issued = s)); + client.resume(); + await once(client, "secureConnect"); + const reused = client.isSessionReused(); + await once(client, "close"); + return { reused, issued }; + } + try { + server.listen(0, "127.0.0.1"); + front.listen(0, "127.0.0.1"); + await Promise.all([once(server, "listening"), once(front, "listening")]); + const accepted = server.address() as AddressInfo; + const injected = front.address() as AddressInfo; + const [onAccepted, onInjected] = [await dial(accepted), await dial(injected)]; + expect({ + acceptedThenInjected: (await dial(injected, onAccepted.issued)).reused, + injectedThenAccepted: (await dial(accepted, onInjected.issued)).reused, + }).toEqual({ acceptedThenInjected: true, injectedThenAccepted: true }); + + // A replaced context takes its sessions with it, on both paths. + server.setSecureContext({ key: rawKey, cert }); + expect({ + accepted: (await dial(accepted, onAccepted.issued)).reused, + injected: (await dial(injected, onAccepted.issued)).reused, + }).toEqual({ accepted: false, injected: false }); + } finally { + front.close(); + server.close(); + } + }); + + it("a session issued by one server does not resume on another with the same options", async () => { + const serve = () => + createServer(COMMON_CERT, socket => socket.on("error", () => {}).end("x")).listen(0, "127.0.0.1"); + const [one, other] = [serve(), serve()]; + const inject = (server: Server) => net.createServer(raw => server.emit("connection", raw)).listen(0, "127.0.0.1"); + const [frontOne, front] = [inject(one), inject(other)]; + async function dial(server: net.Server, session?: Buffer) { + const { port } = server.address() as AddressInfo; + const options = { port, host: "127.0.0.1", rejectUnauthorized: false, minVersion: version, maxVersion: version }; + const client = connect({ ...options, session }); + let issued: Buffer | undefined; + client.on("session", s => (issued = s)); + client.resume(); + await once(client, "secureConnect"); + const reused = client.isSessionReused(); + await once(client, "close"); + return { reused, issued }; + } + try { + await Promise.all([one, other, frontOne, front].map(server => once(server, "listening"))); + const { issued } = await dial(one); + const { issued: issuedInjected } = await dial(frontOne); + expect({ + same: (await dial(one, issued)).reused, + otherAccepted: (await dial(other, issued)).reused, + otherInjected: (await dial(front, issued)).reused, + injectedToOtherAccepted: (await dial(other, issuedInjected)).reused, + injectedToOtherInjected: (await dial(front, issuedInjected)).reused, + }).toEqual({ + same: true, + otherAccepted: false, + otherInjected: false, + injectedToOtherAccepted: false, + injectedToOtherInjected: false, + }); + } finally { + for (const server of [one, other, frontOne, front]) server.close(); + } + }); + + // Bun only: Node's setSecureContext() takes options. Accepted sockets take their verify mode from the context. + it("the listener keeps asking for a client certificate after setSecureContext(aSecureContext)", async () => { + const material = { ...COMMON_CERT, ca: COMMON_CERT.cert }; + const server: Server = createServer({ ...material, requestCert: true, rejectUnauthorized: false }); + try { + server.setSecureContext(tls.createSecureContext(COMMON_CERT) as any); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const secure = once(server, "secureConnection"); + const client = connect({ + ...COMMON_CERT, + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + client.on("error", () => {}); + const [socket] = await secure; + expect(socket.getPeerCertificate().fingerprint256).toBe( + new crypto.X509Certificate(COMMON_CERT.cert).fingerprint256, + ); + client.destroy(); + } finally { + server.close(); + } + }); + + // A resumed handshake skips client authentication. + it("a server that requires a client certificate shares none with one that does not", async () => { + const material = { ...COMMON_CERT, ca: COMMON_CERT.cert }; + const open: Server = createServer(material, socket => socket.on("error", () => {}).end("x")); + const strict: Server = createServer({ ...material, requestCert: true, rejectUnauthorized: true }); + try { + // requestCert is constructor-only, so rotating the certificate does not name it. + strict.setSecureContext(material); + open.listen(0, "127.0.0.1"); + strict.listen(0, "127.0.0.1"); + await Promise.all([once(open, "listening"), once(strict, "listening")]); + const options = { host: "127.0.0.1", rejectUnauthorized: false, minVersion: version, maxVersion: version }; + const first = connect({ ...options, port: (open.address() as AddressInfo).port }); + const [session] = await once(first.resume(), "session"); + first.destroy(); + + const verdict = Promise.race([ + once(strict, "secureConnection").then(() => "accepted"), + once(strict, "tlsClientError").then(([err]) => err.code), + ]); + const second = connect({ ...options, port: (strict.address() as AddressInfo).port, session }); + second.on("error", () => {}); + expect(await verdict).toBe("ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE"); + second.destroy(); + } finally { + open.close(); + strict.close(); + } + }); +}); diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index cb39d7eb4234..05e32e48d3f0 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -461,68 +461,35 @@ test("setDefaultCACertificates() applies to a server's client-cert verification } }); -test("a server's shared SSL_CTX is interned by its client-certificate policy too", () => { - // The shared context carries the client-certificate policy in its verify - // mode, and one SSL_CTX is one session cache. Two servers that share key - // material but not that policy must get separate contexts: otherwise a - // session minted by the permissive server resumes on the mTLS server, and a - // resumed handshake skips client authentication. - const permissive = tls.createServer({ ...tlsCerts }); - const mutual = tls.createServer({ ...tlsCerts, requestCert: true }); - const optionalCert = tls.createServer({ ...tlsCerts, requestCert: true, rejectUnauthorized: false }); - // The same options still intern to one context, so the split above is the - // policy and not a broken digest. - const sameAsPermissive = tls.createServer({ ...tlsCerts }); - const ctx = (s: tls.Server) => (s as any)._sharedCreds.context; - try { - expect(ctx(permissive)).not.toBe(ctx(mutual)); - expect(ctx(permissive)).not.toBe(ctx(optionalCert)); - expect(ctx(mutual)).not.toBe(ctx(optionalCert)); - expect(ctx(sameAsPermissive)).toBe(ctx(permissive)); - // requestCert is constructor-only, so a later call does not name it. - mutual.setSecureContext({ ...tlsCerts }); - expect(ctx(mutual)).not.toBe(ctx(permissive)); - } finally { - for (const s of [permissive, mutual, optionalCert, sameAsPermissive]) s.close(); - } -}); - -// `tls.Server.close()` must release the listener's SSL_CTX ref immediately. -// It used to be dropped only when the GC finalized the Listener, so a `Server` -// the program still references — or any server at process exit — kept its CTX +// stop(), which `tls.Server.close()` calls, must release the listener's SSL_CTX ref immediately. +// It used to be dropped only when the GC finalized the Listener, so a listener +// the program still references — or any at process exit — kept its CTX // alive. That is the leak `leak:create_ssl_context_from_bun_options` used to // suppress. The listen socket up_refs its own ref in // `us_internal_init_listen_socket` and each accepted socket's `SSL_new()` takes -// another, so releasing at close() cannot dangle. -test("tls.Server.close() releases the listener's SSL_CTX without waiting for GC", async () => { - // Hold strong references so the GC can never finalize these Listeners; a - // distinct `sessionTimeout` per server gives each its own cache entry. - // tls.createServer() builds the server's shared SecureContext up front, and - // that one lives as long as the Server object (like Node's `_sharedCreds`), - // so the baseline is taken with the servers constructed: the listener's own - // SSL_CTX is the one close() must release. - const kept: tls.Server[] = []; - for (let i = 0; i < 5; i++) { - kept.push(tls.createServer({ ...tlsCerts, sessionTimeout: 100 + i })); - } +// another, so releasing at stop() cannot dangle. +test("Listener.stop() releases its SSL_CTX without waiting for GC", () => { + // A tls.Server's listener serves the Server's own context, which outlives close(). One from + // Bun.listen() is the only owner of the context it builds, so the count shows its reference. Bun.gc(true); const before = sslCtxLiveCount(); + // Hold strong references so the GC can never finalize these Listeners. + const kept: Bun.TCPSocketListener[] = []; let peak = 0; - for (const server of kept) { - server.listen(0, "127.0.0.1"); - await once(server, "listening"); + for (let i = 0; i < 5; i++) { + const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, tls: tlsCerts, socket: { data() {} } }); + kept.push(listener); peak = Math.max(peak, sslCtxLiveCount() - before); - server.close(); - await once(server, "close"); + listener.stop(); } - // No Bun.gc() here on purpose: the point is that close() alone frees them. - // `peak: 1` proves each listen() did hold a context of its own meanwhile. - expect({ leaked: sslCtxLiveCount() - before, peak, servers: kept.length }).toEqual({ + // No Bun.gc() here on purpose: the point is that stop() alone frees them. + // `peak: 1` proves each listener did hold a context of its own meanwhile. + expect({ leaked: sslCtxLiveCount() - before, peak, listeners: kept.length }).toEqual({ leaked: 0, peak: 1, - servers: 5, + listeners: 5, }); }); From d72f397061056c8c9ac5e856a40014318a7a773f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 04:18:25 +0000 Subject: [PATCH 123/174] node:tls: a read error under the stream-level engine is the TLS socket's to report A TLS socket that runs over a connected net.Socket without taking its fd (TLS over TLS, a socket with writes still queued, a named pipe) had no link to that socket's errors. A peer reset reached it as a clean close, where node emits 'error' ECONNRESET and 'close' true, and raw.destroy(err) was uncaught when only the TLS socket listened. The forward was left off there because a listener on a connected socket makes its close report the reset on that socket. linkUpgraded() now installs the forward for every shape, in that one place. A socket under the stream-level engine hands a read error to the TLS socket in place of reporting it, as TLSWrap owns the reads of the handle it wraps: the TLS socket is destroyed with it and the wrapped socket only closes. For the same reason a TLS socket that is itself wrapped leaves an EOF during its handshake to the one above it. As for a socket with its own fd, a reset is reported to a TLS socket that listens. A plain destroy() of the wrapped socket during the handshake was reported as ECONNRESET on both engines, uncaught with no listener, where node just closes. The stream-level engine took the 'end' a net.Socket emits after destroy() for the peer's EOF, and the fd engine took the close of the fd for a disconnect. --- src/js/node/net.ts | 28 +++- .../tls/node-tls-wrapped-socket-close.test.ts | 149 ++++++++++++++++++ 2 files changed, 169 insertions(+), 8 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 3a4501d211a1..f4d63d7a9e49 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -310,6 +310,8 @@ const kupgraded = Symbol("kupgraded"); const kAdoptedTLSRaw = Symbol("kAdoptedTLSRaw"); // On that TLS socket: the fd closed, and the socket it wraps waits to be closed with it. const kOwesRawClose = Symbol("kOwesRawClose"); +// Emitted on a socket under the stream-level TLS engine in place of reporting a read error itself. +const kReadError = Symbol("kReadError"); const ksocket = Symbol("ksocket"); const khandlers = Symbol("khandlers"); const kclosed = Symbol("closed"); @@ -446,8 +448,11 @@ function onUpgradedError(self, err) { self._hadError = true; self._emitTLSError(err); } -function forwardUpgradedError(self, connection) { - connection.on("error", onUpgradedError.bind(null, self)); +// TLSWrap owns the reads of the handle it wraps: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 +function onUpgradedReadError(self, err) { + self._closeAfterHandlingError = true; + SocketEmitEndNT(self, err); + self.destroy(); } // Whatever state the wrapped stream is in: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 function linkUpgraded(self, connection: SocketInstance) { @@ -455,9 +460,8 @@ function linkUpgraded(self, connection: SocketInstance) { self[kupgraded] = connection; self._parent = isSocket ? connection : null; destroyWhenUpgradedCloses(self, connection); + connection.on("error", onUpgradedError.bind(null, self)); self.connecting = isSocket && (connection.connecting || !connection._handle); - // A listener on a connected net.Socket makes its close synthesize ECONNRESET: that one is listened to once its fd is taken. - if (self.connecting || !isSocket) forwardUpgradedError(self, connection); } // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L964-L973 function onUpgradedConnect(self, connection) { @@ -465,11 +469,16 @@ function onUpgradedConnect(self, connection) { self.connecting = false; self.emit("connect"); } +// A net.Socket emits 'end' after its owner's destroy(), which is no EOF from the peer. +function onUpgradedEnd(connection, eof) { + if (!connection.destroyed) eof(); +} function attachTLSEngine(self, connection, options) { const [handle, events] = upgradeDuplexToTLS(connection, options); connection.on("data", events[0]); - connection.on("end", events[1]); + connection.on("end", onUpgradedEnd.bind(null, connection, events[1])); connection.on("close", events[2]); + connection.once(kReadError, onUpgradedReadError.bind(null, self)); // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L120 connection.resume(); self._handle = handle; @@ -491,7 +500,6 @@ function attachClientTLS(self, connection, tls, upgradeDuplex) { attachTLSEngine(self, connection, options); } else { adoptTLSPair(self, connection, upgradeTLSDeferred(handle, options)); - if (!self.connecting) forwardUpgradedError(self, connection); } } catch (error) { self.destroy(error); @@ -704,7 +712,8 @@ function finishStandaloneWrap(self, verifyError) { self.emit("secure", self); } function onConnectEnd() { - if (!this._hadError && this.secureConnecting) { + // Under another TLS socket the EOF is that one's to report. + if (!this._hadError && this.secureConnecting && this.listenerCount(kReadError) === 0) { const options = this[kConnectOptions]; this._hadError = true; const error: InstanceType & { @@ -968,6 +977,7 @@ function SocketEmitEndNT(self, _err?) { // (native on_error / a fatal write); node emits a socket error exactly // once, so the close that follows it is delivered plain. if (_err && !self.destroyed && !self._hadError && !teardownNoise && self.listenerCount("error") > 0) { + if (self.emit(kReadError, _err)) return; // The consumer can detach its 'error' listener between this close // callback and destroy()'s deferred 'error' emission (a request that // finished just as the reset arrived); a last-resort no-op listener keeps @@ -1676,6 +1686,7 @@ const SocketHandlers2 = { // upgrade also report errors on close, and those must keep ending // cleanly. if (!leftToTLSSocket && err && !self.destroyed && socket === self._handle && self.listenerCount("error") > 0) { + if (self.emit(kReadError, err)) return; // Same late-detach guard as SocketEmitEndNT: the listener seen at // close-time can be gone by the deferred 'error' emission. self.once("error", () => {}); @@ -2459,6 +2470,8 @@ Socket.prototype._destroy = function _destroy(err, callback) { // Shared-fd TLS pair: defer the close two check-phase turns // (test-tls-socket-close); see closeAdoptedTLSRawNT. currentHandle.pause?.(); + // The TLS socket closes with the socket it wraps, and that close is nothing to report. + currentHandle[kAdoptedTLSRaw]._hadError = true; setImmediate(closeAdoptedTLSRawNT, this, currentHandle, isException); } else { closeSocketHandle(this, currentHandle, isException); @@ -2613,7 +2626,6 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, } else if (connection.connecting) { connection.once("connect", process.nextTick.bind(null, adoptServerTLS, this, connection, options)); } else { - forwardUpgradedError(this, connection); process.nextTick(adoptServerTLS, this, connection, options); } if (this.connecting) connection.once("connect", onUpgradedConnect.bind(null, this, connection)); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index 99ecff77793f..51a567f962db 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -799,6 +799,155 @@ describe("an error of the wrapped socket is reported on the TLS socket", () => { server.close(); } }); + + // Node has one engine. Bun adopts the fd of "a connected socket" and runs TLS over the stream for the other two. + const transports = ["a connected socket", "a socket with a write still queued", "a TLS socket"]; + + // Makes the TLS socket under test on one side of a connection that runs through a plain TCP relay. + // `act` gets it, the socket it wraps, and the relay's end of that socket's TCP connection. + async function behindRelay( + transport: string, + side: string, + when: string, + act: (raw: net.Socket, peer: net.Socket) => void, + withListeners = true, + ) { + const sockets: net.Socket[] = []; + const quiet = (socket: T) => (sockets.push(socket), socket.on("error", () => {})); + const result = Promise.withResolvers<{ added: number; events: string[] }>(); + const queued = transport === "a socket with a write still queued"; + const relayEnds: Record = {}; + + function wrap(isServer: boolean, raw: net.Socket) { + return isServer + ? new tls.TLSSocket(raw, { isServer, key, cert }) + : tls.connect({ socket: raw, rejectUnauthorized: false }); + } + function underTest(raw: net.Socket) { + sockets.push(raw); + const before = raw.listenerCount("error"); + if (queued) (raw.cork(), raw.write("pre")); + const tlsSocket = wrap(side === "server", raw); + if (queued) raw.uncork(); + sockets.push(tlsSocket); + const run = () => { + const added = raw.listenerCount("error") - before; + const events: string[] = []; + const closed = [raw, tlsSocket].map((socket, i) => { + const name = i ? "tls" : "raw"; + if (i) socket.on("_tlsError", (err: NodeJS.ErrnoException) => events.push(`tls _tlsError ${err.code}`)); + if (withListeners) + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`${name} error ${err.code}`)); + return new Promise(resolve => + socket.on("close", hadError => (events.push(`${name} close hadError=${hadError}`), resolve())), + ); + }); + tlsSocket.resume(); + act(raw, relayEnds[side]); + result.resolve(Promise.all(closed).then(() => ({ added, events }))); + }; + const soon = () => void setImmediate(run); + if (when === "after the handshake") tlsSocket.once(side === "server" ? "secure" : "secureConnect", soon); + else soon(); + } + function farEnd(raw: net.Socket) { + quiet(raw); + const start = () => (when === "after the handshake" ? quiet(wrap(side !== "server", raw)) : raw).resume(); + if (queued) raw.once("readable", () => (raw.read(3), start())); + else start(); + } + + const onServerEnd = side === "server" ? underTest : farEnd; + const onClientEnd = side === "server" ? farEnd : underTest; + const overTLS = transport === "a TLS socket"; + const server = overTLS ? tls.createServer({ key, cert }, onServerEnd) : net.createServer(onServerEnd); + const port = await listen(server); + const relay = net.createServer({ allowHalfOpen: true }, client => { + relayEnds.client = quiet(client); + relayEnds.server = quiet(net.connect({ port, host: "127.0.0.1", allowHalfOpen: true })); + client.pipe(relayEnds.server).pipe(client); + }); + const dial = { port: await listen(relay), host: "127.0.0.1", rejectUnauthorized: false }; + const dialed: net.Socket = overTLS ? tls.connect(dial) : net.connect(dial); + dialed.once(overTLS ? "secureConnect" : "connect", () => onClientEnd(dialed)); + try { + return await result.promise; + } finally { + for (const socket of sockets) socket.destroy(); + relay.close(); + server.close(); + } + } + + for (const transport of transports) { + for (const side of ["client", "server"]) { + for (const when of ["before the handshake completes", "after the handshake"]) { + const early = when !== "after the handshake"; + // destroy(err) of the TLS socket itself: a server-side wrap still has its own first 'error' listener. + const destroyedWith = (code: string) => [ + ...(side === "server" ? [`tls _tlsError ${code}`] : []), + `tls error ${code}`, + "raw close hadError=false", + "tls close hadError=true", + ]; + const forwarded = (code: string) => [ + `tls _tlsError ${code}`, + ...(side === "client" ? [`tls error ${code}`] : []), + `raw error ${code}`, + ]; + const rows: [string, (raw: net.Socket, peer: net.Socket) => void, string[], boolean?][] = [ + // TLSWrap owns the reads: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L723-L727 + ["the peer resets the connection", (_raw, peer) => peer.resetAndDestroy(), destroyedWith("ECONNRESET")], + [ + "the peer sends a FIN", + (_raw, peer) => peer.end(), + side === "client" && early + ? destroyedWith("ECONNRESET") + : ["raw close hadError=false", "tls close hadError=false"], + // Bun's server-side wrap of an adopted fd reports a FIN ahead of the handshake as ECONNRESET. + typeof Bun !== "undefined" && transport === transports[0] && side === "server" && early, + ], + [ + "destroy(err) of the wrapped socket", + raw => raw.destroy(failure()), + [...forwarded("TRANSPORT_FAILED"), "tls close hadError=false", "raw close hadError=true"], + ], + [ + "destroy() of the wrapped socket", + raw => raw.destroy(), + ["tls close hadError=false", "raw close hadError=false"], + ], + ]; + for (const [what, act, expected, skip] of rows) { + const wrap = side === "server" ? "new TLSSocket(socket, { isServer })" : "tls.connect({ socket })"; + test(`${wrap} over ${transport}, ${when}: ${what}`, { skip }, async () => { + const { added, events } = await behindRelay(transport, side, when, act); + // When its owner destroys the wrapped socket, node closes the TLS socket first and Bun the wrapped one. + const closes = what.startsWith("destroy") ? -2 : events.length; + assert.deepStrictEqual( + { added, events: [...events.slice(0, closes), ...events.slice(closes).sort().reverse()] }, + { added: 1, events: expected }, + ); + }); + } + } + } + + // Bun only: node throws it. A reset is reported to a socket that listens. + test( + `tls.connect({ socket }) over ${transport} with no 'error' listener: the peer resets the connection`, + { + skip: typeof Bun === "undefined", + }, + async () => { + const reset = (_raw: net.Socket, peer: net.Socket) => peer.resetAndDestroy(); + assert.deepStrictEqual(await behindRelay(transport, "client", "after the handshake", reset, false), { + added: 1, + events: ["raw close hadError=false", "tls close hadError=false"], + }); + }, + ); + } }); test("a secureContext that is not one is refused by the constructor", () => { From 1adb7fcae09d008c1ff4386d9302f7db94258c08 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:35:03 +0000 Subject: [PATCH 124/174] ws, node-fetch: a lone pfx sends its intermediates (#42325) node:tls completes the chain of a lone identity from the CAs it adds to the context afterwards. The native tls option has no such step, so only the leaf went out and a server that knows the root alone refused the client. The archive's extra certificates now follow the leaf, as they do for several identities and in Node. --- src/js/internal/tls.ts | 7 +++++-- test/js/first_party/ws/ws.test.ts | 11 +++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/js/internal/tls.ts b/src/js/internal/tls.ts index 69acb1f19c61..924c7ac3577c 100644 --- a/src/js/internal/tls.ts +++ b/src/js/internal/tls.ts @@ -365,8 +365,11 @@ function stripTls13CipherNames(ciphers: string): { cipherList: string; tls13Only function unsealPfxForNative(tls) { if (tls.pfx == null) return tls; tls = processPfxOptions(tls); - const { ca, _pfxExtraCACerts: pfxCAs } = tls; - if (pfxCAs && ca) tls.ca = $isArray(ca) ? [...ca, ...pfxCAs] : [ca, ...pfxCAs]; + const { ca, cert, _pfxExtraCACerts: pfxCAs } = tls; + if (!pfxCAs) return tls; + if (ca) tls.ca = $isArray(ca) ? [...ca, ...pfxCAs] : [ca, ...pfxCAs]; + // No addCACert() follows to complete the chain of a lone identity. Several carry theirs already. + if (typeof cert === "string") tls.cert = cert + pfxCAs[0]; return tls; } diff --git a/test/js/first_party/ws/ws.test.ts b/test/js/first_party/ws/ws.test.ts index a3febe24b1d9..94c02f249202 100644 --- a/test/js/first_party/ws/ws.test.ts +++ b/test/js/first_party/ws/ws.test.ts @@ -2027,6 +2027,17 @@ describe("client TLS options", () => { expect(await dial(server.url, { pfx: pfxWithServerCa, passphrase: "sample" })).toBe("refused"); }); + it("sends the intermediates of the archive with the client certificate", async () => { + // agent10 <- ca4 <- ca2. The archive has ca4, and the server knows ca2 alone. + using server = await serve({ ca: read("ca2-cert.pem") }); + const identity = { pfx: read("agent10.pfx"), passphrase: "sample", rejectUnauthorized: false }; + expect({ + topLevel: await dial(server.url, identity), + agent: await dial(server.url, { agent: new https.Agent(identity) }), + withCa: await dial(server.url, { ...identity, ca }), + }).toEqual({ topLevel: identified, agent: identified, withCa: identified }); + }); + // `openssl x509 -subject_hash` of the server's certificate. const hashedName = "c62891c1.0"; it.each([ From 8d4040fa8fedefdcdff055f45851e48983d4ac53 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:35:04 +0000 Subject: [PATCH 125/174] usockets(tls): say what ssl_batch_spills bounds now that a handshake flight is always held --- packages/bun-usockets/src/crypto/openssl.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 57654ecbc537..819fdad93b83 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -102,8 +102,8 @@ struct loop_ssl_data { struct us_socket_t *ssl_write_batch_owner; /* Connections whose spill (us_ssl_spill_t) holds the rest of a batch flush. While there is - * one, nobody batches and a spill takes at most the rest of one record: userspace holds - * one flush unit per loop plus one record per other stalled connection. */ + * one, no application write batches, so its spill takes at most the rest of one record. A + * handshake flight is held whatever this says, and counts here if the kernel refuses it. */ unsigned int ssl_batch_spills; /* Why ssl_raw_write ended a write side inside the running us_internal_ssl_writev or spill drain. */ From db75896deefe118afaf0f38bb892e1a89f89a1ce Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:35:05 +0000 Subject: [PATCH 126/174] sql: read options.tls and options.ssl once each --- src/js/internal/sql/shared.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index a9a5cc8a97af..6496c30249b3 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -2060,11 +2060,12 @@ function parseOptions( } } - const tlsOption = options.tls || options.ssl; + const { tls: optionsTls, ssl: optionsSsl } = options; + const tlsOption = optionsTls || optionsSsl; if (typeof tlsOption === "string" && tlsOption) { sslMode = normalizeSSLMode(tlsOption); tls = undefined; - } else if (!tlsOption && (options.tls === false || options.ssl === false)) { + } else if (!tlsOption && (optionsTls === false || optionsSsl === false)) { sslMode = SSLMode.disable; tls = undefined; } else if ($inheritsBlob(tlsOption)) { @@ -2168,9 +2169,8 @@ function parseOptions( } // `tls` is read before its alias; a mode the alias names still counts. - const alias = options.ssl; - if (options.tls && typeof alias === "string") { - const aliasMode = normalizeSSLMode(alias); + if (optionsTls && typeof optionsSsl === "string") { + const aliasMode = normalizeSSLMode(optionsSsl); if (aliasMode > sslMode) sslMode = aliasMode; } From 7cdcefa7f7e212bc0f5e4332d329434752ec25c9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:37:54 +0000 Subject: [PATCH 127/174] websocket: the TLS inside a proxy tunnel follows tls.DEFAULT_CIPHERS (#41810) Without a tls option, start_proxy_tls_handshake built the connection to the origin from SSLConfig::default(), so new WebSocket("wss://...", { proxy }) negotiated a suite that the assigned list excludes, while the same URL without a proxy, or with any tls option, followed it. bun_http_jsc sits below bun_runtime and cannot call tls_true_defaults, so what an assigned list does to a config moves to SSLConfig::set_default_ciphers in bun_http, and both callers use it. One Option check while nothing is assigned. The multi-client test moves to a fixture and gains fetch and WebSocket through an http and an https proxy, and Bun.SQL postgres and mysql. --- src/http/ssl_config.rs | 12 ++ .../WebSocketUpgradeClient.rs | 4 + src/runtime/socket/SSLConfig.rs | 12 +- test/js/node/tls/node-tls-server.test.ts | 60 ++------ .../node/tls/tls-default-ciphers-fixture.ts | 137 ++++++++++++++++++ 5 files changed, 167 insertions(+), 58 deletions(-) create mode 100644 test/js/node/tls/tls-default-ciphers-fixture.ts diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index c6bea951364a..0a85d66ecddc 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -128,6 +128,18 @@ impl SSLConfig { Self::default() } + /// What was assigned to `tls.DEFAULT_CIPHERS`, for a config without `ciphers`. It then needs a context of its own, like one with `ciphers`. + pub fn set_default_ciphers(&mut self, ciphers: &[u8]) { + self.ssl_ciphers = bun_core::dupe_z(ciphers); + self.requires_custom_request_ctx = true; + // An empty TLS 1.2 list would leave BoringSSL's built-in one in effect. + if ciphers.is_empty() { + self.ssl_min_version = self + .ssl_min_version + .max(i32::from(bun_boringssl_sys::TLS1_3_VERSION)); + } + } + /// Borrow `server_name` as a `&CStr` (None if null). Convenience accessor /// for callers that previously pattern-matched `Option`. #[inline] diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index e55e5a1abc53..1af216490e37 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -983,6 +983,10 @@ where let mut c = SSLConfig::default(); c.reject_unauthorized = 0; // We verify manually c.request_cert = 1; + let vm = bun_jsc::virtual_machine::VirtualMachine::get(); + if let Some(ciphers) = vm.tls_default_ciphers() { + c.set_default_ciphers(ciphers); + } c } }; diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 26815d6748d5..b02d24ccb7aa 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -295,16 +295,8 @@ pub(crate) fn tls_true_defaults(vm: &VirtualMachine, is_server: bool) -> SSLConf /// No `ciphers` option means `tls.DEFAULT_CIPHERS`, as in Node's `configSecureContext`. fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { - let Some(ciphers) = vm.tls_default_ciphers() else { - return; - }; - cfg.ssl_ciphers = dupe_z(ciphers); - cfg.requires_custom_request_ctx = true; - // An empty TLS 1.2 list would leave BoringSSL's built-in one in effect. - if ciphers.is_empty() { - cfg.ssl_min_version = cfg - .ssl_min_version - .max(i32::from(bun_boringssl_sys::TLS1_3_VERSION)); + if let Some(ciphers) = vm.tls_default_ciphers() { + cfg.set_default_ciphers(ciphers); } } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index e491ab3d890d..e04d8b6c80ff 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4183,53 +4183,8 @@ it("tls.DEFAULT_CIPHERS applies to every context built without a ciphers option" it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", async () => { using dir = tempDir("tls-default-ciphers", { "ca.pem": cert1.cert }); - const script = ` - import tls from "node:tls"; - import { once } from "node:events"; - import { Worker } from "node:worker_threads"; - const cert = ${JSON.stringify({ key: cert1.key, cert: cert1.cert })}; - const AES128 = "ECDHE-RSA-AES128-GCM-SHA256", AES256 = "ECDHE-RSA-AES256-GCM-SHA384"; - - // Prefers AES128, as every client does until it is told otherwise. - let seen; - const server = tls.createServer({ ...cert, maxVersion: "TLSv1.2", ciphers: AES128 + ":" + AES256 }, socket => { - seen.resolve(socket.getCipher().name); - socket.on("error", () => {}); - socket.once("data", () => socket.end("HTTP/1.1 200 OK\\r\\nContent-Length: 0\\r\\nConnection: close\\r\\n\\r\\n")); - }); - server.on("tlsClientError", error => seen.resolve(error.code)); - await once(server.listen(0, "127.0.0.1"), "listening"); - const { port } = server.address(); - const url = "https://localhost:" + port + "/"; - - const clients = { - "fetch": () => fetch(url, { keepalive: false }), - "fetch, tls: {}": () => fetch(url, { keepalive: false, tls: {} }), - "fetch, rejectUnauthorized": () => fetch(url, { keepalive: false, tls: { rejectUnauthorized: false } }), - "fetch, ca": () => fetch(url, { keepalive: false, tls: { ca: cert.cert } }), - "WebSocket": () => void new WebSocket(url.replace("https", "wss")), - "WebSocket, rejectUnauthorized": () => void new WebSocket(url.replace("https", "wss"), { tls: { rejectUnauthorized: false } }), - "RedisClient, tls: true": () => new Bun.RedisClient("rediss://localhost:" + port, { tls: true, autoReconnect: false }).connect(), - "Bun.connect, tls: true": () => Bun.connect({ hostname: "localhost", port, tls: true, socket: { data() {}, error() {} } }), - }; - const results = {}; - for (const list of [undefined, AES256, AES128, "TLS_AES_128_GCM_SHA256"]) { - if (list) tls.DEFAULT_CIPHERS = list; - for (const [name, connect] of Object.entries(clients)) { - seen = Promise.withResolvers(); - Promise.resolve(connect()).catch(() => {}); - (results[name] ??= []).push(await seen.promise); - } - } - // The list is the thread's, as in Node.js. - seen = Promise.withResolvers(); - new Worker("new WebSocket(" + JSON.stringify(url.replace("https", "wss")) + ")", { eval: true }); - results.worker = await seen.promise; - console.log(JSON.stringify(results)); - process.exit(0); - `; await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", script], + cmd: [bunExe(), join(import.meta.dir, "tls-default-ciphers-fixture.ts")], env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(String(dir), "ca.pem") }, stdout: "pipe", stderr: "pipe", @@ -4237,17 +4192,26 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); const AES128 = "ECDHE-RSA-AES128-GCM-SHA256"; - // Nothing assigned, a TLS 1.2 suite, another, TLS 1.3 suites only (which this TLS 1.2 server cannot serve). - const row = [AES128, "ECDHE-RSA-AES256-GCM-SHA384", AES128, "ERR_SSL_UNSUPPORTED_PROTOCOL"]; + const AES256 = "ECDHE-RSA-AES256-GCM-SHA384"; + // Nothing assigned, a TLS 1.2 suite, another, TLS 1.3 suites only (which these TLS 1.2 servers cannot serve). + const row = [AES128, AES256, AES128, "ERR_SSL_UNSUPPORTED_PROTOCOL"]; + // The connection to the proxy, then the one to the origin through it. + const both = [`${AES128}, ${AES128}`, `${AES256}, ${AES256}`, `${AES128}, ${AES128}`, "ERR_SSL_UNSUPPORTED_PROTOCOL"]; expect(JSON.parse(stdout)).toEqual({ "fetch": row, "fetch, tls: {}": row, "fetch, rejectUnauthorized": row, "fetch, ca": row, + "fetch, http proxy": row, + "fetch, https proxy": both, "WebSocket": row, "WebSocket, rejectUnauthorized": row, + "WebSocket, http proxy": row, + "WebSocket, https proxy": both, "RedisClient, tls: true": row, "Bun.connect, tls: true": row, + "Bun.SQL postgres, tls: true": row, + "Bun.SQL mysql, tls: true": row, worker: AES128, }); expect(exitCode).toBe(0); diff --git a/test/js/node/tls/tls-default-ciphers-fixture.ts b/test/js/node/tls/tls-default-ciphers-fixture.ts new file mode 100644 index 000000000000..1f6b70c02ca3 --- /dev/null +++ b/test/js/node/tls/tls-default-ciphers-fixture.ts @@ -0,0 +1,137 @@ +// Prints the TLS 1.2 suite every kind of client negotiates: with nothing assigned to tls.DEFAULT_CIPHERS, then after +// each assignment. NODE_EXTRA_CA_CERTS holds the certificate of the servers. +import { tls as cert } from "harness"; +import { once } from "node:events"; +import net from "node:net"; +import tls from "node:tls"; +import { Worker } from "node:worker_threads"; +import { MYSQL_CLIENT_SSL, MYSQL_DEFAULT_CAPABILITIES, mysqlHandshakeV10, pgSSLResponse } from "../../sql/wire-frames"; + +const AES128 = "ECDHE-RSA-AES128-GCM-SHA256"; +const AES256 = "ECDHE-RSA-AES256-GCM-SHA384"; +// Prefers AES128, as every client does until it is told otherwise. +const serverOptions = { + key: cert.key, + cert: cert.cert, + maxVersion: "TLSv1.2", + ciphers: `${AES128}:${AES256}`, +} as const; + +/** The suite of each handshake of the client under test, or the error that ended one. */ +let reports: string[] = []; +let reported = () => {}; +function report(outcome: string) { + reports.push(outcome); + reported(); +} +async function listen(server: net.Server) { + await once(server.listen(0, "127.0.0.1"), "listening"); + return (server.address() as net.AddressInfo).port; +} +function tlsServer(onSecure: (socket: tls.TLSSocket) => void) { + const server = tls.createServer(serverOptions, socket => { + report(socket.getCipher().name); + socket.on("error", () => {}); + onSecure(socket); + }); + server.on("tlsClientError", error => report((error as NodeJS.ErrnoException).code!)); + return listen(server); +} + +const ok = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; +const origin = await tlsServer(socket => socket.once("data", () => socket.end(ok))); + +function tunnel(socket: net.Socket) { + socket.on("error", () => {}); + socket.once("data", () => { + const upstream = net.connect(origin, "127.0.0.1", () => { + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + socket.pipe(upstream).pipe(socket); + }); + upstream.on("error", () => socket.destroy()); + }); +} +const httpProxy = `http://localhost:${await listen(net.createServer(tunnel))}`; +const httpsProxy = `https://localhost:${await tlsServer(tunnel)}`; + +/** The TLS half of a database server, once its plaintext prelude is done. */ +function upgrade(raw: net.Socket, leftover: Buffer) { + raw.pause(); + if (leftover.length) raw.unshift(leftover); + const socket = new tls.TLSSocket(raw, { isServer: true, ...serverOptions }); + socket.on("secure", () => (report(socket.getCipher().name), socket.destroy())); + socket.on("error", error => report((error as NodeJS.ErrnoException).code!)); +} +const postgres = await listen( + net.createServer(raw => { + raw.on("error", () => {}); + raw.once("data", chunk => { + raw.write(pgSSLResponse("S")); + upgrade(raw, chunk.subarray(8)); + }); + }), +); +const mysql = await listen( + net.createServer(raw => { + raw.on("error", () => {}); + raw.write(mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_SSL })); + let buffered = Buffer.alloc(0); + raw.on("data", function onData(chunk) { + buffered = Buffer.concat([buffered, chunk]); + const end = 4 + (buffered[0] | (buffered[1] << 8) | (buffered[2] << 16)); + if (buffered.length < 4 || buffered.length < end) return; + raw.removeListener("data", onData); + upgrade(raw, buffered.subarray(end)); + }); + }), +); + +const url = `https://localhost:${origin}/`; +const wss = `wss://localhost:${origin}/`; +const sql = (adapter: "postgres" | "mysql", port: number) => + new Bun.SQL({ adapter, hostname: "localhost", port, username: "u", database: "d", tls: true, max: 1 }).connect(); + +/** How many handshakes it takes, and how to start it. */ +const clients: Record unknown]> = { + "fetch": [1, () => fetch(url, { keepalive: false })], + "fetch, tls: {}": [1, () => fetch(url, { keepalive: false, tls: {} })], + "fetch, rejectUnauthorized": [1, () => fetch(url, { keepalive: false, tls: { rejectUnauthorized: false } })], + "fetch, ca": [1, () => fetch(url, { keepalive: false, tls: { ca: cert.cert } })], + "fetch, http proxy": [1, () => fetch(url, { keepalive: false, proxy: httpProxy })], + "fetch, https proxy": [2, () => fetch(url, { keepalive: false, proxy: httpsProxy })], + "WebSocket": [1, () => void new WebSocket(wss)], + "WebSocket, rejectUnauthorized": [1, () => void new WebSocket(wss, { tls: { rejectUnauthorized: false } })], + "WebSocket, http proxy": [1, () => void new WebSocket(wss, { proxy: httpProxy })], + "WebSocket, https proxy": [2, () => void new WebSocket(wss, { proxy: httpsProxy })], + "RedisClient, tls: true": [ + 1, + () => new Bun.RedisClient(`rediss://localhost:${origin}`, { tls: true, autoReconnect: false }).connect(), + ], + "Bun.connect, tls: true": [ + 1, + () => Bun.connect({ hostname: "localhost", port: origin, tls: true, socket: { data() {}, error() {} } }), + ], + "Bun.SQL postgres, tls: true": [1, () => sql("postgres", postgres)], + "Bun.SQL mysql, tls: true": [1, () => sql("mysql", mysql)], +}; + +async function outcome(handshakes: number, connect: () => unknown) { + reports = []; + const { promise, resolve } = Promise.withResolvers(); + reported = () => void ((reports.length === handshakes || !reports.at(-1)!.startsWith("ECDHE-")) && resolve()); + Promise.resolve(connect()).catch(() => {}); + await promise; + return reports.join(", "); +} + +const results: Record = {}; +for (const list of [undefined, AES256, AES128, "TLS_AES_128_GCM_SHA256"]) { + if (list) tls.DEFAULT_CIPHERS = list; + for (const [name, [handshakes, connect]] of Object.entries(clients)) { + ((results[name] ??= []) as string[]).push(await outcome(handshakes, connect)); + } +} +// The list is the thread's, as in Node.js. +results.worker = await outcome(1, () => new Worker(`new WebSocket(${JSON.stringify(wss)})`, { eval: true })); +console.log(JSON.stringify(results)); +process.exit(0); From ef999e96c0d303f314b405b8be1a31cb95cbe5e8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:40:46 +0000 Subject: [PATCH 128/174] s3: follow tls.DEFAULT_CIPHERS (#41810) The three requests S3Client builds (simple, list, streaming download) carry no TLS config, so they used the default context of the HTTP thread, like fetch without a tls option did. They take the same config, through one helper that fetch now uses too. One Option check while nothing is assigned. --- src/runtime/socket/SSLConfig.rs | 8 ++++++++ src/runtime/socket/mod.rs | 3 ++- src/runtime/webcore/fetch.rs | 6 ++---- src/runtime/webcore/s3/client.rs | 2 ++ src/runtime/webcore/s3/simple_request.rs | 1 + test/js/node/tls/node-tls-server.test.ts | 3 +++ test/js/node/tls/tls-default-ciphers-fixture.ts | 4 ++++ 7 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index b02d24ccb7aa..970aee2095d2 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -293,6 +293,14 @@ pub(crate) fn tls_true_defaults(vm: &VirtualMachine, is_server: bool) -> SSLConf cfg } +/// For a request without TLS options: its default context is built on the HTTP thread, which does not see `tls.DEFAULT_CIPHERS`. +pub(crate) fn http_client_defaults(vm: &VirtualMachine) -> Option { + vm.tls_default_ciphers()?; + Some(bun_http::ssl_config::global_registry::intern( + tls_true_defaults(vm, false), + )) +} + /// No `ciphers` option means `tls.DEFAULT_CIPHERS`, as in Node's `configSecureContext`. fn apply_default_ciphers(vm: &VirtualMachine, cfg: &mut SSLConfig) { if let Some(ciphers) = vm.tls_default_ciphers() { diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index d24f2e3097a1..f34f53612ef6 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -76,7 +76,8 @@ pub(crate) mod uws_jsc; #[path = "SSLConfig.rs"] pub(crate) mod ssl_config; pub(crate) use ssl_config::{ - SSLConfig, SSLConfigFromJs, resolve_reject_unauthorized, tls_true_defaults, + SSLConfig, SSLConfigFromJs, http_client_defaults, resolve_reject_unauthorized, + tls_true_defaults, }; // ─── canonical type surface ────────────────────────────────────────────────── diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index b54fa40606ce..c5282e235c88 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -759,10 +759,8 @@ fn fetch_impl( reject_unauthorized = session.reject_unauthorized().unwrap_or(reject_unauthorized); session_check_server_identity = session.check_server_identity().is_some(); } - // The default context is built on the HTTP thread, which does not see `tls.DEFAULT_CIPHERS`. - if ssl_config.is_none() && vm.tls_default_ciphers().is_some() { - let defaults = crate::socket::tls_true_defaults(vm, false); - ssl_config = Some(http::ssl_config::global_registry::intern(defaults)); + if ssl_config.is_none() { + ssl_config = crate::socket::http_client_defaults(vm); } // unix: string | undefined diff --git a/src/runtime/webcore/s3/client.rs b/src/runtime/webcore/s3/client.rs index fb26347f2d3a..4d62db5cfdae 100644 --- a/src/runtime/webcore/s3/client.rs +++ b/src/runtime/webcore/s3/client.rs @@ -350,6 +350,7 @@ pub(crate) fn list_objects( http_proxy, verbose: Some(vm.get_verbose_fetch()), reject_unauthorized: Some(vm.get_tls_reject_unauthorized()), + tls_props: crate::socket::http_client_defaults(vm), signals: Some(task.signal_store.to()), ..Default::default() }, @@ -1285,6 +1286,7 @@ fn download_stream( verbose: Some(verbose), signals: Some(task.signals), reject_unauthorized: Some(reject_unauthorized), + tls_props: crate::socket::http_client_defaults(vm), ..Default::default() }, )); diff --git a/src/runtime/webcore/s3/simple_request.rs b/src/runtime/webcore/s3/simple_request.rs index 8c7369111576..c8b41d701cab 100644 --- a/src/runtime/webcore/s3/simple_request.rs +++ b/src/runtime/webcore/s3/simple_request.rs @@ -724,6 +724,7 @@ pub(crate) fn execute_simple_s3_request( http_proxy, verbose: Some(verbose), reject_unauthorized: Some(reject_unauthorized), + tls_props: crate::socket::http_client_defaults(vm), // SAFETY: `task_ptr` outlives the request; the store is only read // through these pointers by the HTTP client. signals: Some(unsafe { (*task_ptr).signal_store.to() }), diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index e04d8b6c80ff..d1527db19f3e 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4212,6 +4212,9 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as "Bun.connect, tls: true": row, "Bun.SQL postgres, tls: true": row, "Bun.SQL mysql, tls: true": row, + "S3Client, text()": row, + "S3Client, stream()": row, + "S3Client, list()": row, worker: AES128, }); expect(exitCode).toBe(0); diff --git a/test/js/node/tls/tls-default-ciphers-fixture.ts b/test/js/node/tls/tls-default-ciphers-fixture.ts index 1f6b70c02ca3..e71543d6085d 100644 --- a/test/js/node/tls/tls-default-ciphers-fixture.ts +++ b/test/js/node/tls/tls-default-ciphers-fixture.ts @@ -90,6 +90,7 @@ const url = `https://localhost:${origin}/`; const wss = `wss://localhost:${origin}/`; const sql = (adapter: "postgres" | "mysql", port: number) => new Bun.SQL({ adapter, hostname: "localhost", port, username: "u", database: "d", tls: true, max: 1 }).connect(); +const s3 = () => new Bun.S3Client({ endpoint: url.slice(0, -1), accessKeyId: "a", secretAccessKey: "b", bucket: "c" }); /** How many handshakes it takes, and how to start it. */ const clients: Record unknown]> = { @@ -113,6 +114,9 @@ const clients: Record unknown]> = { ], "Bun.SQL postgres, tls: true": [1, () => sql("postgres", postgres)], "Bun.SQL mysql, tls: true": [1, () => sql("mysql", mysql)], + "S3Client, text()": [1, () => s3().file("x").text()], + "S3Client, stream()": [1, () => s3().file("x").stream().getReader().read()], + "S3Client, list()": [1, () => s3().list()], }; async function outcome(handshakes: number, connect: () => unknown) { From 768e1313f2cb6756ab1b91787520ed0919c9ae46 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:42:34 +0000 Subject: [PATCH 129/174] fetch: preconnect() dials nothing once tls.DEFAULT_CIPHERS is assigned (#41810) It warms a socket of the default context, with the default ciphers. After an assignment no request takes a socket from that context, so the handshake only negotiated a suite the list may exclude. --- src/runtime/webcore/fetch.rs | 9 +++------ test/js/node/tls/node-tls-server.test.ts | 1 + test/js/node/tls/tls-default-ciphers-fixture.ts | 9 +++++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index c5282e235c88..9f37c17b4258 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -286,12 +286,9 @@ fn bun_fetch_preconnect( // `preconnect` is a free fn in `bun_http::async_http`. Ownership // of `href_raw` transfers here (`is_url_owned: true`). - // A request to an origin the environment proxies never dials it. - if VirtualMachine::get() - .env_loader() - .get_http_proxy_for(&url) - .is_some() - { + // A request to an origin the environment proxies never dials it. One after `tls.DEFAULT_CIPHERS` was assigned does not use the default context. + let vm = VirtualMachine::get(); + if vm.env_loader().get_http_proxy_for(&url).is_some() || vm.tls_default_ciphers().is_some() { reclaim_href!(); return Ok(JSValue::UNDEFINED); } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index d1527db19f3e..1cee467522a6 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4216,6 +4216,7 @@ it("tls.DEFAULT_CIPHERS reaches a client whatever its other TLS options are", as "S3Client, stream()": row, "S3Client, list()": row, worker: AES128, + "connections of fetch.preconnect() and fetch()": "1", }); expect(exitCode).toBe(0); }); diff --git a/test/js/node/tls/tls-default-ciphers-fixture.ts b/test/js/node/tls/tls-default-ciphers-fixture.ts index e71543d6085d..2b9bf9687165 100644 --- a/test/js/node/tls/tls-default-ciphers-fixture.ts +++ b/test/js/node/tls/tls-default-ciphers-fixture.ts @@ -20,6 +20,7 @@ const serverOptions = { /** The suite of each handshake of the client under test, or the error that ended one. */ let reports: string[] = []; let reported = () => {}; +let connections = 0; function report(outcome: string) { reports.push(outcome); reported(); @@ -35,6 +36,7 @@ function tlsServer(onSecure: (socket: tls.TLSSocket) => void) { onSecure(socket); }); server.on("tlsClientError", error => report((error as NodeJS.ErrnoException).code!)); + server.on("connection", () => connections++); return listen(server); } @@ -137,5 +139,12 @@ for (const list of [undefined, AES256, AES128, "TLS_AES_128_GCM_SHA256"]) { } // The list is the thread's, as in Node.js. results.worker = await outcome(1, () => new Worker(`new WebSocket(${JSON.stringify(wss)})`, { eval: true })); +// No request would take a socket of the default context any more. The HTTP thread dials in the order of the calls. +connections = 0; +await outcome(1, () => { + fetch.preconnect(`https://127.0.0.1:${origin}/`); + return fetch(`https://127.0.0.1:${origin}/`, { keepalive: false }); +}); +results["connections of fetch.preconnect() and fetch()"] = String(connections); console.log(JSON.stringify(results)); process.exit(0); From 41429e2a444370eb03ac6b420ebcf97db78c276f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:38:08 +0000 Subject: [PATCH 130/174] node:tls: destroy the TLS socket ahead of the fd its wrapped socket's owner closes To keep a plain destroy() of the wrapped socket during the handshake from being reported as ECONNRESET, _destroy set _hadError on the TLS socket that adopted the fd. node:http reads that flag to decide whether it still owes 'socket hang up', so an https request in flight over tls.connect({ socket }) (the shape of https-proxy-agent) lost its 'error' and only closed. The flag is left alone. The TLS socket is destroyed right before the shared fd is closed, as node's wrap 'close' -> destroy() does, so that close finds nothing left to report. It also gives node's order: the TLS socket closes first. --- src/js/node/net.ts | 4 +-- .../tls/node-tls-wrapped-socket-close.test.ts | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index f4d63d7a9e49..4b4be6b4f037 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -2470,8 +2470,6 @@ Socket.prototype._destroy = function _destroy(err, callback) { // Shared-fd TLS pair: defer the close two check-phase turns // (test-tls-socket-close); see closeAdoptedTLSRawNT. currentHandle.pause?.(); - // The TLS socket closes with the socket it wraps, and that close is nothing to report. - currentHandle[kAdoptedTLSRaw]._hadError = true; setImmediate(closeAdoptedTLSRawNT, this, currentHandle, isException); } else { closeSocketHandle(this, currentHandle, isException); @@ -4449,6 +4447,8 @@ function closeSocketHandle(self, handle, isException, isCleanupPending = false) $debug("closeSocketHandle", isException, isCleanupPending); // Ahead of the setImmediate below: the transport's 'close' precedes this socket's. unadoptedTransport(self)?.destroy(); + // The owner of a wrapped socket destroyed it: the TLS socket goes first, so the close of their fd is nothing to report. + handle[kAdoptedTLSRaw]?.destroy(); handle.close(onSocketHandleClosed); setImmediate(() => { $debug("emit close", isCleanupPending); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index 51a567f962db..ec0afa38dd6c 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -17,6 +17,7 @@ import assert from "node:assert"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFileSync } from "node:fs"; +import https from "node:https"; import net from "node:net"; import { join } from "node:path"; import { Duplex } from "node:stream"; @@ -950,6 +951,30 @@ describe("an error of the wrapped socket is reported on the TLS socket", () => { } }); +// The shape of https-proxy-agent. node:http reads the socket's _hadError to decide whether it still owes 'socket hang up'. +for (const when of ["during the handshake", "with the request in flight"]) { + test(`an https request over tls.connect({ socket }) hangs up when the wrapped socket is destroyed ${when}`, async () => { + const destroyRaw = () => void setImmediate(() => raw.destroy()); + const server = + when === "during the handshake" + ? net.createServer(peer => peer.on("error", () => {}).once("data", destroyRaw)) + : https.createServer({ key, cert }, destroyRaw); + const raw = net.connect(await listen(server), "127.0.0.1"); + try { + const events: string[] = []; + const req = https.request({ createConnection: () => tls.connect({ socket: raw, rejectUnauthorized: false }) }); + req.on("response", () => events.push("response")); + req.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code} ${err.message}`)); + req.end(); + await new Promise(resolve => req.on("close", resolve)); + assert.deepStrictEqual(events, ["error ECONNRESET socket hang up"]); + } finally { + raw.destroy(); + server.close(); + } + }); +} + test("a secureContext that is not one is refused by the constructor", () => { const invalid = { name: "TypeError", code: "ERR_TLS_INVALID_CONTEXT", message: "context must be a SecureContext" }; for (const secureContext of [{ context: {} }, {}, tls.createSecureContext().context, "context"]) { From 2ae839bdf8948b25e06ea5f04cd74d161582aa90 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:40:51 +0000 Subject: [PATCH 131/174] node:tls: do not forward a wrapped socket's error to a TLS socket that already has one With tls.connect({ socket: outer }) made while the outer handshake is pending, a FIN from the peer fails the inner ClientHello that is parked on the outer socket. The inner socket heard of that twice: its own failed write (EPIPE), and the outer socket's ERR_SOCKET_CLOSED through the forward, which emitted a second 'error' on it. The forward now honors _hadError like every other reporter. --- src/js/node/net.ts | 2 +- .../tls/node-tls-wrapped-socket-close.test.ts | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 4b4be6b4f037..3eec093e6139 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -443,7 +443,7 @@ function destroyWhenUpgradedCloses(self, connection) { // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L65 // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L977 function onUpgradedError(self, err) { - if (self.destroyed) return; + if (self.destroyed || self._hadError) return; // The close that follows is no second error. self._hadError = true; self._emitTLSError(err); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index ec0afa38dd6c..af696c8a762b 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -951,6 +951,27 @@ describe("an error of the wrapped socket is reported on the TLS socket", () => { } }); +test("a FIN during the handshake of a TLS socket that is already wrapped is one 'error' on the TLS socket above it", async () => { + const server = net.createServer({ allowHalfOpen: true }, peer => { + peer.on("error", () => {}).once("data", () => peer.end()); + }); + const outer = tls.connect({ port: await listen(server), host: "127.0.0.1", rejectUnauthorized: false }); + // Bun also fails the inner ClientHello that is parked on it, as ERR_SOCKET_CLOSED. Node reports nothing here. + outer.on("error", () => {}); + try { + const inner = tls.connect({ socket: outer, rejectUnauthorized: false }); + let errors = 0; + inner.on("secureConnect", () => assert.fail("secureConnect")).on("error", () => errors++); + await new Promise(resolve => inner.on("close", resolve)); + // Past any error that is still queued. + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(errors, 1); + } finally { + outer.destroy(); + server.close(); + } +}); + // The shape of https-proxy-agent. node:http reads the socket's _hadError to decide whether it still owes 'socket hang up'. for (const when of ["during the handshake", "with the request in flight"]) { test(`an https request over tls.connect({ socket }) hangs up when the wrapped socket is destroyed ${when}`, async () => { From 81e9acbe540c9e6d33aa379874d3d892b6c9f527 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:38:14 +0000 Subject: [PATCH 132/174] Bun.listen, Bun.connect: tls.secureContext alone asks for TLS, and must be a SecureContext Both read tls.secureContext only once the other tls options had already made the socket TLS, so `tls: { secureContext }` by itself was a plaintext listener, or a plaintext connection, and a value that is no SecureContext was ignored. connect() has done so since it learned the option; listen() since it took the tls.Server's context. Its presence alone now makes the socket TLS, with the defaults of `tls: true`, and any other truthy value throws ERR_INVALID_ARG_TYPE, as upgradeTLS() does. --- src/runtime/socket/Listener.rs | 40 +++++++--------- test/js/bun/net/socket.test.ts | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 78e5807795b4..e369dd9a6b43 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -137,10 +137,13 @@ impl Listener { } } -/// The `SSL_CTX` of the native `SecureContext` node:tls passes as `tls.secureContext`. +/// The `SSL_CTX` of the native `SecureContext` in `tls.secureContext`, which alone asks for TLS. fn prebuilt_ssl_ctx( + vm: &VirtualMachine, global: &JSGlobalObject, opts: JSValue, + config: &mut SocketConfig, + is_server: bool, ) -> JsResult> { let Some(tls_js) = opts.get_truthy(global, "tls")? else { return Ok(None); @@ -151,9 +154,17 @@ fn prebuilt_ssl_ctx( let Some(sc_js) = tls_js.get_truthy(global, "secureContext")? else { return Ok(None); }; - Ok(sc_js - .as_class_ref::() - .map(|sc| sc.ctx.clone())) + let Some(sc) = sc_js.as_class_ref::() else { + return Err(global.throw_invalid_argument_type_value( + b"secureContext", + b"SecureContext", + sc_js, + )); + }; + if config.ssl.is_none() { + config.ssl = Some(crate::socket::tls_true_defaults(vm, is_server)); + } + Ok(Some(sc.ctx.clone())) } /// Tears down the half-built `Listener` of `listen()`, unless `disarm()` runs first. @@ -256,14 +267,10 @@ impl Listener { )); } } + let prebuilt_ctx = prebuilt_ssl_ctx(vm, cx.global(), opts, &mut socket_config, true)?; let ssl_enabled = socket_config.ssl.is_some(); let socket_flags = socket_config.socket_flags(); let pause_on_connect = socket_config.pause_on_connect; - let prebuilt_ctx = if ssl_enabled { - prebuilt_ssl_ctx(cx.global(), opts)? - } else { - None - }; #[cfg(windows)] if port.is_none() { @@ -836,10 +843,7 @@ impl Listener { } /// `tls.Server#setSecureContext()` while listening. Accepted sockets keep their context. - pub(crate) fn set_secure_context( - this: &Self, - secure_context: &SecureContext, - ) -> JSValue { + pub(crate) fn set_secure_context(this: &Self, secure_context: &SecureContext) -> JSValue { if !this.ssl { return JSValue::UNDEFINED; } @@ -1130,6 +1134,7 @@ impl Listener { let _cell_root = handlers.root_cell(cx.global()); let port = socket_config.port; + let mut owned_ssl_ctx = prebuilt_ssl_ctx(vm, cx.global(), opts, &mut socket_config, false)?; let ssl_enabled = socket_config.ssl.is_some(); let default_data = socket_config.default_data; @@ -1187,15 +1192,6 @@ impl Listener { Some((local_addr_bytes.to_vec().into_boxed_slice(), local_port)) }; - // Resolve the prebuilt SSL_CTX before the platform branches so the Windows - // named-pipe path can adopt it. node:tls passes the native SecureContext as - // `tls.secureContext` so we share its already-built SSL_CTX. - let mut owned_ssl_ctx = if ssl_enabled { - prebuilt_ssl_ctx(cx.global(), opts)? - } else { - None - }; - #[cfg(windows)] let mut connection = connection; #[cfg(windows)] diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 4de6966ab638..e2272d65ad59 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -6182,3 +6182,90 @@ describe("a fatal TLS alert that arrives after the handshake", () => { expect(await rejectedClientEvents(false)).toEqual(["handshake true TLSv1.3", "close undefined"]); }); }); + +// node:tls hands the context it built to Bun.listen() and Bun.connect() as `tls.secureContext`. +describe("tls.secureContext with no other tls option", () => { + const native = () => (createSecureContext({ key: tls.key, cert: tls.cert }) as any).context; + const socket = { + open: (s: Socket) => void s.write("PLAINTEXT-HELLO"), + data() {}, + error() {}, + }; + const notAContext = expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }); + const wrongValues: [string, unknown][] = [ + ["a plain object", {}], + ["a string", "x"], + ["the node:tls wrapper", createSecureContext({ key: tls.key, cert: tls.cert })], + ]; + + it("Bun.listen() speaks TLS", async () => { + using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, tls: { secureContext: native() } as any, socket }); + // What open() wrote travels inside the session: a client that completes the handshake is the one that reads it. + const client = tlsConnect({ host: "127.0.0.1", port: listener.port, rejectUnauthorized: false }); + const [chunk] = await once(client, "data"); + expect({ encrypted: client.encrypted, text: chunk.toString() }).toEqual({ + encrypted: true, + text: "PLAINTEXT-HELLO", + }); + client.destroy(); + + // A client that sends no ClientHello is sent nothing. + const raw = net.connect(listener.port, "127.0.0.1"); + const received: Buffer[] = []; + raw.on("data", chunk => received.push(chunk)); + await once(raw, "connect"); + raw.end(); + await once(raw, "close"); + expect(Buffer.concat(received).toString("latin1")).toBe(""); + }); + + it("Bun.connect() speaks TLS", async () => { + const first = Promise.withResolvers(); + const server = net.createServer(raw => raw.on("error", () => {}).once("data", first.resolve)); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: (server.address() as net.AddressInfo).port, + tls: { secureContext: native() } as any, + socket, + }); + // 22: a handshake record, the ClientHello. + expect((await first.promise)[0]).toBe(22); + client.terminate(); + } finally { + server.close(); + } + }); + + it.each(wrongValues)("Bun.listen() throws for %s", (_, secureContext) => { + expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls: { secureContext } as any, socket })).toThrow( + notAContext, + ); + // Not ignored next to options that make a listener TLS by themselves either. + expect(() => + Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: { key: tls.key, cert: tls.cert, secureContext } as any, + socket, + }), + ).toThrow(notAContext); + }); + + it.each(wrongValues)("Bun.connect() throws for %s", async (_, secureContext) => { + let dialed = 0; + const server = net.createServer(() => void dialed++); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + try { + const port = (server.address() as net.AddressInfo).port; + const dial = async () => Bun.connect({ hostname: "127.0.0.1", port, tls: { secureContext } as any, socket }); + await expect(dial()).rejects.toEqual(notAContext); + expect(dialed).toBe(0); + } finally { + server.close(); + } + }); +}); From 621f1e88bb8575e71cf4cce7201de5cb3176a858 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:38:15 +0000 Subject: [PATCH 133/174] node:tls: the listener never keeps the context setSecureContext() replaced The context handed to the listener was left alone when setSecureContext() got a SecureContext instance, while the key and certificate fields were cleared and _sharedCreds replaced. A later listen() then served the certificate the server had been told to replace, and a live listener kept serving it, which it already did before. The listener's context is now rebuilt on every call from the server's own fields and policy. An instance names no key and no certificate, as in Node, so the listener has none to serve and the handshake fails, in either order. Wrapped sockets keep using the instance. --- src/js/node/tls.ts | 10 ++--- test/js/node/tls/node-tls-server.test.ts | 56 ++++++++++++------------ 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 38031ffedadc..c8e35c454cce 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1293,11 +1293,11 @@ function Server(options, secureConnectionListener): void { } const isContext = serverTLSOptions instanceof InternalSecureContext; // Reads the staged fields over the server's own. Throws on material BoringSSL rejects, so it runs before the fields change. - const sharedCreds = isContext ? serverTLSOptions : buildSharedCreds({ __proto__: this, ...next }, serverTLSOptions); - // Accepted sockets take their verify mode from the context, so a listener only gets one built from this server's policy. - if (!isContext) listenerContext = sharedCreds.context; + const own = buildSharedCreds({ __proto__: this, ...next }, isContext ? undefined : serverTLSOptions); + // Accepted sockets take their verify mode from the context, so the listener's is always this server's own. An instance names no key, as in Node. + listenerContext = own.context; const handle = this._handle; - if (handle && options && !isContext) { + if (handle && options) { setListenerSecureContext(handle, listenerContext); } if (options) { @@ -1317,7 +1317,7 @@ function Server(options, secureConnectionListener): void { this.minVersion = next.minVersion; this.maxVersion = next.maxVersion; } - this._sharedCreds = sharedCreds; + this._sharedCreds = isContext ? serverTLSOptions : own; }; this[buntls] = function (port, host, isClient) { diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 1cee467522a6..795db223b051 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4928,33 +4928,35 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("accepted and injected connection } }); - // Bun only: Node's setSecureContext() takes options. Accepted sockets take their verify mode from the context. - it("the listener keeps asking for a client certificate after setSecureContext(aSecureContext)", async () => { - const material = { ...COMMON_CERT, ca: COMMON_CERT.cert }; - const server: Server = createServer({ ...material, requestCert: true, rejectUnauthorized: false }); - try { - server.setSecureContext(tls.createSecureContext(COMMON_CERT) as any); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const secure = once(server, "secureConnection"); - const client = connect({ - ...COMMON_CERT, - port: (server.address() as AddressInfo).port, - host: "127.0.0.1", - rejectUnauthorized: false, - minVersion: version, - maxVersion: version, - }); - client.on("error", () => {}); - const [socket] = await secure; - expect(socket.getPeerCertificate().fingerprint256).toBe( - new crypto.X509Certificate(COMMON_CERT.cert).fingerprint256, - ); - client.destroy(); - } finally { - server.close(); - } - }); + // Node reads a SecureContext instance as options that name no key and no certificate. + it.each(["before listen()", "while listening"])( + "the listener stops serving what setSecureContext(aSecureContext) replaced, %s", + async when => { + const server: Server = createServer(COMMON_CERT, socket => socket.on("error", () => {}).end()); + server.on("tlsClientError", () => {}); + const replace = () => server.setSecureContext(tls.createSecureContext({ key: rawKey, cert }) as any); + try { + if (when === "before listen()") replace(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + if (when === "while listening") replace(); + const { promise, resolve } = Promise.withResolvers(); + const client = connect({ + port: (server.address() as AddressInfo).port, + host: "127.0.0.1", + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + client.on("secureConnect", () => resolve(`served ${client.getPeerCertificate().fingerprint256}`)); + client.on("error", () => resolve("no handshake")); + expect(await promise).toBe("no handshake"); + client.destroy(); + } finally { + server.close(); + } + }, + ); // A resumed handshake skips client authentication. it("a server that requires a client certificate shares none with one that does not", async () => { From 1b9e48bbbd495f9b6937c24688038f79282a3b9a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:38:34 +0000 Subject: [PATCH 134/174] node:tls: comments that described a listener built from the raw key and certificate A tls.Server's listener serves the context setSecureContext() built. Only a named pipe listener still builds from the raw fields. --- src/js/node/tls.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index c8e35c454cce..ffe65c61309a 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -586,7 +586,7 @@ function createSecureContext(options) { // by the per-VM `SSLContextCache`, so no JS-side hashing here. The JS wrapper // is built fresh because it carries the per-call `servername`. // The user-facing constructor owns its SSL_CTX exclusively so addCACert - // cannot leak across contexts; internal connect/listen paths stay cached. + // cannot leak across contexts; a TLSSocket's own and addContext()'s stay cached. return new InternalSecureContext(options); } @@ -1208,20 +1208,11 @@ function Server(options, secureConnectionListener): void { next.key = key; let ca = options.ca; - // The process-wide default-CA override (tls.setDefaultCACertificates) - // applies here too when no explicit `ca` was given: this path hands raw - // {key, cert, ca} to the native listener and never goes through - // InternalSecureContext, so without this an mTLS server would verify - // client certificates against the bundled roots instead of the - // overridden defaults. + // InternalSecureContext applies tls.setDefaultCACertificates() itself; a named pipe listener builds from this field. if (_defaultCACertificatesOverride !== undefined && ca == null) { ca = _defaultCACertificatesOverride; } - // PKCS#12-embedded CAs are stashed separately so createSecureContext can - // extend (not replace) the default trust set via addCACert. The server - // path hands raw {key, cert, ca} to the native listener and has no - // addCACert hook, so fold them into `ca` here - an mTLS server should - // verify client certificates against the bundle's own CA chain. + // buildSharedCreds() drops them, so they go into `ca`: an mTLS server verifies clients against the bundle's own CA chain. const pfxExtraCAs = options._pfxExtraCACerts; if (pfxExtraCAs?.length) { ca = ca == null ? pfxExtraCAs : Array.isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; From b6425cba953ebaab559f3f2904e9590dbcc03b01 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:51:59 +0000 Subject: [PATCH 135/174] test(tls): drop the in-process WeakRef check of a dropped tls.Server It asserted collection after one GC in the test process and failed 1 run in 20 with --rerun-each 10: what else has run decides whether a closure over the server is still referenced at that moment. The permanent retention it guarded against, the Server prototype pinning the last server constructed, is covered by "the names last as long as the connections, and no longer", which runs in a process of its own and asserts that no SSL_CTX is left. --- test/js/node/tls/node-tls-server.test.ts | 26 ------------------------ 1 file changed, 26 deletions(-) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 795db223b051..90d97638ddaa 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -4271,32 +4271,6 @@ it.each([ }, ); -describe("a tls.Server nothing refers to any more is collected", () => { - it.each([ - ["never listened", async (_: Server) => {}], - [ - "listened and closed", - async (server: Server) => { - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - server.close(); - await once(server, "close"); - }, - ], - ])("%s", async (_, use) => { - const ref = await (async () => { - const server: Server = createServer(COMMON_CERT); - server.addContext("a.example", COMMON_CERT); - await use(server); - return new WeakRef(server); - })(); - // A WeakRef keeps its target until the job that created it ends. - await new Promise(resolve => setImmediate(resolve)); - Bun.gc(true); - expect(ref.deref()).toBeUndefined(); - }); -}); - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L480-L488 describe("a server-side handshake failure reported under a JS call emits 'close'", () => { // `wrap` writes a plain banner, so `peer` runs once the wrap is in place. From 44b287e45b31433b65fab97ed2d87a81f1143906 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:38:42 +0000 Subject: [PATCH 136/174] node:tls: what a wrapped Duplex passes to its write callback only says whether the write failed on_write_done was both the callback handed to duplex.write() and the function it queued for the next tick, and told the two apart by whether its argument was a number. A stream whose _write() calls callback(-1) or callback(1e9) reached from_code_int with that number: a failed assertion in a debug build, a truncated errno in a release build. callback(5) was reported at once as `write EIO`, and callback(0) from a hand-written write() never completed the write. None of them decremented the count of writes in flight. callback("") counted as a failure. The stream now gets the function bound to a first argument of undefined, and only the queued call has an errno there, so no stream can choose it. The bound function takes the wrapper's slot and keeps its target alive: no new slot, sizeof(JSTLSSocket) stays 128. What the stream passes is read as node reads it: truthy is a failure, uv[`UV_${err.code}`] || UV_EPIPE. --- src/runtime/socket/UpgradedDuplex.rs | 49 +++++++++---- test/js/node/tls/node-tls-connect.test.ts | 83 +++++++++++++++++++++++ 2 files changed, 117 insertions(+), 15 deletions(-) diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index c5717b13f7f9..71fe9f51423c 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -44,7 +44,7 @@ pub(crate) struct UpgradedDuplex { pub on_data_callback: Cell, pub on_end_callback: Cell, pub on_close_callback: Cell, - /// The `cb` of every `origin.write(chunk, cb)` and `origin.end(null, cb)`. + /// What the `cb` of every `origin.write(chunk, cb)` and `origin.end(null, cb)` is bound to. pub on_write_done_callback: Cell, /// Those calls whose `cb` has not run. pub in_flight: Cell, @@ -328,14 +328,13 @@ impl UpgradedDuplex { None => JSValue::NULL, }; chunk.ensure_still_alive(); - let done = lazy_js_handler( - &self.on_write_done_callback, - self.js_wrapper, - js_TLSSocket::duplex_on_write_done_set_cached, - &global, - __jsc_host_on_write_done, - std::ptr::from_ref(self).cast_mut().cast::(), - ); + let done = match self.write_done_callback(&global) { + Ok(done) => done, + Err(err) => { + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + return; + } + }; self.in_flight.set(self.in_flight.get() + 1); if let Err(err) = write_or_end.call(&global, duplex, &[chunk, done]) { @@ -343,6 +342,26 @@ impl UpgradedDuplex { } } + /// `on_write_done` with its first argument bound, so that no stream can choose it. + fn write_done_callback(&self, global: &JSGlobalObject) -> JsResult { + if let Some(done) = js_TLSSocket::duplex_on_write_done_get_cached(self.js_wrapper) { + return Ok(done); + } + let target = host_fn::new_function_with_data( + global, + None, + 0, + __jsc_host_on_write_done, + std::ptr::from_ref(self).cast_mut().cast::(), + ); + let no_errno = [JSValue::UNDEFINED]; + let name = bun_core::String::EMPTY; + let done = target.bind(global, JSValue::UNDEFINED, &name, 1.0, &no_errno)?; + js_TLSSocket::duplex_on_write_done_set_cached(self.js_wrapper, global, done); + self.on_write_done_callback.set(target); + Ok(done) + } + /// The wrapped stream has completed every write it was handed. #[uws_callback(export = "UpgradedDuplex__transport_idle", no_catch)] pub(crate) fn transport_idle(&self) -> bool { @@ -802,19 +821,19 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { Ok(JSValue::UNDEFINED) } -/// Node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. +/// `(undefined, err)` is node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. #[bun_jsc::host_fn] fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { bun_output::scoped_log!(UpgradedDuplex, "onWriteDone"); let function = frame.callee(); - let [arg] = frame.arguments_as_array::<1>(); + let [errno, err] = frame.arguments_as_array::<2>(); if let Some(self_ptr) = host_fn::get_function_data(function) { // SAFETY: see host-fn note above. let this = unsafe { &*self_ptr.cast::() }; - if arg.is_number() { - let errno = arg.to_int32(); + if errno.is_number() { + let errno = errno.to_int32(); if errno != 0 { let err = bun_sys::Error::from_code_int(errno, bun_sys::Tag::write); let mut err = ::to_system_error(&err); @@ -829,8 +848,8 @@ fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult new Promise(resolve => setImmediate(resolve)); + const shape = err => err && [err.name, err.message, err.code, util.getSystemErrorName(err.errno), err.syscall].join(" | "); + async function run(value) { + let stalled = false, held, chunk; + class Side extends Duplex { + _read() {} + _write(data, encoding, callback) { + if (stalled && this === clientSide) return void ([held, chunk] = [callback, data]); + this.peer.push(data); + callback(); + } + _final(callback) { + this.peer.push(null); + callback(); + } + } + const clientSide = new Side(), serverSide = new Side(); + clientSide.peer = serverSide; + serverSide.peer = clientSide; + // Not through stream.Writable, which hands a falsy value on as null. + if (process.argv.at(-1) === "write()") { + clientSide.write = (data, encoding, callback) => (clientSide._write(data, null, callback ?? encoding), true); + } + clientSide.on("error", () => {}); + const server = new tls.TLSSocket(serverSide, { isServer: true, key: process.env.TLS_KEY, cert: process.env.TLS_CERT }); + const client = tls.connect({ socket: clientSide, rejectUnauthorized: false }); + server.on("error", () => {}); + let received = ""; + server.on("data", data => (received += data)); + const ended = once(server, "end"); + const log = []; + // Node's tls.connect() also forwards the error of the Duplex. + client.on("error", err => err?.syscall && log.push("'error': " + shape(err))); + const closed = new Promise(resolve => client.on("close", resolve)); + await once(client, "secureConnect"); + await turn(); + stalled = true; + const written = new Promise(resolve => client.write("a", resolve)); + await turn(); + await turn(); + stalled = false; + if (!value) serverSide.push(chunk); + held(value); + const err = await written; + log.unshift("write callback: " + shape(err)); + if (err) await closed; + else { + log.push("end callback: " + ((await new Promise(resolve => client.end("b", resolve))) ?? null)); + await ended; + log.push("received " + received); + } + await turn(); + client.destroy(); + server.destroy(); + return log.join(", "); + } + (async () => { + for (const value of values) console.log(await run(value)); + })(); + `; + it.each(["_write()", "write()"])( + "what the %s of the Duplex passes to the write callback only says whether the write failed", + async form => { + const { stdout } = await promisify(execFile)(bunExe(), ["-e", writeCallbackValues, form], { + env: { ...bunEnv, TLS_KEY: COMMON_CERT_.key, TLS_CERT: COMMON_CERT_.cert }, + }); + const failed = (code: string) => + `write callback: Error | write ${code} | ${code} | ${code} | write, 'error': Error | write ${code} | ${code} | ${code} | write`; + const completed = "write callback: null, end callback: null, received ab"; + expect(stdout.trim().split("\n")).toEqual([ + ...Array(5).fill(failed("EPIPE")), + failed("ECONNRESET"), + ...Array(3).fill(completed), + ]); + }, + ); + describe.each(["client", "server"] as const)("a %s write in flight", side => { it.each(["destroy()", "destroy(err)", "the destroy() of the Duplex"])( "fails at %s, and a Duplex that completes it afterwards changes nothing", From 7b1a1edea4e7db46d2c8f5850996acda2b7969a7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 05:58:04 +0000 Subject: [PATCH 137/174] node:tls: say that a write over a Duplex completes a tick later, not as node's immediate node's JSStreamSocket queues finishWrite with setImmediate(). UpgradedDuplex queues it with process.nextTick, and the comment read as if that were node's timing. It is not: a chain of writes that each wait for the one before never leaves the tick queue, so a timer does not fire until the chain ends, as on main. The case is a todo test. An immediate was tried and is not taken. The engine closes by itself when the peer's close_notify arrives or the stream closes, and the close fails the parked write. With the failure queued as an immediate the stream's own 'close' comes first and `write EPIPE` becomes ERR_SOCKET_CLOSED. With only the success queued as an immediate, a write the stream has completed is failed with ERR_SOCKET_CLOSED when the peer answers and closes in the same turn. --- src/runtime/socket/UpgradedDuplex.rs | 2 +- test/js/node/tls/node-tls-connect.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 71fe9f51423c..4ee6b9116e84 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -821,7 +821,7 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { Ok(JSValue::UNDEFINED) } -/// `(undefined, err)` is node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. +/// `(undefined, err)` is node's `done(err)` of `doWrite`, but queues `finishWrite(errno)`, itself, for the next tick: a close overtakes an immediate. #[bun_jsc::host_fn] fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { bun_output::scoped_log!(UpgradedDuplex, "onWriteDone"); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index efcbb91ffa84..51d7bd54ae03 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -6539,6 +6539,28 @@ describe("a write on a TLS socket over a Duplex completes when the Duplex has co }, ); + // Node completes a write in a setImmediate(): https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L218 + it.todo("writes that each wait for the one before let a timer fire", async () => { + const pair = makePair("client"); + using sockets = await connectPair(pair, "client"); + const { writer, reader } = sockets; + reader.resume(); + const limit = 2000; + let writes = 0; + let fired = false; + const timer = setTimeout(() => (fired = true), 1); + const { promise, resolve, reject } = Promise.withResolvers(); + writer.on("error", reject); + (function pump(err?: Error | null) { + if (err) reject(err); + else if (fired || ++writes > limit) resolve(); + else writer.write("x", pump); + })(); + await promise; + clearTimeout(timer); + expect(fired).toBe(true); + }); + describe.each(["client", "server"] as const)("a %s write in flight", side => { it.each(["destroy()", "destroy(err)", "the destroy() of the Duplex"])( "fails at %s, and a Duplex that completes it afterwards changes nothing", From e92a938fe52010b4687f9ecfd5c040c089f44192 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:18:38 +0000 Subject: [PATCH 138/174] [autofix.ci] apply automated fixes --- test/js/sql/tls-sql.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/sql/tls-sql.test.ts b/test/js/sql/tls-sql.test.ts index 23a19a41a268..48fbba360793 100644 --- a/test/js/sql/tls-sql.test.ts +++ b/test/js/sql/tls-sql.test.ts @@ -1,4 +1,4 @@ -import { SQL, randomUUIDv7 } from "bun"; +import { randomUUIDv7, SQL } from "bun"; import { describe, expect, test } from "bun:test"; import { describeWithContainer, isDockerEnabled, tls as tlsCert } from "harness"; import net from "node:net"; From 736afd761fd3b08b6e4cb616c68098cc414d6e02 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 07:02:37 +0000 Subject: [PATCH 139/174] usockets: the TLS read path does not bind NULL to its _Nonnull socket parameter The helpers us_internal_ssl_on_data() calls return NULL for a socket that is gone, and the parameter they were assigned to is _Nonnull in release builds (the annotation is compiled out under BUN_DEBUG), so the release ASAN build aborted on -fsanitize=nullability-assign in every handshake that closed its socket. --- packages/bun-usockets/src/crypto/openssl.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 819fdad93b83..e126db21a1dc 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2512,7 +2512,9 @@ static struct us_socket_t *ssl_fail_established_session(struct us_socket_t *s, s return NULL; } -struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, int length) { +struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *socket, char *data, int length) { + /* The parameter is _Nonnull in release builds, and the helpers below return NULL for a socket that is gone. */ + struct us_socket_t *s = socket; /* See ssl_update_handshake: start this socket's SSL processing with a clean * per-thread error queue so a captured reason cannot belong to another * socket on the same thread. */ From 92ebf92c39217268873e5648ca660c903f3277c7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 07:02:37 +0000 Subject: [PATCH 140/174] http2, postgres: move the parts that do not use the type parameter out of two generic functions --- src/runtime/api/bun/h2_frame_parser.rs | 14 +++++- src/sql_jsc/postgres/PostgresRequest.rs | 60 ++++++++++++++----------- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 3ecb769831be..6fdc51e902ef 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -2525,8 +2525,18 @@ impl H2FrameParser { let offset = self.write_buffer_offset.replace(0); let mut pending = self.write_buffer.take(); let _ = pending.write(bytes); - let total = pending.len() - offset; let result: i32 = socket.write_maybe_corked(&pending[offset..]); + self.restore_unwritten_pending(pending, offset, bytes.len(), result) + } + + fn restore_unwritten_pending( + &self, + pending: Vec, + offset: usize, + appended: usize, + result: i32, + ) -> (usize, usize) { + let total = pending.len() - offset; let written: usize = if result < 0 { if Self::is_transport_fatal_write_result(result) { self.note_transport_write_fatal(); @@ -2544,7 +2554,7 @@ impl H2FrameParser { .with_mut(|wb| wb.write(&reentrant[reentrant_offset..])); self.global() .vm() - .deprecated_report_extra_memory(bytes.len().min(total - written)); + .deprecated_report_extra_memory(appended.min(total - written)); } (written, total) } diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index 88eaf248400f..725fa1ac4b9d 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -503,6 +503,39 @@ pub(crate) fn execute_query( Ok(()) } +fn on_ssl_request_reply( + connection: &PostgresSQLConnection, + c: u8, + sent: u8, +) -> Result<(), AnyPostgresError> { + match c { + b'S' => { + debug_assert!(sent == 8); + connection.tls_status.set(TlsStatus::SslOk); + connection.setup_tls(); + Ok(()) + } + b'N' => { + connection.tls_status.set(TlsStatus::SslNotAvailable); + bun_core::scoped_log!(Postgres, "Server does not support SSL"); + if matches!( + connection.ssl_mode, + SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull + ) { + connection.fail( + b"Server does not support SSL", + AnyPostgresError::TLSNotAvailable, + ); + return Ok(()); + } + // Nothing the server sent before our StartupMessage is a reply to it. + connection.start(); + Ok(()) + } + _ => Err(AnyPostgresError::UnexpectedMessage), + } +} + pub(crate) fn on_data( connection: &PostgresSQLConnection, mut reader: protocol::NewReader, @@ -523,32 +556,7 @@ pub(crate) fn on_data( // it is the only unframed backend byte and must be handled before the // frame peek below. if let TlsStatus::MessageSent(n) = connection.tls_status.get() { - match c { - b'S' => { - debug_assert!(n == 8); - connection.tls_status.set(TlsStatus::SslOk); - connection.setup_tls(); - return Ok(()); - } - b'N' => { - connection.tls_status.set(TlsStatus::SslNotAvailable); - bun_core::scoped_log!(Postgres, "Server does not support SSL"); - if matches!( - connection.ssl_mode, - SslMode::Require | SslMode::VerifyCa | SslMode::VerifyFull - ) { - connection.fail( - b"Server does not support SSL", - AnyPostgresError::TLSNotAvailable, - ); - return Ok(()); - } - // Nothing the server sent before our StartupMessage is a reply to it. - connection.start(); - return Ok(()); - } - _ => return Err(AnyPostgresError::UnexpectedMessage), - } + return on_ssl_request_reply(connection, c, n); } // Every other backend message is Byte1(type) Int32(length) body[length-4]. From 35bb62e90bba6bffb814f0a6d09466f032e178a8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 07:30:38 +0000 Subject: [PATCH 141/174] test(tls): four tests asserted what only the Linux kernel does Whether a peer reads a FIN ahead of the reset behind it, whether unread bytes survive a reset (Windows drops them), how much a loopback buffer holds, and fstat() on a socket handle. Each now says so per platform. The Node.js run of node-tls-wrapped-socket-close fails a hanging test by name instead of running out of time. --- test/js/bun/net/socket.test.ts | 13 ++++++++++--- .../node/tls/node-tls-wrapped-socket-close.test.ts | 14 +++++++++++--- test/js/web/fetch/fetch.tls.test.ts | 3 ++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index e2272d65ad59..3f0f74316e9c 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1772,10 +1772,12 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn cutShort: [missing(SILENT) > 0, missing(DRIPPING) > 0], missing: missing(UNANSWERING), }).toEqual({ - before: { fdIsOpen: true, wrote: batches ? 16 * 1024 : undefined }, + // fstat() does not take a Windows socket handle. + before: { fdIsOpen: !isWindows, wrote: batches ? 16 * 1024 : undefined }, after: { fdIsOpen: false, wrote: batches ? 64 * 1024 : undefined }, timeouts: 0, - cutShort: [true, true], + // Loopback buffers outside Linux are small enough for the drips to read everything in time. + cutShort: [true, isLinux ? true : expect.any(Boolean)], missing: 0, }); } finally { @@ -6126,7 +6128,12 @@ describe.concurrent("write() that is the first to observe the peer's reset retur failureDetail: exitCode === 0 ? "" : stderr, }).toEqual({ reset: true, - result: { writes: [-1, -1], received: "hello and bye", closed: true }, + // Windows drops what the client has yet to read when the reset arrives. + result: { + writes: [-1, -1], + received: isWindows ? expect.stringMatching(/^hello( and bye)?$/) : "hello and bye", + closed: true, + }, failureDetail: "", }); expect(exitCode).toBe(0); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index af696c8a762b..a6fbf77c84a9 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -545,7 +545,14 @@ describe("a TLS socket made over a socket that has sent its FIN", () => { for (const event of ["secureConnect", "end", "error"]) peer.on(event, err => log.push("peer " + event + (err ? " " + err.code : ""))); }); `); - assert.deepStrictEqual(log.sort(), ["peer end", "peer error ECONNRESET", "raw close", "tls close"]); + // Only Linux is known to hand the peer the FIN ahead of the reset behind it. + const peerEnd = process.platform === "linux" ? ["peer end"] : []; + assert.deepStrictEqual(log.filter(event => event !== "peer end" || peerEnd.length > 0).sort(), [ + ...peerEnd, + "peer error ECONNRESET", + "raw close", + "tls close", + ]); }); } @@ -1010,9 +1017,10 @@ test("a secureContext that is not one is refused by the constructor", () => { if (typeof Bun !== "undefined") { const node = Bun.which("node"); describe("Node.js compatibility", () => { - (node ? test : test.skip)("all tests pass in Node.js", async () => { + (node ? test : test.skip)("all tests pass in Node.js", { timeout: 120_000 }, async () => { await using proc = Bun.spawn({ - cmd: [node!, import.meta.filename], + // A test that hangs in Node.js fails by name instead of running this one out of time. + cmd: [node!, "--test-timeout=15000", "--test-force-exit", import.meta.filename], stdout: "pipe", stderr: "pipe", stdin: "ignore", diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 77924a6125c6..25914e5ee3b7 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -1651,7 +1651,8 @@ describe.concurrent("fetch-tls", () => { } }); - it("reads a response that the server sent before it reset the upload", async () => { + // Windows drops what the client has yet to read when the reset arrives. + it.skipIf(isWindows)("reads a response that the server sent before it reset the upload", async () => { const server = tls.createServer(validTls, socket => { socket.on("error", () => {}); socket.once("data", () => { From 8bb2a01051aa6713c043d00f43a1a0277a27cd21 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 07:30:38 +0000 Subject: [PATCH 142/174] Bun.listen: the client certificate policy of tls.secureContext holds The listener took its policy from the options alone. A context created with requestCert and rejectUnauthorized served a client with an untrusted certificate when the options did not repeat them: the certificate was asked for and nothing acted on the verdict. Accepted sockets now also reject when the context in use does, which covers setSecureContext() too. requestCert in the options over a context that does not ask closed every connection. It throws at listen() now: the context is served as it is, so clients would be checked against its store and not the ca next to requestCert. --- src/runtime/socket/Listener.rs | 21 +++++++++- src/runtime/socket/mod.rs | 1 + src/runtime/socket/socket_body.rs | 11 +++--- test/js/bun/net/socket.test.ts | 65 +++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index e369dd9a6b43..e90fa3400bef 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -161,6 +161,20 @@ fn prebuilt_ssl_ctx( sc_js, )); }; + // The context is served as it is: its trust store would not be the `ca` written next to `requestCert`. + if is_server + && config.ssl.as_ref().is_some_and(|ssl| ssl.request_cert != 0) + && !crate::socket::server_ctx_requests_cert(Some(sc.ctx.as_ptr())) + { + return Err(global + .err( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "TLSOptions.requestCert needs a secureContext that was created with requestCert" + ), + ) + .throw()); + } if config.ssl.is_none() { config.ssl = Some(crate::socket::tls_true_defaults(vm, is_server)); } @@ -655,7 +669,12 @@ impl Listener { // `OWNED_PROTOS` stays unset: accepted sockets clone the listener's `protos`. fn accepted_socket_flags(&self) -> SocketFlags { let mut flags = SocketFlags::empty(); - flags.set(SocketFlags::REJECT_UNAUTHORIZED, self.reject_unauthorized); + // Also what the context in use was built with: `tls.secureContext` need not come with the options again. + let ctx = self.secure_ctx.get().as_ref().map(|ctx| ctx.as_ptr()); + flags.set( + SocketFlags::REJECT_UNAUTHORIZED, + self.reject_unauthorized || crate::socket::server_ctx_rejects_unauthorized(ctx), + ); flags.set(SocketFlags::PAUSE_ON_CONNECT, self.pause_on_connect); flags } diff --git a/src/runtime/socket/mod.rs b/src/runtime/socket/mod.rs index f34f53612ef6..52232423205a 100644 --- a/src/runtime/socket/mod.rs +++ b/src/runtime/socket/mod.rs @@ -91,6 +91,7 @@ pub(crate) use socket_address::SocketAddress; pub(crate) use socket_body::DuplexUpgradeContext; pub(crate) use socket_body::{ Flags as SocketFlags, NativeCallbacks, NewSocket, SocketMode, TCPSocket, TLSSocket, + server_ctx_rejects_unauthorized, server_ctx_requests_cert, }; #[cfg(windows)] diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 202c62f8d0b4..6e48079052f8 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -4247,20 +4247,19 @@ fn upgrade_reject_policy( } } -/// A bare server-side `secureContext` carries no parsed config, so the policy -/// comes from the ctx itself: `us_ssl_ctx_from_options` sets +/// The policy a server-side `SSL_CTX` was built with: `us_ssl_ctx_from_options` sets /// `FAIL_IF_NO_PEER_CERT` iff the context was created with `requestCert` and `rejectUnauthorized`. -fn server_ctx_rejects_unauthorized(ctx: Option<*mut SSL_CTX>) -> bool { +pub(crate) fn server_ctx_rejects_unauthorized(ctx: Option<*mut SSL_CTX>) -> bool { let Some(ctx) = ctx else { return false }; const MODE: c_int = boringssl_sys::SSL_VERIFY_PEER | boringssl_sys::SSL_VERIFY_FAIL_IF_NO_PEER_CERT; - // SAFETY: `ctx` is the +1 `SSL_CTX` ref held for this socket; read-only. + // SAFETY: the caller holds a ref on `ctx` across the call; read-only. unsafe { boringssl_sys::SSL_CTX_get_verify_mode(ctx) & MODE == MODE } } -fn server_ctx_requests_cert(ctx: Option<*mut SSL_CTX>) -> bool { +pub(crate) fn server_ctx_requests_cert(ctx: Option<*mut SSL_CTX>) -> bool { let Some(ctx) = ctx else { return false }; - // SAFETY: `ctx` is the +1 `SSL_CTX` ref held for this socket; read-only. + // SAFETY: the caller holds a ref on `ctx` across the call; read-only. unsafe { boringssl_sys::SSL_CTX_get_verify_mode(ctx) & boringssl_sys::SSL_VERIFY_PEER != 0 } } diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 3f0f74316e9c..0f5156e2b781 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -6276,3 +6276,68 @@ describe("tls.secureContext with no other tls option", () => { } }); }); + +// The context is served as it is, so its own client certificate policy holds whatever the options next to it say. +describe.concurrent("Bun.listen() with tls.secureContext and the client certificate", () => { + const pem = (name: string) => readFileSync(join(import.meta.dir, "../../node/test/fixtures/keys", name), "utf8"); + const [key, cert, ca] = [pem("agent1-key.pem"), pem("agent1-cert.pem"), pem("ca1-cert.pem")]; + const clients = { + none: {}, + untrusted: { key: pem("agent3-key.pem"), cert: pem("agent3-cert.pem") }, + trusted: { key, cert }, + }; + const native = (policy: object) => (createSecureContext({ key, cert, ca, ...policy }) as any).context; + const socket = { + handshake: (socket: Socket) => void socket.end(socket.getPeerCertificate()?.subject?.CN ?? "no certificate"), + data() {}, + error() {}, + }; + const strict = { requestCert: true, rejectUnauthorized: true }; + const asksOnly = { requestCert: true, rejectUnauthorized: false }; + const onlyTrusted = { none: "refused", untrusted: "refused", trusted: "agent1" }; + const everyone = { none: "no certificate", untrusted: "agent3", trusted: "agent1" }; + + // What the server saw of the client it served, or "refused". + async function outcome(port: number, maxVersion: "TLSv1.2" | "TLSv1.3", identity: object) { + const client = tlsConnect({ port, host: "127.0.0.1", rejectUnauthorized: false, maxVersion, ...identity }); + let received = ""; + const closed = Promise.withResolvers(); + client.on("data", chunk => (received += chunk)).on("error", () => {}); + client.on("close", () => closed.resolve()); + await closed.promise; + return received || "refused"; + } + + describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s", maxVersion => { + it.each([ + ["neither asks", {}, {}, { none: "no certificate", untrusted: "no certificate", trusted: "no certificate" }], + ["only the context asks", strict, {}, onlyTrusted], + ["only the context asks, next to another option", strict, { sessionTimeout: 300 }, onlyTrusted], + ["both ask", strict, strict, onlyTrusted], + ["the options do not relax the context", strict, asksOnly, onlyTrusted], + ["the context asks and the options reject", asksOnly, strict, onlyTrusted], + ["the context asks and nothing rejects", asksOnly, {}, everyone], + ["both ask and nothing rejects", asksOnly, asksOnly, everyone], + ])("%s", async (_, contextPolicy, options, expected) => { + const tls = { secureContext: native(contextPolicy), ...options }; + using listener = Bun.listen({ hostname: "127.0.0.1", port: 0, tls, socket } as any); + expect({ + none: await outcome(listener.port, maxVersion, clients.none), + untrusted: await outcome(listener.port, maxVersion, clients.untrusted), + trusted: await outcome(listener.port, maxVersion, clients.trusted), + }).toEqual(expected); + }); + }); + + // Clients would be checked against the context's store, not the `ca` written next to `requestCert`. + it.each([ + ["alone", {}], + ["next to a key, a certificate and a ca", { key, cert, ca: pem("ca2-cert.pem") }], + ["with rejectUnauthorized: false", { rejectUnauthorized: false }], + ])("requestCert %s throws over a context that does not ask", (_, options) => { + const tls = { secureContext: native({}), requestCert: true, ...options }; + expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls, socket } as any)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_VALUE" }), + ); + }); +}); From 1db91e1aeb6a408c50abf91b3d525e9ec561a4b7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 08:46:40 +0000 Subject: [PATCH 143/174] tls: the first record leaves with the held handshake flight beside a stalled socket too While a connection on the loop holds the rest of a batch the kernel refused, application writes leave record by record. The write BIO then flushed a held flight and sent the record behind it with a send() of its own, which is #40653 again: a server that tears down right after the handshake closes over the unread second segment and the client reads ECONNRESET in place of 'end'. The record now joins the flight and both leave at once, so a spill still takes no more than a flight and a record. main has the same split. stalledConnection() left its accepted socket open, so what its client could not send waited on the loop into the next test, which is the one for #40653. --- packages/bun-usockets/src/crypto/openssl.c | 9 ++-- test/js/node/tls/tls-client-close-fixture.mjs | 26 ++++++---- test/js/node/tls/tls-syscall-fault.test.ts | 50 +++++++++++++++++++ 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index e126db21a1dc..f8d55e8bb10c 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -103,7 +103,8 @@ struct loop_ssl_data { /* Connections whose spill (us_ssl_spill_t) holds the rest of a batch flush. While there is * one, no application write batches, so its spill takes at most the rest of one record. A - * handshake flight is held whatever this says, and counts here if the kernel refuses it. */ + * handshake flight is held whatever this says, with the first record written behind it, and + * counts here if the kernel refuses it. */ unsigned int ssl_batch_spills; /* Why ssl_raw_write ended a write side inside the running us_internal_ssl_writev or spill drain. */ @@ -566,7 +567,7 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { int batch_is_foreign = loop_ssl_data->ssl_write_batch_len && loop_ssl_data->ssl_write_batch_owner != loop_ssl_data->ssl_socket; - if (loop_ssl_data->ssl_write_batching && !batch_is_foreign) { + if ((loop_ssl_data->ssl_write_batching || loop_ssl_data->ssl_write_batch_len) && !batch_is_foreign) { /* Append the sealed record; the batch hits the kernel once, after * SSL_write returns. */ unsigned int needed = loop_ssl_data->ssl_write_batch_len + (unsigned int)length; @@ -590,11 +591,11 @@ static int BIO_s_custom_write(BIO *bio, const char *data, int length) { memcpy(loop_ssl_data->ssl_write_batch + loop_ssl_data->ssl_write_batch_len, data, (size_t)length); loop_ssl_data->ssl_write_batch_len = needed; loop_ssl_data->ssl_write_batch_owner = loop_ssl_data->ssl_socket; + /* Record by record, but this one leaves with the flight that was held for it: https://github.com/oven-sh/bun/issues/40653 */ + if (!loop_ssl_data->ssl_write_batching) ssl_flush_write_batch(loop_ssl_data, loop_ssl_data->ssl_socket); BIO_clear_retry_flags(bio); return length; } - /* A flight of this socket that the batch still holds goes first. */ - ssl_flush_write_batch(loop_ssl_data, loop_ssl_data->ssl_socket); ssl_emit(loop_ssl_data, loop_ssl_data->ssl_socket, data, (unsigned int)length, 0); BIO_clear_retry_flags(bio); return length; diff --git a/test/js/node/tls/tls-client-close-fixture.mjs b/test/js/node/tls/tls-client-close-fixture.mjs index 1e7e143a5d6d..918781e39182 100644 --- a/test/js/node/tls/tls-client-close-fixture.mjs +++ b/test/js/node/tls/tls-client-close-fixture.mjs @@ -196,18 +196,19 @@ async function secondConnection() { // A TLS connection of this process whose peer never reads, with more written than the kernel takes. export async function stalledConnection() { - const server = tls.createServer({ key: pem("agent1-key.pem"), cert: pem("agent1-cert.pem") }, socket => { - socket.on("error", () => {}); - socket.pause(); - }); + const server = tls.createServer({ key: pem("agent1-key.pem"), cert: pem("agent1-cert.pem") }); await once(server.listen(0, "127.0.0.1"), "listening"); const socket = tls.connect({ host: "127.0.0.1", port: server.address().port, rejectUnauthorized: false }); socket.on("error", () => {}); - await once(socket, "secureConnect"); + const [[accepted]] = await Promise.all([once(server, "secureConnection"), once(socket, "secureConnect")]); + accepted.on("error", () => {}).pause(); socket.write(Buffer.alloc(32 * 1024 * 1024)); return { + socket, + // Both ends: what the client could not send would wait for a peer that is still there, into the next test. close() { socket.destroy(); + accepted.destroy(); server.close(); }, }; @@ -452,10 +453,13 @@ export async function report(mode, version) { } } - const [saw] = await Promise.all([serverSaw.promise, closed]); - second?.close(); - stalled?.close(); - relay.close(); - server.close(); - return { client, server: saw, ...wire(Buffer.concat(fromClient)) }; + try { + const [saw] = await Promise.all([serverSaw.promise, closed]); + return { client, server: saw, ...wire(Buffer.concat(fromClient)) }; + } finally { + second?.close(); + stalled?.close(); + relay.close(); + server.close(); + } } diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 6077802372db..68b99de8fcb7 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -659,6 +659,56 @@ describe.skipIf(skip)("node:https server under injected syscall faults", () => { ); }); +describe.skipIf(skip)("the final handshake flight and the first write leave in one send()", () => { + // While a connection on the loop holds ciphertext the kernel refused, writes leave record by record. Not the + // one that a held flight waits for: as a second segment it is https://github.com/oven-sh/bun/issues/40653. + // The client may send twice, its ClientHello and that flight. A write that needs a send() of its own fails. + test.concurrent.each([ + ["before the handshake", `socket.write("HELLO");`], + ["in 'secureConnect'", `socket.on("secureConnect", () => socket.write("HELLO"));`], + ])("a write %s, beside a stalled TLS socket", async (_name, write) => { + const received: string[] = []; + const options = { key: certs.key, cert: certs.cert, minVersion: "TLSv1.3", maxVersion: "TLSv1.3" } as const; + const peer = tls.createServer(options, socket => { + socket.on("error", () => {}).once("data", chunk => (received.push(chunk.toString()), socket.end())); + }); + await once(peer.listen(0, "127.0.0.1"), "listening"); + const fixture = /* js */ ` + const { socketFaultInjection: fault } = require("bun:internal-for-testing"); + const { stalledConnection } = require(${JSON.stringify(join(import.meta.dir, "tls-client-close-fixture.mjs"))}); + const tls = require("node:tls"); + (async () => { + const stalled = await stalledConnection(); + for (let left = 3; left > 0; left--) { + const events = []; + const socket = tls.connect({ host: "127.0.0.1", port: ${(peer.address() as AddressInfo).port}, rejectUnauthorized: false }); + // The ClientHello leaves after 'connect'. + socket.on("connect", () => fault.set({ syscall: "send", action: "errno", errno: "ECONNRESET", fd: socket._handle.fd, after: 2, repeat: -1 })); + ${write} + socket.on("error", error => events.push("error:" + error.code)); + socket.on("end", () => events.push("end")); + await new Promise(resolve => socket.on("close", resolve)); + fault.clear(); + events.push(stalled.socket.writableNeedDrain && !stalled.socket.destroyed ? "stalled" : "not stalled"); + console.log(events.join()); + } + process.exit(0); + })(); + `; + try { + await using proc = Bun.spawn({ cmd: [bunExe(), "-e", fixture], env: bunEnv, stderr: "inherit", stdout: "pipe" }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect({ client: stdout.trim().split("\n"), received, exitCode }).toEqual({ + client: ["end,stalled", "end,stalled", "end,stalled"], + received: ["HELLO", "HELLO", "HELLO"], + exitCode: 0, + }); + } finally { + peer.close(); + } + }); +}); + describe.skipIf(skip)("node:tls seeded syscall fuzz", () => { const seed = Number(process.env.BUN_SOCKET_FUZZ_SEED ?? 0x7a1c) >>> 0 || 1; function makePrng(s: number) { From 711e6c8e1df7a23cb6674655bf94618679978e07 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Tue, 6 Oct 2026 08:46:41 +0000 Subject: [PATCH 144/174] test: two tests that depended on timing The relay of node-tls-wrapped-socket-close acted on its end of a connection before the 'connection' event had delivered it (Node.js on Windows). The proxy tunnel memory test gave up after 100 requests, which the release ASAN build makes before a collected body is back with the system: it polls to a deadline now. --- test/js/bun/http/proxy.test.ts | 3 ++- test/js/node/tls/node-tls-wrapped-socket-close.test.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 106f8ba0c64d..05aa7d9c80ad 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -1236,7 +1236,8 @@ test("a pooled HTTPS proxy tunnel does not keep the memory of a large request bo const before = process.memoryUsage.rss(); await post(${bodyMiB * MiB}); let kept = Infinity; - for (let attempt = 0; attempt < 100 && kept > ${(bodyMiB / 4) * MiB}; attempt++) { + // The body itself goes back to the system a few hundred ms after it is collected, however many requests that is. + for (const deadline = performance.now() + 2500; performance.now() < deadline && kept > ${(bodyMiB / 4) * MiB}; ) { await post(1024); Bun.gc(true); kept = Math.min(kept, process.memoryUsage.rss() - before); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index a6fbf77c84a9..0caa85badc82 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -825,6 +825,8 @@ describe("an error of the wrapped socket is reported on the TLS socket", () => { const result = Promise.withResolvers<{ added: number; events: string[] }>(); const queued = transport === "a socket with a write still queued"; const relayEnds: Record = {}; + // A dialed socket can hear of its 'connect' before the relay hears of the 'connection'. + const relayed = Promise.withResolvers(); function wrap(isServer: boolean, raw: net.Socket) { return isServer @@ -854,7 +856,7 @@ describe("an error of the wrapped socket is reported on the TLS socket", () => { act(raw, relayEnds[side]); result.resolve(Promise.all(closed).then(() => ({ added, events }))); }; - const soon = () => void setImmediate(run); + const soon = () => void relayed.promise.then(() => setImmediate(run)); if (when === "after the handshake") tlsSocket.once(side === "server" ? "secure" : "secureConnect", soon); else soon(); } @@ -874,6 +876,7 @@ describe("an error of the wrapped socket is reported on the TLS socket", () => { relayEnds.client = quiet(client); relayEnds.server = quiet(net.connect({ port, host: "127.0.0.1", allowHalfOpen: true })); client.pipe(relayEnds.server).pipe(client); + relayed.resolve(); }); const dial = { port: await listen(relay), host: "127.0.0.1", rejectUnauthorized: false }; const dialed: net.Socket = overTLS ? tls.connect(dial) : net.connect(dial); From c9df1f0435a782b83b9df66e5ba92cedecbe5a18 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 7 Oct 2026 04:42:48 +0000 Subject: [PATCH 145/174] test(tls): the peer of a failed handshake over SSLWrapper gets the alert, from more entry points An http2 server fed through emit("connection"), a server wrap made by new TLSSocket(duplex, { isServer }) and by tls.Server.emit("connection", duplex), a client wrap, a transport that destroys the socket from inside the write of the alert, fetch and WebSocket through a CONNECT proxy, and a client over a Windows named pipe. Offered in the review of this pull request. --- test/js/bun/http/proxy.test.ts | 22 +++++ .../js/node/http2/node-http2-upgrade.test.mts | 27 +++++ test/js/node/tls/node-tls-connect.test.ts | 99 +++++++++++++++++++ test/js/node/tls/node-tls-namedpipes.test.ts | 14 +++ test/js/node/tls/tls-handshake-alert-utils.ts | 63 ++++++++++++ test/js/web/websocket/websocket-proxy.test.ts | 15 +++ 6 files changed, 240 insertions(+) create mode 100644 test/js/node/tls/tls-handshake-alert-utils.ts diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 05aa7d9c80ad..2e903c86a8f3 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -8,6 +8,7 @@ import { once } from "node:events"; import http from "node:http"; import net from "node:net"; import tls from "node:tls"; +import { decodeErrorAlert, startMalformedServerHelloServer } from "../../node/tls/tls-handshake-alert-utils"; import { createAdversarialProxy, deadPort, proxyFreeEnv } from "./proxy-stress-helpers"; async function createProxyServer(is_tls: boolean) { const serverArgs: any[] = []; @@ -1149,6 +1150,27 @@ test("HTTPS target through proxy with rejecting checkServerIdentity transmits no } }); +// The client resets the proxy connection right after it writes the alert. On +// Windows a reset discards the data the proxy has not read yet. +test.skipIf(isWindows)( + "HTTPS target through proxy gets the client's TLS alert when the tunneled handshake fails", + async () => { + using target = await startMalformedServerHelloServer(); + const result = await fetch(`https://localhost:${target.port}/`, { + proxy: httpProxyServer.url, + keepalive: false, + tls: { rejectUnauthorized: false }, + }).then( + () => "resolved", + (err: NodeJS.ErrnoException) => `rejected:${err.code}`, + ); + expect({ result, targetReceived: await target.afterClientHello }).toEqual({ + result: "rejected:EPROTO", + targetReceived: decodeErrorAlert, + }); + }, +); + test("HTTPS over HTTP proxy preserves TLS record order with large bodies", async () => { // Create a custom HTTPS server that returns body size for this test using customServer = Bun.serve({ diff --git a/test/js/node/http2/node-http2-upgrade.test.mts b/test/js/node/http2/node-http2-upgrade.test.mts index b7baaa594557..178437814ae8 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -300,6 +300,33 @@ describe("HTTP/2 upgrade — ALPN negotiation", () => { client.close(); srv.netServer.close(); }); + + test("a client that offers no protocol the server speaks gets the server's alert, not a reset", async () => { + const h2Server = http2.createSecureServer(TLS); + h2Server.on("error", () => {}); + h2Server.on("tlsClientError", () => {}); + const netServer = net.createServer(socket => { + socket.on("error", () => {}); + h2Server.emit("connection", socket); + }); + await once(netServer.listen(0, "127.0.0.1"), "listening"); + const port = (netServer.address() as net.AddressInfo).port; + + // The server speaks only h2. It fails the handshake with a + // no_application_protocol alert. + const client = tls.connect({ host: "127.0.0.1", port, rejectUnauthorized: false, ALPNProtocols: ["xyz"] }); + try { + const outcome = await new Promise(resolve => { + client.on("secureConnect", () => resolve("secureConnect")); + client.on("error", (err: NodeJS.ErrnoException) => resolve(`error:${err.code}`)); + client.on("close", () => resolve("close")); + }); + assert.strictEqual(outcome, "error:ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL"); + } finally { + client.destroy(); + netServer.close(); + } + }); }); describe("HTTP/2 upgrade — varied status codes", () => { diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 51d7bd54ae03..a1e2679fea48 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -21,6 +21,7 @@ import { join } from "path"; import stream from "stream"; import tls, { checkServerIdentity, connect as tlsConnect, TLSSocket } from "tls"; import { promisify } from "util"; +import { decodeErrorAlert, startMalformedServerHelloServer, tlsRecords } from "./tls-handshake-alert-utils"; import type { AddressInfo } from "net"; import { Duplex } from "node:stream"; @@ -2797,6 +2798,104 @@ describe("a TLS server wrap over a Duplex transport whose peer ends the handshak }); }); +describe("a TLS socket over a Duplex transport sends its fatal alert when the handshake fails", () => { + // Node writes the alert to the stream before it reports the error: + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L899-L905 + + // A Duplex in front of a TCP socket, so the other end of the handshake is a real remote peer. + const overTcp = (socket: net.Socket) => { + const transport = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + socket.write(chunk, encoding, callback); + }, + final(callback) { + socket.end(); + callback(); + }, + }); + socket.on("data", chunk => transport.push(chunk)); + socket.on("end", () => transport.push(null)); + socket.on("error", () => {}); + transport.on("close", () => socket.destroy()); + return transport; + }; + + it.each(["TLSSocket", "tls.Server"])("a server wrap made by %s: the client gets the alert, not a reset", async by => { + // The server speaks only h2 and the client offers only xyz. The server + // fails the handshake with a no_application_protocol alert. + const options = { ...COMMON_CERT_, ALPNProtocols: ["h2"] }; + const server = tls.createServer(options); + server.on("tlsClientError", () => {}); + await using listener = net.createServer(socket => { + if (by === "tls.Server") { + server.emit("connection", overTcp(socket)); + } else { + new TLSSocket(overTcp(socket), { isServer: true, ...options }).on("error", () => {}); + } + }); + await once(listener.listen(0, "127.0.0.1"), "listening"); + const { port } = listener.address() as AddressInfo; + + const client = tls.connect({ host: "127.0.0.1", port, rejectUnauthorized: false, ALPNProtocols: ["xyz"] }); + try { + const outcome = await new Promise(resolve => { + client.on("secureConnect", () => resolve("secureConnect")); + client.on("error", (err: NodeJS.ErrnoException) => resolve(`error:${err.code}`)); + client.on("close", () => resolve("close")); + }); + expect(outcome).toBe("error:ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL"); + } finally { + client.destroy(); + } + }); + + it("a client wrap: the server gets the alert", async () => { + using server = await startMalformedServerHelloServer(); + const transport = overTcp(net.connect(server.port, "127.0.0.1")); + const client = tls.connect({ socket: transport, rejectUnauthorized: false }); + client.on("error", () => {}); + try { + expect(await server.afterClientHello).toEqual(decodeErrorAlert); + } finally { + client.destroy(); + } + }); + + it("a transport that destroys the socket from inside the alert's write gets a plain close", async () => { + const events: string[] = []; + const settled = Promise.withResolvers(); + const transport = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + events.push(`write:${JSON.stringify(tlsRecords(chunk))}`); + wrapped.destroy(); + callback(); + }, + }); + const wrapped = new TLSSocket(transport, { isServer: true, secureContext: tls.createSecureContext(COMMON_CERT_) }); + wrapped.on("secure", () => { + events.push("secure"); + settled.resolve(); + }); + wrapped.on("error", (err: NodeJS.ErrnoException) => { + events.push(`error:${err.code}`); + settled.resolve(); + }); + wrapped.on("close", hadError => { + events.push(`close:${hadError}`); + settled.resolve(); + }); + // An application data record where the ClientHello belongs. A TLS server + // answers it with a fatal unexpected_message (10) alert. + transport.push(Buffer.from([0x17, 0x03, 0x03, 0x00, 0x01, 0x00])); + + await settled.promise; + // The socket was destroyed with no error before the failure could be reported. + expect(events).toEqual([`write:[{"type":21,"body":"020a"}]`, "close:false"]); + }); +}); + it("delivers 'session' even when the data handler destroys the socket immediately", async () => { // The TLS1.3 NewSessionTickets ride in the same read pass as the response // bytes. If the parked session were only flushed after the data dispatch, diff --git a/test/js/node/tls/node-tls-namedpipes.test.ts b/test/js/node/tls/node-tls-namedpipes.test.ts index 689da3e185b5..0979a8c69880 100644 --- a/test/js/node/tls/node-tls-namedpipes.test.ts +++ b/test/js/node/tls/node-tls-namedpipes.test.ts @@ -6,6 +6,7 @@ import { readFileSync } from "node:fs"; import net from "node:net"; import { join } from "node:path"; import { connect, createServer, TLSSocket } from "node:tls"; +import { decodeErrorAlert, startMalformedServerHelloServer } from "./tls-handshake-alert-utils"; it.if(isWindows)("should work with named pipes and tls", async () => { await expectMaxObjectTypeCount(expect, "TLSSocket", 0); @@ -245,6 +246,19 @@ describe("a peer that ends the handshake with close_notify over a named pipe", ( }); }); +it.if(isWindows)("a client over a named pipe sends its fatal alert when the handshake fails", async () => { + // Same contract as the Duplex transport test in node-tls-connect.test.ts. + const pipeName = `\\\\.\\pipe\\test\\${randomUUID()}`; + using server = await startMalformedServerHelloServer(pipeName); + const client = connect({ path: pipeName, rejectUnauthorized: false }); + client.on("error", () => {}); + try { + expect(await server.afterClientHello).toEqual(decodeErrorAlert); + } finally { + client.destroy(); + } +}); + it.if(isWindows)("setSecureContext() rotates the certificate of a server listening on a named pipe", async () => { const fixture = (name: string) => readFileSync(join(import.meta.dir, "fixtures", name), "utf8"); const agent1 = { key: fixture("agent1-key.pem"), cert: fixture("agent1-cert.pem") }; diff --git a/test/js/node/tls/tls-handshake-alert-utils.ts b/test/js/node/tls/tls-handshake-alert-utils.ts new file mode 100644 index 000000000000..d985324388ea --- /dev/null +++ b/test/js/node/tls/tls-handshake-alert-utils.ts @@ -0,0 +1,63 @@ +/** + * Shared by the tests that check which TLS alert a client sends when its + * handshake fails: node-tls-connect.test.ts, node-tls-namedpipes.test.ts, + * proxy.test.ts and websocket-proxy.test.ts. + */ + +import { once } from "node:events"; +import net from "node:net"; + +export interface TLSRecord { + type: number; + /** The record body, as hex. */ + body: string; +} + +/** The complete TLS records in `bytes`. */ +export function tlsRecords(bytes: Buffer): TLSRecord[] { + const records: TLSRecord[] = []; + for (let rest = bytes; rest.length >= 5 && rest.length >= 5 + rest.readUInt16BE(3); ) { + const end = 5 + rest.readUInt16BE(3); + records.push({ type: rest[0], body: rest.subarray(5, end).toString("hex") }); + rest = rest.subarray(end); + } + return records; +} + +/** What a TLS client sends after `startMalformedServerHelloServer` answered it: one alert record (21), level fatal (2), description decode_error (50). */ +export const decodeErrorAlert: TLSRecord[] = [{ type: 21, body: "0232" }]; + +/** + * Starts a server that answers the ClientHello of each connection with a + * handshake record whose ServerHello has a one-byte body. Both BoringSSL and + * OpenSSL clients fail the handshake on it with a fatal decode_error alert. + * + * `afterClientHello` resolves when the first connection closes, with the + * records the client sent after its ClientHello. Listens on `path` (a named + * pipe or a unix socket) when given, on a 127.0.0.1 port otherwise. + */ +export async function startMalformedServerHelloServer(path?: string) { + const malformedServerHello = Buffer.from([0x16, 0x03, 0x03, 0x00, 0x05, 0x02, 0x00, 0x00, 0x01, 0x00]); + const afterClientHello = Promise.withResolvers(); + const server = net.createServer(socket => { + let received = Buffer.alloc(0); + let answered = false; + socket.on("data", chunk => { + received = Buffer.concat([received, chunk]); + if (!answered && tlsRecords(received).length > 0) { + answered = true; + socket.write(malformedServerHello); + } + }); + socket.on("error", () => {}); + socket.on("close", () => afterClientHello.resolve(tlsRecords(received).slice(1))); + }); + await once(path === undefined ? server.listen(0, "127.0.0.1") : server.listen(path), "listening"); + return { + port: path === undefined ? (server.address() as net.AddressInfo).port : 0, + afterClientHello: afterClientHello.promise, + [Symbol.dispose]() { + server.close(); + }, + }; +} diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 2b4098a7e29b..e5675c1f37dd 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -7,6 +7,7 @@ import { createHash } from "node:crypto"; import { once } from "node:events"; import net, { type AddressInfo } from "node:net"; import tls from "node:tls"; +import { decodeErrorAlert, startMalformedServerHelloServer } from "../../node/tls/tls-handshake-alert-utils"; import { type ClientEvent, clientEvents, @@ -350,6 +351,20 @@ describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => { gc(); }); + test("the target gets the client's TLS alert when the tunneled handshake fails", async () => { + using target = await startMalformedServerHelloServer(); + using recorded = await startRecordingProxy(); + const url = `wss://127.0.0.1:${target.port}`; + const ws = new WebSocket(url, { + proxy: `http://127.0.0.1:${recorded.port}`, + tls: { rejectUnauthorized: false }, + }); + expect({ events: await failingSession(ws), targetReceived: await target.afterClientHello }).toEqual({ + events: failed(url, "TLS handshake failed", 1015), + targetReceived: decodeErrorAlert, + }); + }); + test("server-initiated ping survives through TLS tunnel proxy", async () => { // Regression test: sendPong checked socket.isClosed() on the detached tcp // field instead of using hasTCP(). For wss:// through HTTP proxy, the From c9207c87f1fe49d70988d278972acd6ae92d5a5d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 7 Oct 2026 05:53:10 +0000 Subject: [PATCH 146/174] test(tls): room for the sockets that linger in the Duplex teardown leak test Up to 3 of the latest native TLS sockets stay reachable for a while, however many were made: never more than 3 over 1,200 sockets in a fresh process, on main as well. The test allowed 2 of 20, and failed 3 of 5 attempts on the Linux x64 lanes with 3. It now allows 5 of 40. Without the pending_close that it guards, all 40 stay. --- test/js/node/tls/node-tls-connect.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index a1e2679fea48..32175d751fb6 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -2146,9 +2146,10 @@ describe("a TLS socket over a Duplex transport follows that transport's teardown const nativeSockets = () => heapStats().objectTypeCounts.TLSSocket || 0; Bun.gc(true); const baseline = nativeSockets(); - const count = 20; - // Well under the 10 of each kind, so a leak of every client or of every server wrap fails. - const bound = 2; + const count = 40; + // Up to 3 of the latest sockets stay reachable for a while, however many were made (3 of 1,200). Well under + // the 20 of each kind, so a leak of every client or of every server wrap fails. + const bound = 5; await (async () => { const closes: Promise[] = []; for (let i = 0; i < count; i++) { From 1ca1ae114411cdf8d6bfd9106516670f48f55084 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 7 Oct 2026 22:45:20 +0000 Subject: [PATCH 147/174] Revert "node:tls: say that a write over a Duplex completes a tick later, not as node's immediate" This reverts commit aae25503db658074358a3d0b48bb090883e666a1. --- src/runtime/socket/UpgradedDuplex.rs | 2 +- test/js/node/tls/node-tls-connect.test.ts | 22 ---------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 4ee6b9116e84..71fe9f51423c 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -821,7 +821,7 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { Ok(JSValue::UNDEFINED) } -/// `(undefined, err)` is node's `done(err)` of `doWrite`, but queues `finishWrite(errno)`, itself, for the next tick: a close overtakes an immediate. +/// `(undefined, err)` is node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. #[bun_jsc::host_fn] fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { bun_output::scoped_log!(UpgradedDuplex, "onWriteDone"); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 32175d751fb6..4dd084256b3c 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -6639,28 +6639,6 @@ describe("a write on a TLS socket over a Duplex completes when the Duplex has co }, ); - // Node completes a write in a setImmediate(): https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L218 - it.todo("writes that each wait for the one before let a timer fire", async () => { - const pair = makePair("client"); - using sockets = await connectPair(pair, "client"); - const { writer, reader } = sockets; - reader.resume(); - const limit = 2000; - let writes = 0; - let fired = false; - const timer = setTimeout(() => (fired = true), 1); - const { promise, resolve, reject } = Promise.withResolvers(); - writer.on("error", reject); - (function pump(err?: Error | null) { - if (err) reject(err); - else if (fired || ++writes > limit) resolve(); - else writer.write("x", pump); - })(); - await promise; - clearTimeout(timer); - expect(fired).toBe(true); - }); - describe.each(["client", "server"] as const)("a %s write in flight", side => { it.each(["destroy()", "destroy(err)", "the destroy() of the Duplex"])( "fails at %s, and a Duplex that completes it afterwards changes nothing", From d0c5057c271a8e25dc3dbb4d865dfd3b6f65d293 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 7 Oct 2026 22:45:45 +0000 Subject: [PATCH 148/174] Revert "node:tls: what a wrapped Duplex passes to its write callback only says whether the write failed" This reverts commit 7d401405f27d3a2a15ff74c549cb14576f23e60d. --- src/runtime/socket/UpgradedDuplex.rs | 49 ++++--------- test/js/node/tls/node-tls-connect.test.ts | 83 ----------------------- 2 files changed, 15 insertions(+), 117 deletions(-) diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 71fe9f51423c..c5717b13f7f9 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -44,7 +44,7 @@ pub(crate) struct UpgradedDuplex { pub on_data_callback: Cell, pub on_end_callback: Cell, pub on_close_callback: Cell, - /// What the `cb` of every `origin.write(chunk, cb)` and `origin.end(null, cb)` is bound to. + /// The `cb` of every `origin.write(chunk, cb)` and `origin.end(null, cb)`. pub on_write_done_callback: Cell, /// Those calls whose `cb` has not run. pub in_flight: Cell, @@ -328,13 +328,14 @@ impl UpgradedDuplex { None => JSValue::NULL, }; chunk.ensure_still_alive(); - let done = match self.write_done_callback(&global) { - Ok(done) => done, - Err(err) => { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); - return; - } - }; + let done = lazy_js_handler( + &self.on_write_done_callback, + self.js_wrapper, + js_TLSSocket::duplex_on_write_done_set_cached, + &global, + __jsc_host_on_write_done, + std::ptr::from_ref(self).cast_mut().cast::(), + ); self.in_flight.set(self.in_flight.get() + 1); if let Err(err) = write_or_end.call(&global, duplex, &[chunk, done]) { @@ -342,26 +343,6 @@ impl UpgradedDuplex { } } - /// `on_write_done` with its first argument bound, so that no stream can choose it. - fn write_done_callback(&self, global: &JSGlobalObject) -> JsResult { - if let Some(done) = js_TLSSocket::duplex_on_write_done_get_cached(self.js_wrapper) { - return Ok(done); - } - let target = host_fn::new_function_with_data( - global, - None, - 0, - __jsc_host_on_write_done, - std::ptr::from_ref(self).cast_mut().cast::(), - ); - let no_errno = [JSValue::UNDEFINED]; - let name = bun_core::String::EMPTY; - let done = target.bind(global, JSValue::UNDEFINED, &name, 1.0, &no_errno)?; - js_TLSSocket::duplex_on_write_done_set_cached(self.js_wrapper, global, done); - self.on_write_done_callback.set(target); - Ok(done) - } - /// The wrapped stream has completed every write it was handed. #[uws_callback(export = "UpgradedDuplex__transport_idle", no_catch)] pub(crate) fn transport_idle(&self) -> bool { @@ -821,19 +802,19 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { Ok(JSValue::UNDEFINED) } -/// `(undefined, err)` is node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. +/// Node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. #[bun_jsc::host_fn] fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { bun_output::scoped_log!(UpgradedDuplex, "onWriteDone"); let function = frame.callee(); - let [errno, err] = frame.arguments_as_array::<2>(); + let [arg] = frame.arguments_as_array::<1>(); if let Some(self_ptr) = host_fn::get_function_data(function) { // SAFETY: see host-fn note above. let this = unsafe { &*self_ptr.cast::() }; - if errno.is_number() { - let errno = errno.to_int32(); + if arg.is_number() { + let errno = arg.to_int32(); if errno != 0 { let err = bun_sys::Error::from_code_int(errno, bun_sys::Tag::write); let mut err = ::to_system_error(&err); @@ -848,8 +829,8 @@ fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult new Promise(resolve => setImmediate(resolve)); - const shape = err => err && [err.name, err.message, err.code, util.getSystemErrorName(err.errno), err.syscall].join(" | "); - async function run(value) { - let stalled = false, held, chunk; - class Side extends Duplex { - _read() {} - _write(data, encoding, callback) { - if (stalled && this === clientSide) return void ([held, chunk] = [callback, data]); - this.peer.push(data); - callback(); - } - _final(callback) { - this.peer.push(null); - callback(); - } - } - const clientSide = new Side(), serverSide = new Side(); - clientSide.peer = serverSide; - serverSide.peer = clientSide; - // Not through stream.Writable, which hands a falsy value on as null. - if (process.argv.at(-1) === "write()") { - clientSide.write = (data, encoding, callback) => (clientSide._write(data, null, callback ?? encoding), true); - } - clientSide.on("error", () => {}); - const server = new tls.TLSSocket(serverSide, { isServer: true, key: process.env.TLS_KEY, cert: process.env.TLS_CERT }); - const client = tls.connect({ socket: clientSide, rejectUnauthorized: false }); - server.on("error", () => {}); - let received = ""; - server.on("data", data => (received += data)); - const ended = once(server, "end"); - const log = []; - // Node's tls.connect() also forwards the error of the Duplex. - client.on("error", err => err?.syscall && log.push("'error': " + shape(err))); - const closed = new Promise(resolve => client.on("close", resolve)); - await once(client, "secureConnect"); - await turn(); - stalled = true; - const written = new Promise(resolve => client.write("a", resolve)); - await turn(); - await turn(); - stalled = false; - if (!value) serverSide.push(chunk); - held(value); - const err = await written; - log.unshift("write callback: " + shape(err)); - if (err) await closed; - else { - log.push("end callback: " + ((await new Promise(resolve => client.end("b", resolve))) ?? null)); - await ended; - log.push("received " + received); - } - await turn(); - client.destroy(); - server.destroy(); - return log.join(", "); - } - (async () => { - for (const value of values) console.log(await run(value)); - })(); - `; - it.each(["_write()", "write()"])( - "what the %s of the Duplex passes to the write callback only says whether the write failed", - async form => { - const { stdout } = await promisify(execFile)(bunExe(), ["-e", writeCallbackValues, form], { - env: { ...bunEnv, TLS_KEY: COMMON_CERT_.key, TLS_CERT: COMMON_CERT_.cert }, - }); - const failed = (code: string) => - `write callback: Error | write ${code} | ${code} | ${code} | write, 'error': Error | write ${code} | ${code} | ${code} | write`; - const completed = "write callback: null, end callback: null, received ab"; - expect(stdout.trim().split("\n")).toEqual([ - ...Array(5).fill(failed("EPIPE")), - failed("ECONNRESET"), - ...Array(3).fill(completed), - ]); - }, - ); - describe.each(["client", "server"] as const)("a %s write in flight", side => { it.each(["destroy()", "destroy(err)", "the destroy() of the Duplex"])( "fails at %s, and a Duplex that completes it afterwards changes nothing", From 149d8a82ada07792c1feb80a6410a94125f91d8f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Wed, 7 Oct 2026 22:46:14 +0000 Subject: [PATCH 149/174] Revert "node:tls: a write over a Duplex completes when the Duplex has completed it (#43877, #44394)" This reverts commit 9c2a439f42fc3b12a35f5e02a4da53ffe36dc261. --- src/js/node/_http2_upgrade.ts | 13 +- src/js/node/net.ts | 3 +- src/runtime/socket/UpgradedDuplex.rs | 115 ++---- src/runtime/socket/socket_body.rs | 11 +- src/runtime/socket/sockets.classes.ts | 4 +- src/uws_sys/lib.rs | 5 - src/uws_sys/socket.rs | 8 - test/js/node/tls/node-tls-connect.test.ts | 443 ---------------------- test/js/node/tls/renegotiation.test.ts | 35 -- 9 files changed, 51 insertions(+), 586 deletions(-) diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index 172986418e4f..ce5567d5588a 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -10,7 +10,7 @@ interface NativeHandle { } type UpgradeEventListener = (...args: any[]) => void; -type UpgradeEvents = [UpgradeEventListener, UpgradeEventListener, UpgradeEventListener]; +type UpgradeEvents = [UpgradeEventListener, UpgradeEventListener, UpgradeEventListener, UpgradeEventListener]; interface UpgradeContextType { connectionListener: (...args: any[]) => any; @@ -265,7 +265,8 @@ function onTlsClose(this: TLSProxySocket) { if (!ev) return; raw.removeListener("data", ev[0]); raw.removeListener("end", ev[1]); - raw.removeListener("close", ev[2]); + raw.removeListener("drain", ev[2]); + raw.removeListener("close", ev[3]); } // --------------------------------------------------------------------------- @@ -340,7 +341,7 @@ function upgradeRawSocketToH2( try { // upgradeDuplexToTLS wraps rawSocket with a TLS layer in server mode (isServer: true). // The native side will: - // 1. Read encrypted data from rawSocket via events[0..2] + // 1. Read encrypted data from rawSocket via events[0..3] // 2. Decrypt it through the TLS engine (with ALPN negotiation for "h2") // 3. Call our socket callbacks below with the decrypted plaintext // @@ -384,10 +385,12 @@ function upgradeRawSocketToH2( tlsSocket._ctx.events = events; // Wire up the raw TCP socket to feed encrypted data into the TLS layer. - // events[0..2] are the native TLS engine's listeners for data, end and close. + // events[0..3] are native event handlers returned by upgradeDuplexToTLS that + // the native TLS engine expects to receive data/end/drain/close through. rawSocket.on("data", events[0]); rawSocket.on("end", events[1]); - rawSocket.on("close", events[2]); + rawSocket.on("drain", events[2]); + rawSocket.on("close", events[3]); // When the TLS socket closes (e.g. H2 session destroyed), clean up the raw socket // listeners to prevent memory leaks and stale callback references. diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 3eec093e6139..3ae8edf24a33 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -477,7 +477,8 @@ function attachTLSEngine(self, connection, options) { const [handle, events] = upgradeDuplexToTLS(connection, options); connection.on("data", events[0]); connection.on("end", onUpgradedEnd.bind(null, connection, events[1])); - connection.on("close", events[2]); + connection.on("drain", events[2]); + connection.on("close", events[3]); connection.once(kReadError, onUpgradedReadError.bind(null, self)); // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L117-L120 connection.resume(); diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index c5717b13f7f9..2f5e3925eed3 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -17,7 +17,6 @@ use core::ffi::{CStr, c_uint, c_void}; use bun_jsc::virtual_machine::VirtualMachine; use bun_jsc::{CallFrame, GlobalRef, JSGlobalObject, JSValue, JsCell, JsResult, host_fn}; -use bun_sys::SystemErrno; use bun_uws::{us_bun_verify_error_t, uws_callback}; use super::ssl_wrapper::SSLWrapper; @@ -29,7 +28,7 @@ bun_output::declare_scope!(UpgradedDuplex, visible); pub(crate) struct UpgradedDuplex { pub wrapper: JsCell>, /// The owning `JSTLSSocket` wrapper. Its `values:` slots root `origin` and - /// the four native thunks; the `JSValue` fields below are read-side + /// the four listener thunks; the `JSValue` fields below are read-side /// shadows of those slots. pub js_wrapper: JSValue, pub origin: Cell, // any duplex @@ -43,11 +42,8 @@ pub(crate) struct UpgradedDuplex { pub handlers: Handlers, pub on_data_callback: Cell, pub on_end_callback: Cell, + pub on_writable_callback: Cell, pub on_close_callback: Cell, - /// The `cb` of every `origin.write(chunk, cb)` and `origin.end(null, cb)`. - pub on_write_done_callback: Cell, - /// Those calls whose `cb` has not run. - pub in_flight: Cell, pub event_loop_timer: JsCell, pub current_timeout: Cell, /// Transport bytes that arrived before the TLS engine existed. @@ -116,7 +112,7 @@ use crate::jsc_hooks::timer_all_mut as timer_all; /// Lazily create-and-cache a JS host-function callback in `shadow`, mirrored /// into the owning `JSTLSSocket` wrapper's visited `values:` slot (the GC -/// root). All four slots follow the identical +/// root). All four `get_js_handlers` slots follow the identical /// `NewFunctionWithData(global, null, 0, fn, self)` → `ensureStillAlive` → /// `setFunctionData(self)` → store pattern. #[inline] @@ -317,38 +313,26 @@ impl UpgradedDuplex { } }; - let chunk = match data { - Some(data) => match bun_jsc::array_buffer::BinaryType::Buffer.to_js(data, &global) { + if let Some(data) = data { + let buffer = match bun_jsc::array_buffer::BinaryType::Buffer.to_js(data, &global) { Ok(b) => b, Err(err) => { (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); return; } - }, - None => JSValue::NULL, - }; - chunk.ensure_still_alive(); - let done = lazy_js_handler( - &self.on_write_done_callback, - self.js_wrapper, - js_TLSSocket::duplex_on_write_done_set_cached, - &global, - __jsc_host_on_write_done, - std::ptr::from_ref(self).cast_mut().cast::(), - ); + }; + buffer.ensure_still_alive(); - self.in_flight.set(self.in_flight.get() + 1); - if let Err(err) = write_or_end.call(&global, duplex, &[chunk, done]) { - (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + if let Err(err) = write_or_end.call(&global, duplex, &[buffer]) { + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + } + } else { + if let Err(err) = write_or_end.call(&global, duplex, &[JSValue::NULL]) { + (self.handlers.on_error)(self.handlers.ctx, global.take_error(err)); + } } } - /// The wrapped stream has completed every write it was handed. - #[uws_callback(export = "UpgradedDuplex__transport_idle", no_catch)] - pub(crate) fn transport_idle(&self) -> bool { - self.in_flight.get() == 0 - } - /// Not the 'end' event, which a paused transport holds back. No public property tells. fn transport_got_eof(duplex: JSValue, global: &JSGlobalObject) -> JsResult { let ended = match duplex.get(global, "_readableState")? { @@ -460,9 +444,8 @@ impl UpgradedDuplex { ssl_error: JsCell::new(CertError::default()), on_data_callback: Cell::new(JSValue::ZERO), on_end_callback: Cell::new(JSValue::ZERO), + on_writable_callback: Cell::new(JSValue::ZERO), on_close_callback: Cell::new(JSValue::ZERO), - on_write_done_callback: Cell::new(JSValue::ZERO), - in_flight: Cell::new(0), event_loop_timer: JsCell::new(EventLoopTimer::init_paused( EventLoopTimerTag::UpgradedDuplex, )), @@ -474,7 +457,7 @@ impl UpgradedDuplex { } pub(crate) fn get_js_handlers(&self, global: &JSGlobalObject) -> JsResult { - let array = JSValue::create_empty_array(global, 3)?; + let array = JSValue::create_empty_array(global, 4)?; array.ensure_still_alive(); let this_ptr = std::ptr::from_ref(self).cast_mut().cast::(); @@ -506,6 +489,18 @@ impl UpgradedDuplex { array.put_index( global, 2, + lazy_js_handler( + &self.on_writable_callback, + js_wrapper, + js_TLSSocket::duplex_on_writable_set_cached, + global, + __jsc_host_on_writable, + this_ptr, + ), + )?; + array.put_index( + global, + 3, lazy_js_handler( &self.on_close_callback, js_wrapper, @@ -724,8 +719,8 @@ impl UpgradedDuplex { for cb in [ &self.on_data_callback, &self.on_end_callback, + &self.on_writable_callback, &self.on_close_callback, - &self.on_write_done_callback, ] { let value = cb.get(); if !value.is_empty() { @@ -733,7 +728,6 @@ impl UpgradedDuplex { cb.set(JSValue::ZERO); } } - self.in_flight.set(0); self.ssl_error.set(CertError::default()); self.pending_data.set(Vec::new()); self.pending_close.set(false); @@ -748,7 +742,7 @@ impl Drop for UpgradedDuplex { } // SAFETY (all four host fns): the function data is the `*mut UpgradedDuplex` -// installed by `lazy_js_handler`; `teardown` clears it before the storage is +// installed by `get_js_handlers`; `teardown` clears it before the storage is // freed, so a non-null data pointer is live for the call. #[bun_jsc::host_fn] @@ -802,61 +796,24 @@ fn on_end(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { Ok(JSValue::UNDEFINED) } -/// Node's `done(err)` of `doWrite`. It calls itself on the next tick, with the errno for `finishWrite`. #[bun_jsc::host_fn] -fn on_write_done(global: &JSGlobalObject, frame: &CallFrame) -> JsResult { - bun_output::scoped_log!(UpgradedDuplex, "onWriteDone"); +fn on_writable(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { + bun_output::scoped_log!(UpgradedDuplex, "onWritable"); let function = frame.callee(); - let [arg] = frame.arguments_as_array::<1>(); if let Some(self_ptr) = host_fn::get_function_data(function) { // SAFETY: see host-fn note above. let this = unsafe { &*self_ptr.cast::() }; - if arg.is_number() { - let errno = arg.to_int32(); - if errno != 0 { - let err = bun_sys::Error::from_code_int(errno, bun_sys::Tag::write); - let mut err = ::to_system_error(&err); - // Node's errnoException(errCode, 'write'). - err.message = bun_core::String::create_format(format_args!("write {}", err.code)); - (this.handlers.on_error)(this.handlers.ctx, err.to_error_instance(global)); - } else if this.in_flight.get() == 0 { - (this.handlers.on_writable)(this.handlers.ctx); - } - return Ok(JSValue::UNDEFINED); - } - let in_flight = this.in_flight.get().saturating_sub(1); - this.in_flight.set(in_flight); - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L157-L159 - if !arg.is_undefined_or_null() && !this.is_shutdown() { - let errno = JSValue::js_number_from_int32(write_errno(global, arg)? as i32); - JSValue::call_next_tick_1(function, global, errno)?; - } else if in_flight == 0 { - JSValue::call_next_tick_1(function, global, JSValue::js_number_from_int32(0))?; - } + // flush pending data + this.flush(); + // call onWritable (will flush on demand) + (this.handlers.on_writable)(this.handlers.ctx); } Ok(JSValue::UNDEFINED) } -/// ``uv[`UV_${err.code}`] || uv.UV_EPIPE`` -fn write_errno(global: &JSGlobalObject, err: JSValue) -> JsResult { - let code = if err.is_object() { - err.get(global, "code")?.filter(|code| code.is_string()) - } else { - None - }; - let Some(code) = code else { - return Ok(SystemErrno::EPIPE); - }; - Ok(core::str::from_utf8(&code.to_utf8(global)?) - .ok() - .and_then(|code| code.parse().ok()) - .filter(|&errno| errno != SystemErrno::SUCCESS) - .unwrap_or(SystemErrno::EPIPE)) -} - #[bun_jsc::host_fn] fn on_close_js(_global: &JSGlobalObject, frame: &CallFrame) -> JsResult { bun_output::scoped_log!(UpgradedDuplex, "onCloseJS"); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 6e48079052f8..c60bbb9f4350 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -2727,9 +2727,7 @@ impl NewSocket { // node:net fails the write like Node's onWriteComplete; // -1 stays the legacy closed/shutdown sentinel. JSValue::js_number(f64::from(wrote)) - } else if usize::try_from(wrote.max(0)).expect("int cast") == total - && this.socket.get().transport_idle() - { + } else if usize::try_from(wrote.max(0)).expect("int cast") == total { JSValue::TRUE } else { JSValue::FALSE @@ -2761,10 +2759,7 @@ impl NewSocket { let _ = this.internal_flush(); } - JSValue::from( - usize::try_from(wrote.max(0)).expect("int cast") == total - && this.socket.get().transport_idle(), - ) + JSValue::from(usize::try_from(wrote.max(0)).expect("int cast") == total) } }; Ok(result) @@ -5000,7 +4995,7 @@ pub(crate) fn js_upgrade_duplex_to_tls( let array = JSValue::create_empty_array(global, 2)?; array.put_index(global, 0, tls_js_value)?; - // data, end and close events must be reported + // data, end, drain and close events must be reported array.put_index( global, 1, diff --git a/src/runtime/socket/sockets.classes.ts b/src/runtime/socket/sockets.classes.ts index c531c79260f3..854e00ffebed 100644 --- a/src/runtime/socket/sockets.classes.ts +++ b/src/runtime/socket/sockets.classes.ts @@ -9,11 +9,11 @@ function generate(ssl) { memoryCost: true, // Visited slot holding the shared JSSocketHandlers cell, so the callbacks // stay alive as long as any socket that can still fire them. The duplex* - // slots carry the origin stream and the four native thunks for a + // slots carry the origin stream and the four native listener thunks for a // TLSSocket driven by an upgraded Duplex (UpgradedDuplex); plain TCP // sockets never populate them. values: ssl - ? ["handlers", "duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnClose", "duplexOnWriteDone"] + ? ["handlers", "duplexOrigin", "duplexOnData", "duplexOnEnd", "duplexOnWritable", "duplexOnClose"] : ["handlers"], proto: { getAuthorizationError: { diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 139e5538ee41..b9dcf45b7fe7 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -222,7 +222,6 @@ unsafe extern "C" { safe fn UpgradedDuplex__is_established(this: &UpgradedDuplex) -> bool; safe fn UpgradedDuplex__is_closed(this: &UpgradedDuplex) -> bool; safe fn UpgradedDuplex__is_shutdown(this: &UpgradedDuplex) -> bool; - safe fn UpgradedDuplex__transport_idle(this: &UpgradedDuplex) -> bool; safe fn UpgradedDuplex__ssl(this: &UpgradedDuplex) -> *mut bun_boringssl_sys::SSL; safe fn UpgradedDuplex__set_inline_reject(this: &UpgradedDuplex); safe fn UpgradedDuplex__latest_session( @@ -261,10 +260,6 @@ impl UpgradedDuplex { UpgradedDuplex__is_shutdown(self) } #[inline] - pub(crate) fn transport_idle(&self) -> bool { - UpgradedDuplex__transport_idle(self) - } - #[inline] pub(crate) fn ssl(&self) -> Option<*mut bun_boringssl_sys::SSL> { let p = UpgradedDuplex__ssl(self); if p.is_null() { None } else { Some(p) } diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index c0fc5e5d5704..bc50a421c102 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -277,14 +277,6 @@ impl NewSocketHandler { ) } - /// False while a wrapped stream has not completed a write that `write` counted. - pub fn transport_idle(&self) -> bool { - match self.socket { - InternalSocket::UpgradedDuplex(d) => duplex(d).transport_idle(), - _ => true, - } - } - pub fn is_established(&self) -> bool { on_socket!(self.socket; connected s => s.is_established(), diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index f9665182dfd0..4ecc327b5df9 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -13,7 +13,6 @@ import { rejectUnauthorizedScope, tempDir, } from "harness"; -import http2 from "http2"; import https from "https"; import net from "net"; import { join } from "path"; @@ -6379,448 +6378,6 @@ describe("over a duplex, 'session' is emitted before the data that followed it o }); }); -describe("a write on a TLS socket over a Duplex completes when the Duplex has completed it", () => { - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/js_stream_socket.js#L174-L224 - type Side = "client" | "server"; - type WriteCallback = (err?: Error | null) => void; - // Two in-memory Duplexes. From `stall()` on, the side named by `stalls` keeps each - // chunk and its write callback in `held` instead of completing the write. - function makePair(stalls: Side) { - const held: [Buffer, WriteCallback][] = []; - let stalled = false; - const makeSide = (peer: () => Duplex, name: Side) => - new Duplex({ - read() {}, - write(chunk: Buffer, _encoding, callback) { - if (stalled && name === stalls) { - held.push([chunk, callback]); - return; - } - peer().push(chunk); - callback(); - }, - final(callback) { - peer().push(null); - callback(); - }, - }); - const clientSide: Duplex = makeSide(() => serverSide, "client"); - const serverSide: Duplex = makeSide(() => clientSide, "server"); - return { - clientSide, - serverSide, - held, - stall: () => void (stalled = true), - release() { - stalled = false; - for (const [chunk, callback] of held.splice(0)) { - (stalls === "server" ? clientSide : serverSide).push(chunk); - callback(); - } - }, - }; - } - const turn = () => new Promise(resolve => setImmediate(resolve)); - // events.once() would reject on 'error'. - const closeOf = (socket: TLSSocket) => new Promise(resolve => socket.once("close", () => resolve())); - - // An established session over `pair`. `writer` is the socket on the side that can stall. - async function connectPair( - pair: { clientSide: Duplex; serverSide: Duplex }, - stalls: Side, - options: tls.ConnectionOptions = {}, - ) { - const server = new TLSSocket(pair.serverSide, { - isServer: true, - secureContext: tls.createSecureContext(COMMON_CERT_), - }); - const client = tls.connect({ socket: pair.clientSide, rejectUnauthorized: false, ...options }); - const [writer, reader] = stalls === "server" ? [server, client] : [client, server]; - const sockets = { - writer, - reader, - received: "", - [Symbol.dispose]() { - client.destroy(); - server.destroy(); - }, - }; - reader.on("data", chunk => (sockets.received += chunk)); - try { - await Promise.all([once(client, "secureConnect"), once(server, "secure")]); - // The session tickets of TLS 1.3 have left the server. - await turn(); - } catch (err) { - sockets[Symbol.dispose](); - throw err; - } - return sockets; - } - - describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s", maxVersion => { - describe.each(["client", "server"] as const)("%s", side => { - const issue = { - "write(data, cb)": (socket: TLSSocket, cb: WriteCallback) => void socket.write("x", cb), - "end(data, cb)": (socket: TLSSocket, cb: WriteCallback) => void socket.end("x", cb), - "end(cb) behind a write": (socket: TLSSocket, cb: WriteCallback) => void (socket.write("x"), socket.end(cb)), - "the last write of a cork()": (socket: TLSSocket, cb: WriteCallback) => { - socket.cork(); - socket.write("x"); - socket.write("", cb); - socket.uncork(); - }, - }; - it.each(Object.keys(issue) as (keyof typeof issue)[])("%s", async how => { - const pair = makePair(side); - using sockets = await connectPair(pair, side, { maxVersion }); - const { promise, resolve, reject } = Promise.withResolvers<{ err: unknown; received: string; held: number }>(); - sockets.writer.on("error", reject); - sockets.reader.on("error", reject); - - pair.stall(); - issue[how](sockets.writer, err => - resolve({ err: err ?? null, received: sockets.received, held: pair.held.length }), - ); - await turn(); - await turn(); - expect(pair.held.length).toBeGreaterThan(0); - pair.release(); - - expect(await promise).toEqual({ err: null, received: "x", held: 0 }); - }); - }); - }); - - it("on a socket of a tls.Server that was handed the Duplex", async () => { - const pair = makePair("server"); - const server = tls.createServer(COMMON_CERT_); - const accepted = once(server, "secureConnection"); - server.emit("connection", pair.serverSide); - const client = tls.connect({ socket: pair.clientSide, rejectUnauthorized: false }); - let received = ""; - client.on("data", chunk => (received += chunk)); - const [socket]: [TLSSocket] = await accepted; - try { - await turn(); - pair.stall(); - const { promise, resolve } = Promise.withResolvers<{ err: unknown; received: string }>(); - socket.write("x", err => resolve({ err: err ?? null, received })); - await turn(); - await turn(); - expect(pair.held.length).toBeGreaterThan(0); - pair.release(); - expect(await promise).toEqual({ err: null, received: "x" }); - } finally { - client.destroy(); - socket.destroy(); - } - }); - - describe.each(["client", "server"] as const)("a %s write that the Duplex fails", side => { - // ``uv[`UV_${err.code}`] || uv.UV_EPIPE`` - it.each([ - [undefined, "EPIPE"], - ["ERR_NOT_AN_ERRNO", "EPIPE"], - ["ECONNRESET", "ECONNRESET"], - ])("with code %p fails with %s, and the Duplex still reports its own error", async (code, expected) => { - const pair = makePair(side); - using sockets = await connectPair(pair, side); - const { writer, reader } = sockets; - const log: string[] = []; - reader.on("error", () => {}); - const transportFailed = once(pair[`${side}Side`], "error"); - const failed = Promise.withResolvers(); - // Node's tls.connect() also forwards the error of the Duplex. - writer.on("error", err => err.message !== "boom" && failed.resolve(err)); - const closed = closeOf(writer); - - pair.stall(); - writer.write("x", err => log.push(`write callback: ${err?.message}`)); - writer.write("y", err => log.push(`queued write callback: ${err?.message}`)); - await turn(); - await turn(); - expect(log).toEqual([]); - pair.held[0][1](Object.assign(new Error("boom"), { code })); - - const err = await failed.promise; - await closed; - expect({ name: err.name, code: err.code, syscall: err.syscall, message: err.message }).toEqual({ - name: "Error", - code: expected, - syscall: "write", - message: `write ${expected}`, - }); - expect(err.errno).toBeLessThan(0); - expect(log).toEqual([`write callback: write ${expected}`, `queued write callback: write ${expected}`]); - expect((await transportFailed)[0].message).toBe("boom"); - }); - }); - - describe.each(["client", "server"] as const)("a %s write in flight", side => { - it.each(["destroy()", "destroy(err)", "the destroy() of the Duplex"])( - "fails at %s, and a Duplex that completes it afterwards changes nothing", - async how => { - const pair = makePair(side); - using sockets = await connectPair(pair, side); - const { writer, reader } = sockets; - const log: string[] = []; - reader.on("error", () => {}); - writer.on("error", err => log.push(`'error': ${err.message}`)); - const closed = closeOf(writer); - - pair.stall(); - writer.write("x", err => log.push(`write callback: ${err ? "failed" : "completed"}`)); - await turn(); - await turn(); - expect(log).toEqual([]); - expect(pair.held.length).toBe(1); - if (how === "destroy()") writer.destroy(); - else if (how === "destroy(err)") writer.destroy(new Error("mine")); - else pair[`${side}Side`].destroy(); - await closed; - await turn(); - const atClose = log.filter(line => line.startsWith("write callback")); - expect(atClose).toEqual(["write callback: failed"]); - - const before = log.length; - for (const [, callback] of pair.held.splice(0)) callback(); - await turn(); - await turn(); - expect(log.length).toBe(before); - }, - ); - }); - - it("end(cb) completes with no error when the Duplex closes before it has taken the close_notify", async () => { - const pair = makePair("server"); - using sockets = await connectPair(pair, "server"); - const { writer, reader } = sockets; - reader.on("error", () => {}); - const log: string[] = []; - writer.on("error", (err: NodeJS.ErrnoException) => log.push(`error ${err.code}`)); - writer.on("finish", () => log.push("finish")); - const closed = closeOf(writer); - - pair.stall(); - writer.end((err?: Error | null) => log.push(`end callback ${err ?? null}`)); - await turn(); - await turn(); - expect(pair.held.length).toBe(1); - pair.serverSide.destroy(); - - await closed; - expect(log).toEqual(["end callback null", "finish"]); - }); - - it("a close_notify that the Duplex fails is no failed write", async () => { - const pair = makePair("server"); - using sockets = await connectPair(pair, "server"); - const { writer, reader } = sockets; - reader.on("error", () => {}); - const log: string[] = []; - pair.serverSide.on("error", err => log.push(`transport 'error': ${err.message}`)); - writer.on("error", err => log.push(`'error': ${err.message}`)); - const closed = new Promise(resolve => - writer.on("close", hadError => resolve(void log.push(`'close': ${hadError}`))), - ); - - pair.stall(); - writer.end(); - await turn(); - await turn(); - expect(pair.held.length).toBe(1); - pair.held[0][1](new Error("boom")); - - await closed; - await turn(); - expect(log).toEqual(["transport 'error': boom", "'close': false"]); - }); - - it("sockets destroyed with a write in flight leave no native socket behind", async () => { - // Bun only: node has no count of native sockets. - const nativeSockets = () => heapStats().objectTypeCounts.TLSSocket || 0; - Bun.gc(true); - const baseline = nativeSockets(); - const count = 10; - // The Duplex of a destroyed socket can keep the callback of its write for as long as it likes. - const held: WriteCallback[] = []; - await (async () => { - for (let i = 0; i < count; i++) { - const side = i % 2 === 0 ? "client" : "server"; - const pair = makePair(side); - using sockets = await connectPair(pair, side); - pair.stall(); - sockets.writer.write("x", () => {}); - await turn(); - held.push(pair.held[0][1]); - } - })(); - let alive = count * 2; - for (let i = 0; i < 10 && alive > 2; i++) { - await turn(); - Bun.gc(true); - alive = nativeSockets() - baseline; - } - expect(held.length).toBe(count); - expect(alive).toBeLessThanOrEqual(2); - }); - - it("end(cb) completes while an http2 session owns the socket", async () => { - const pair = makePair("server"); - const server = http2.createSecureServer(COMMON_CERT_); - server.on("sessionError", () => {}); - server.emit("connection", pair.serverSide); - const client = tls.connect({ socket: pair.clientSide, ALPNProtocols: ["h2"], rejectUnauthorized: false }); - const session = http2.connect("https://localhost", { createConnection: () => client }); - try { - session.on("error", () => {}); - await once(session, "remoteSettings"); - // No reply of the peer can complete it. - pair.stall(); - const log: string[] = []; - const finished = once(client, "finish"); - client.end((err?: Error | null) => log.push(`end callback ${err ?? null}`)); - await finished; - expect(log).toEqual(["end callback null"]); - } finally { - session.destroy(); - client.destroy(); - pair.serverSide.destroy(); - } - }); - - // Not a stream.Writable's write(): that one never runs the callback, or emits 'drain', before it returns. - class SyncSide extends Duplex { - peer!: SyncSide; - writes = 0; - constructor(readonly inWrite: "callback" | "drain and callback") { - super(); - } - _read() {} - write(chunk: Buffer, encoding?: unknown, callback?: unknown): boolean { - this.writes++; - if (this.inWrite === "drain and callback") this.emit("drain"); - this.peer.push(chunk); - (typeof encoding === "function" ? encoding : (callback as WriteCallback | undefined))?.(); - return true; - } - _final(callback: () => void) { - this.peer.push(null); - callback(); - } - } - it.each(["callback", "drain and callback"] as const)( - "a Duplex whose write() runs the %s before it returns gets each byte once", - async inWrite => { - const payload = Buffer.alloc(256 * 1024); - for (let i = 0; i < payload.length; i++) payload[i] = (i * 31 + (i >> 8)) & 0xff; - const serverSide = new SyncSide(inWrite); - const clientSide = new SyncSide(inWrite); - serverSide.peer = clientSide; - clientSide.peer = serverSide; - const server = new TLSSocket(serverSide, { - isServer: true, - secureContext: tls.createSecureContext(COMMON_CERT_), - }); - const client = tls.connect({ socket: clientSide, rejectUnauthorized: false }); - try { - const { promise: ended, resolve, reject } = Promise.withResolvers(); - server.on("error", reject); - client.on("error", reject); - const received: Buffer[] = []; - server.on("data", chunk => received.push(chunk)); - server.on("end", resolve); - // Waits for the handshake, whose completion flushes it. - client.write(payload, () => client.write(payload, () => client.end())); - await ended; - expect(Buffer.concat(received).equals(Buffer.concat([payload, payload]))).toBe(true); - } finally { - client.destroy(); - server.destroy(); - } - }, - ); - - it("a write queued before the handshake goes out once over a Duplex that emits 'drain' in its _write()", async () => { - const payload = Buffer.alloc(256 * 1024); - for (let i = 0; i < payload.length; i++) payload[i] = (i * 31 + (i >> 8)) & 0xff; - const pair = makePair("server"); - const write = pair.clientSide._write; - pair.clientSide._write = function (chunk, encoding, callback) { - this.emit("drain"); - write.call(this, chunk, encoding, callback); - }; - const server = new TLSSocket(pair.serverSide, { - isServer: true, - secureContext: tls.createSecureContext(COMMON_CERT_), - }); - const client = tls.connect({ socket: pair.clientSide, rejectUnauthorized: false }); - try { - const { promise: ended, resolve, reject } = Promise.withResolvers(); - server.on("error", reject); - client.on("error", reject); - const received: Buffer[] = []; - server.on("data", chunk => received.push(chunk)); - server.on("end", resolve); - client.write(payload, () => client.end()); - await ended; - const all = Buffer.concat(received); - expect(all.length).toBe(payload.length); - expect(all.equals(payload)).toBe(true); - } finally { - client.destroy(); - server.destroy(); - } - }); - - it("an HTTP/2 request completes over a Duplex that emits 'drain' in its _write()", async () => { - const server = http2.createSecureServer(COMMON_CERT_); - server.on("stream", stream => { - stream.respond({ ":status": 200 }); - stream.end("ok"); - }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); - await once(raw, "connect"); - const transport = new Duplex({ - read() {}, - write(chunk, _encoding, callback) { - this.emit("drain"); - raw.write(chunk, callback); - }, - }); - raw.on("data", chunk => transport.push(chunk)); - - const { promise, resolve, reject } = Promise.withResolvers<{ status: unknown; body: string }>(); - raw.on("error", reject); - raw.on("close", () => reject(new Error("the connection closed before the response ended"))); - // A second copy of the client preface reaches the server as a protocol error. - server.on("sessionError", reject); - const session = http2.connect("https://localhost", { - createConnection: () => tls.connect({ socket: transport, ALPNProtocols: ["h2"], rejectUnauthorized: false }), - }); - try { - session.on("error", reject); - const request = session.request({ ":path": "/" }); - request.on("error", reject); - request.on("close", () => reject(new Error("the request closed before its response ended"))); - request.setEncoding("utf8"); - request.on("response", headers => { - let body = ""; - request.on("data", chunk => (body += chunk)); - request.on("end", () => resolve({ status: headers[":status"], body })); - }); - request.end(); - expect(await promise).toEqual({ status: 200, body: "ok" }); - } finally { - session.destroy(); - raw.destroy(); - server.close(); - } - }); -}); - describe("large writes and reads of a TLS socket over a Duplex", () => { const pattern = Buffer.from(Array.from({ length: 251 }, (_, i) => i)); diff --git a/test/js/node/tls/renegotiation.test.ts b/test/js/node/tls/renegotiation.test.ts index 9f59cfe68696..7fdde3ada6ba 100644 --- a/test/js/node/tls/renegotiation.test.ts +++ b/test/js/node/tls/renegotiation.test.ts @@ -1397,41 +1397,6 @@ it.concurrent( }, ); -it.concurrent("a write made in the middle of a renegotiation leaves when it ends, over a Duplex", async () => { - const tcp = netConnect(quiet.staysOpen, "127.0.0.1"); - tcp.on("error", () => {}); - const APPLICATION_DATA = 23; - const sent: number[] = []; - // The first thing an established session writes here is the ClientHello of the renegotiation. - const clientHello = Promise.withResolvers<() => void>(); - let established = false; - const duplex = new Duplex({ - read() {}, - write(chunk: Buffer, _encoding: string, callback: () => void) { - const forward = () => void tcp.write(chunk, callback); - if (!established) return forward(); - if (sent.push(chunk[0]) === 1) clientHello.resolve(forward); - else forward(); - }, - }); - tcp.on("data", (chunk: Buffer) => duplex.push(chunk)); - const socket = tlsConnect({ ca: tls.cert, servername: "localhost", socket: duplex }); - try { - const failed = Promise.withResolvers(); - socket.on("error", failed.reject); - socket.once("secureConnect", () => (established = true)); - const forward = await Promise.race([clientHello.promise, failed.promise]); - const written = Promise.withResolvers(); - socket.write("parked", err => (err ? written.reject(err) : written.resolve())); - forward(); - await Promise.race([written.promise, failed.promise]); - expect(sent.at(-1)).toBe(APPLICATION_DATA); - } finally { - socket.destroy(); - tcp.destroy(); - } -}); - it.concurrent("a transport that closes in the middle of a renegotiation is no report over a Duplex", async () => { const events = await nodeTlsEvents("a Duplex", quiet.cut, Infinity, false); // Bun also emits 'end'. From 03827c8163f23c2ae4fe8a7e51b35521afae64c3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 8 Oct 2026 03:09:04 +0000 Subject: [PATCH 150/174] node:tls: a socket that a TLS socket wraps is not destroyed over what it still has to send The close of a TLS socket destroyed the stream it wraps, as in node. There every write has completed by then. Here a write over a stream completes once the stream has taken the ciphertext, so the destroy dropped what the stream had yet to send: when the peer closed its side first, 4 of 8 MiB arrived with TLS in TLS and 4 of 32 MiB on the http2 emit('connection') path. Until the verdict on the peer lets the session through, the application cannot have written over it. There the stream is still destroyed, and so are the sessions below it, which releases the connection of a failed handshake, of a rejected certificate and of an early destroy(). The same holds for an http2 socket that the application never got, and for resetAndDestroy(). After that, as before this branch: - a net.Socket only gets the end() of the engine. What left it at 'finish' is still the kernel's to send, and a close over unread input drops that. It closes at the FIN of its peer. Until then it keeps its own timeout, which the TLS socket cleared through _parent, and reports its own errors, which went to the TLS socket that is now gone. - any other stream is destroyed with the TLS socket. A wrapped socket whose handle is gone hands the error to its TLS socket and closes by itself. The engine resumes the stream at its close only if it paused it, so that a paused net.Socket reads the FIN. Two http2 tests of #38154 need a write that completes as in node and are todo: a client that never closes its side holds the accepted socket of a session that the server destroyed, or of a client it turned away with a 403. --- src/js/node/_http2_upgrade.ts | 7 +- src/js/node/net.ts | 45 +- src/runtime/socket/UpgradedDuplex.rs | 12 +- .../js/node/http2/node-http2-upgrade.test.mts | 60 ++- test/js/node/tls/node-tls-connect.test.ts | 48 +++ .../tls/node-tls-duplex-end-verify.test.ts | 385 ++++++++++++++++++ test/js/node/tls/renegotiation.test.ts | 35 ++ 7 files changed, 578 insertions(+), 14 deletions(-) diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index ce5567d5588a..bd2a0c79724c 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -18,6 +18,7 @@ interface UpgradeContextType { rawSocket: import("node:net").Socket; nativeHandle: NativeHandle | null; events: UpgradeEvents | null; + handedOver: boolean; } interface Http2SecureServer { @@ -64,6 +65,7 @@ function UpgradeContext( this.rawSocket = rawSocket; this.nativeHandle = null; this.events = null; + this.handedOver = false; } // --------------------------------------------------------------------------- @@ -109,8 +111,8 @@ function tlsSocketDestroy(this: TLSProxySocket, err: Error | null, callback: (er h.close(); ctx.nativeHandle = null; } - // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L676-L688 - ctx.rawSocket.destroy(); + // Nothing of the application's is on its way on a socket it never got. Else the raw socket closes when it has sent what it took. + if (!ctx.handedOver) ctx.rawSocket.destroy(); // Must invoke pending write callback with error per Writable stream contract const writeCb = this._writeCallback; if (writeCb) { @@ -243,6 +245,7 @@ function socketHandshake( // Invoke the H2 connectionListener which creates a ServerHttp2Session. // This is the same function passed to Http2SecureServer's constructor // and is what normally fires on the 'secureConnection' event. + ctx.handedOver = true; ctx.connectionListener.$call(ctx.server, tlsSocket); // Resume the Duplex so the H2 session can read frames from it. The accept diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 3ae8edf24a33..5402d273dfb9 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -312,6 +312,8 @@ const kAdoptedTLSRaw = Symbol("kAdoptedTLSRaw"); const kOwesRawClose = Symbol("kOwesRawClose"); // Emitted on a socket under the stream-level TLS engine in place of reporting a read error itself. const kReadError = Symbol("kReadError"); +// The verdict on the peer let the session through, so the application can have written over it. +const kSessionAccepted = Symbol("kSessionAccepted"); const ksocket = Symbol("ksocket"); const khandlers = Symbol("khandlers"); const kclosed = Symbol("closed"); @@ -533,6 +535,22 @@ function unadoptedTransport(self): SocketInstance | undefined { const upgraded = self[kupgraded]; if (upgraded && !upgraded.destroyed && upgraded._handle?.[kAdoptedTLSRaw] !== self) return upgraded; } +// Node destroys the transport at every close: all writes have completed by then. Here a write completes once the transport has +// taken the ciphertext, so one that can hold some of the application's is released instead (releaseTransport). +function transportToDestroy(self): SocketInstance | undefined { + if (self[kSessionAccepted] !== true) return unadoptedTransport(self); +} +// Sessions below one that was never accepted, or is reset, carried nothing else of the application's: they go with it. +function abortTransport(transport: SocketInstance) { + if (transport instanceof Socket) transport[kSessionAccepted] = false; + transport.destroy(); +} +function releaseTransport(transport: SocketInstance) { + if (!(transport instanceof Socket)) return void transport.destroy(); + // What leaves a socket at 'finish' is still the kernel's to send, and a close over unread input drops that. So it only gets the + // end() of the engine, closes at the FIN of its peer and reports its own errors until then. + transport.removeAllListeners(kReadError); +} // The wrapped socket reports nothing and closes with the TLS socket: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L676-L688 function closeWithTLSSocket(self, raw) { const tlsSocket = raw[kAdoptedTLSRaw]; @@ -677,6 +695,7 @@ function onClientHandshake(self, socket, success, verifyError) { } else { self.authorized = true; } + self[kSessionAccepted] = true; // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1697-L1698 self.secureConnecting = false; // pauseOnConnect stopped the handle as the handshake completed, whatever resume() did before that. @@ -702,6 +721,7 @@ function finishStandaloneWrap(self, verifyError) { self.destroy(verifyError); return; } + self[kSessionAccepted] = true; // The rest is node's _finishInit: no hostname check, no 'secureConnect', and `authorized` stays false. self.secureConnecting = false; self.emit(kSecureConnectDone); @@ -978,7 +998,8 @@ function SocketEmitEndNT(self, _err?) { // (native on_error / a fatal write); node emits a socket error exactly // once, so the close that follows it is delivered plain. if (_err && !self.destroyed && !self._hadError && !teardownNoise && self.listenerCount("error") > 0) { - if (self.emit(kReadError, _err)) return; + // Its TLS socket reports the error. The handle is gone, so this socket closes with nothing to report. + if (self.emit(kReadError, _err)) return void self.destroy(); // The consumer can detach its 'error' listener between this close // callback and destroy()'s deferred 'error' emission (a request that // finished just as the reset arrived); a last-resort no-op listener keeps @@ -1321,6 +1342,7 @@ const ServerHandlers = { self.authorized = true; } } + self[kSessionAccepted] = true; const pauseOnConnect = server?.pauseOnConnect; if (pauseOnConnect) { pauseOnCreate(self, socket); @@ -1687,7 +1709,8 @@ const SocketHandlers2 = { // upgrade also report errors on close, and those must keep ending // cleanly. if (!leftToTLSSocket && err && !self.destroyed && socket === self._handle && self.listenerCount("error") > 0) { - if (self.emit(kReadError, err)) return; + // See SocketEmitEndNT. + if (self.emit(kReadError, err)) return void self.destroy(); // Same late-detach guard as SocketEmitEndNT: the listener seen at // close-time can be gone by the deferred 'error' emission. self.once("error", () => {}); @@ -2330,6 +2353,7 @@ Socket.prototype.connect = function connect(...args) { this.secureConnecting = true; this[kPreHandshakeWrite] = false; this._secureEstablished = false; + this[kSessionAccepted] = false; this._securePending = true; this[kConnectOptions] = options; // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1811 @@ -2432,9 +2456,10 @@ Socket.prototype._destroy = function _destroy(err, callback) { } } - for (let s = this; s !== null; s = s._parent) { - clearTimeout(s[kTimeout]); - } + // What closes with this socket clears its own. + clearTimeout(this[kTimeout]); + const released = unadoptedTransport(this); + if (released !== undefined && !this.resetAndClosing && this[kSessionAccepted] === true) releaseTransport(released); $debug("close"); if (this._handle) { @@ -2455,7 +2480,8 @@ Socket.prototype._destroy = function _destroy(err, callback) { // ECONNRESET. `close()` does a fast shutdown (clean close) which only // happens to surface as RST on some platforms; `terminate()` arms // SO_LINGER{1,0} for a real reset on all platforms. - unadoptedTransport(this)?.destroy(); + const transport = unadoptedTransport(this); + if (transport !== undefined) abortTransport(transport); const err = this._handle.terminate(); setImmediate(() => { $debug("emit close"); @@ -2487,8 +2513,8 @@ Socket.prototype._destroy = function _destroy(err, callback) { } else { callback(err); closeOwedRaw(this, upgraded); - const transport = unadoptedTransport(this); - transport?.destroy(); + const transport = transportToDestroy(this); + if (transport !== undefined) abortTransport(transport); if (transport instanceof Socket) transport.once("close", emitCloseNT.bind(null, this, err ? true : false)); else process.nextTick(emitCloseNT, this, err ? true : false); } @@ -4447,7 +4473,8 @@ function onSocketHandleClosed() {} function closeSocketHandle(self, handle, isException, isCleanupPending = false) { $debug("closeSocketHandle", isException, isCleanupPending); // Ahead of the setImmediate below: the transport's 'close' precedes this socket's. - unadoptedTransport(self)?.destroy(); + const transport = transportToDestroy(self); + if (transport !== undefined) abortTransport(transport); // The owner of a wrapped socket destroyed it: the TLS socket goes first, so the close of their fd is nothing to report. handle[kAdoptedTLSRaw]?.destroy(); handle.close(onSocketHandleClosed); diff --git a/src/runtime/socket/UpgradedDuplex.rs b/src/runtime/socket/UpgradedDuplex.rs index 2f5e3925eed3..8ea622c498a5 100644 --- a/src/runtime/socket/UpgradedDuplex.rs +++ b/src/runtime/socket/UpgradedDuplex.rs @@ -62,6 +62,8 @@ pub(crate) struct UpgradedDuplex { pub pending_close: Cell, /// [`Self::shutdown`] arrived before the engine existed. [`Self::drain_pending`] replays it. pub pending_shutdown: Cell, + /// `pause_stream` paused the wrapped stream and `resume_stream` has not run since. + paused_origin: Cell, } bun_event_loop::impl_timer_owner!(UpgradedDuplex; from_timer_ptr => event_loop_timer); @@ -219,6 +221,10 @@ impl UpgradedDuplex { (self.handlers.on_close)(self.handlers.ctx); // closes the underlying duplex self.call_write_or_end(None, false); + // Left paused, a net.Socket transport never reads its peer's FIN and stays open. + if self.paused_origin.get() { + self.resume_stream(); + } // Early teardown (struct itself is dropped later by parent). self.teardown(); @@ -229,11 +235,14 @@ impl UpgradedDuplex { #[uws_callback(export = "UpgradedDuplex__pause_stream")] pub(crate) fn pause_stream(&self) -> bool { // Like a connecting fd: `on_open` forgets the owner's pause, so nothing would resume this one. - self.wrapper_ref().is_some() && self.call_origin("pause") + let paused = self.wrapper_ref().is_some() && self.call_origin("pause"); + self.paused_origin.set(self.paused_origin.get() || paused); + paused } #[uws_callback(export = "UpgradedDuplex__resume_stream")] pub(crate) fn resume_stream(&self) -> bool { + self.paused_origin.set(false); self.call_origin("resume") } @@ -453,6 +462,7 @@ impl UpgradedDuplex { pending_data: JsCell::new(Vec::new()), pending_close: Cell::new(false), pending_shutdown: Cell::new(false), + paused_origin: Cell::new(false), } } diff --git a/test/js/node/http2/node-http2-upgrade.test.mts b/test/js/node/http2/node-http2-upgrade.test.mts index 178437814ae8..f56e059fd97e 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -329,6 +329,57 @@ describe("HTTP/2 upgrade — ALPN negotiation", () => { }); }); +describe("HTTP/2 upgrade — the client closes its side first", () => { + test("what the server still has to send arrives, over a raw socket that got the client's FIN", async () => { + const h2Server = http2.createSecureServer(TLS); + const log: string[] = []; + const first = Buffer.alloc(16 * 1024 * 1024, "a"); + const writing = Promise.withResolvers(); + h2Server.on("error", err => log.push(`server 'error': ${err.code}`)); + h2Server.on("unknownProtocol", socket => { + socket.on("error", (err: NodeJS.ErrnoException) => log.push(`'error': ${err.code}`)); + socket.resume(); + // More than the kernel takes at once, so the writes behind it are still queued when the FIN arrives. + socket.write(first, err => log.push(`write callback: ${(err as NodeJS.ErrnoException)?.code}`)); + socket.write("tail", err => log.push(`queued write callback: ${(err as NodeJS.ErrnoException)?.code}`)); + socket.write("third", err => log.push(`last write callback: ${(err as NodeJS.ErrnoException)?.code}`)); + writing.resolve(); + }); + let raw: net.Socket | undefined; + const netServer = net.createServer(socket => void h2Server.emit("connection", (raw = socket))); + await once(netServer.listen(0, "127.0.0.1"), "listening"); + const port = (netServer.address() as net.AddressInfo).port; + const options = { host: "127.0.0.1", port, rejectUnauthorized: false, allowHalfOpen: true }; + const client = tls.connect(options as tls.ConnectionOptions); + try { + client.on("error", err => log.push(`client 'error': ${(err as NodeJS.ErrnoException).code}`)); + await once(client, "secureConnect"); + await writing.promise; + // A turn later only what the kernel did not take is left. + await new Promise(resolve => setImmediate(resolve)); + if (process.versions.bun !== undefined) assert.notStrictEqual(raw?.writableLength, 0); + let received = 0; + const ended = once(client, "end"); + client.end(); + // Its FIN is out before it reads the first byte. + await once(client, "finish"); + client.on("data", chunk => (received += chunk.length)); + await ended; + assert.deepStrictEqual( + { received, log }, + { + received: first.length + "tail".length + "third".length, + log: ["write callback: undefined", "queued write callback: undefined", "last write callback: undefined"], + }, + ); + } finally { + client.destroy(); + raw?.destroy(); + netServer.close(); + } + }); +}); + describe("HTTP/2 upgrade — varied status codes", () => { test("404 response with custom header", async () => { const srv = await createUpgradeServer((_req, res) => { @@ -549,6 +600,11 @@ describe("HTTP/2 upgrade — the accepted socket is released when the server sid if (netServer.listening) netServer.close(); } + // Once the application has the socket, what it wrote may still be on its way when the session goes down: in Bun a write + // over a stream completes before the stream has sent it. So the accepted socket is ended, not destroyed, and a client + // that never closes its side holds it. Releasing it needs a write that completes as in Node (#43877). + const heldByTheClient = process.versions.bun !== undefined && "the accepted socket waits for the client's FIN"; + test("after a failed handshake", async () => { const h2Server = http2.createSecureServer(TLS); const tlsClientError = once(h2Server, "tlsClientError"); @@ -565,7 +621,7 @@ describe("HTTP/2 upgrade — the accepted socket is released when the server sid } }); - test("after the session is destroyed", async () => { + test("after the session is destroyed", { todo: heldByTheClient }, async () => { const h2Server = http2.createSecureServer(TLS); const sessionClosed = new Promise(resolve => { h2Server.once("session", (session: http2.ServerHttp2Session) => { @@ -600,7 +656,7 @@ describe("HTTP/2 upgrade — the accepted socket is released when the server sid } }); - test("after a client that negotiated no protocol is turned away", async () => { + test("after a client that negotiated no protocol is turned away", { todo: heldByTheClient }, async () => { const h2Server = http2.createSecureServer({ ...TLS, unknownProtocolTimeout: 0 }); h2Server.on("session", () => assert.fail("a client without ALPN must not get a session")); const { netServer, port, accepted } = await acceptInto(h2Server); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 4ecc327b5df9..1c55d13aaf52 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -13,6 +13,7 @@ import { rejectUnauthorizedScope, tempDir, } from "harness"; +import http2 from "http2"; import https from "https"; import net from "net"; import { join } from "path"; @@ -6378,6 +6379,53 @@ describe("over a duplex, 'session' is emitted before the data that followed it o }); }); +it("an HTTP/2 request completes over a Duplex that emits 'drain' in its _write()", async () => { + const server = http2.createSecureServer(COMMON_CERT_); + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const raw = net.connect((server.address() as AddressInfo).port, "127.0.0.1"); + await once(raw, "connect"); + const transport = new Duplex({ + read() {}, + write(chunk, _encoding, callback) { + this.emit("drain"); + raw.write(chunk, callback); + }, + }); + raw.on("data", chunk => transport.push(chunk)); + + const { promise, resolve, reject } = Promise.withResolvers<{ status: unknown; body: string }>(); + raw.on("error", reject); + raw.on("close", () => reject(new Error("the connection closed before the response ended"))); + // A second copy of the client preface reaches the server as a protocol error. + server.on("sessionError", reject); + const session = http2.connect("https://localhost", { + createConnection: () => tls.connect({ socket: transport, ALPNProtocols: ["h2"], rejectUnauthorized: false }), + }); + try { + session.on("error", reject); + const request = session.request({ ":path": "/" }); + request.on("error", reject); + request.on("close", () => reject(new Error("the request closed before its response ended"))); + request.setEncoding("utf8"); + request.on("response", headers => { + let body = ""; + request.on("data", chunk => (body += chunk)); + request.on("end", () => resolve({ status: headers[":status"], body })); + }); + request.end(); + expect(await promise).toEqual({ status: 200, body: "ok" }); + } finally { + session.destroy(); + raw.destroy(); + server.close(); + } +}); + describe("large writes and reads of a TLS socket over a Duplex", () => { const pattern = Buffer.from(Array.from({ length: 251 }, (_, i) => i)); diff --git a/test/js/node/tls/node-tls-duplex-end-verify.test.ts b/test/js/node/tls/node-tls-duplex-end-verify.test.ts index 172f9d521bfc..a0f23f631f74 100644 --- a/test/js/node/tls/node-tls-duplex-end-verify.test.ts +++ b/test/js/node/tls/node-tls-duplex-end-verify.test.ts @@ -1512,3 +1512,388 @@ for (const maxVersion of ["TLSv1.2", "TLSv1.3"]) { }); } } + +// What a TLS socket over another stream has written is still on its way when the TLS socket closes: the stream has +// taken the ciphertext and may yet have to send it. +const turn = () => new Promise(resolve => setImmediate(resolve)); + +// `depth` TLS sessions, one inside the other, over one TCP connection. Index 0 is the session over TCP. +async function nestedSessions(depth, { allowHalfOpen = false } = {}) { + const accepted = Promise.withResolvers(); + const listener = tls.createServer({ key, cert, allowHalfOpen }, accepted.resolve); + await new Promise(listening => listener.listen(0, "127.0.0.1", listening)); + const clients = [ + tls.connect({ port: listener.address().port, host: "127.0.0.1", rejectUnauthorized: false, allowHalfOpen }), + ]; + const servers = []; + const close = () => { + for (const socket of [...clients, ...servers]) socket.destroy(); + listener.close(); + }; + try { + servers.push(await accepted.promise); + while (clients.length < depth) { + servers.push(new tls.TLSSocket(servers.at(-1), { isServer: true, key, cert })); + clients.push(tls.connect({ socket: clients.at(-1), rejectUnauthorized: false })); + await Promise.all([ + new Promise(secured => clients.at(-1).once("secureConnect", secured)), + new Promise(secured => servers.at(-1).once("secure", secured)), + ]); + } + } catch (err) { + close(); + throw err; + } + return { clients, servers, close }; +} + +for (const side of ["client", "server"]) { + test(`over a Duplex: a ${side} write that the Duplex has yet to complete is no error when the peer closes its side`, async () => { + // Holds the chunk and the callback of each write of `side` from `stall()` on. + const held = []; + let stalled = false; + const makeSide = (name, peer) => + new Duplex({ + read() {}, + write(chunk, encoding, callback) { + if (stalled && name === side) return void held.push([chunk, callback]); + peer().push(chunk); + callback(); + }, + final(callback) { + peer().push(null); + callback(); + }, + }); + const sides = { client: makeSide("client", () => sides.server), server: makeSide("server", () => sides.client) }; + const server = new tls.TLSSocket(sides.server, { isServer: true, key, cert }); + const client = tls.connect({ socket: sides.client, rejectUnauthorized: false }); + const [writer, reader] = side === "client" ? [client, server] : [server, client]; + const log = []; + let received = ""; + try { + writer.on("error", err => log.push(`'error': ${err.message}`)); + reader.on("error", err => log.push(`peer 'error': ${err.message}`)); + await Promise.all([ + new Promise(secured => client.once("secureConnect", secured)), + new Promise(secured => server.once("secure", secured)), + ]); + // The session tickets of the server are out. + await turn(); + reader.on("data", chunk => (received += chunk)); + writer.resume(); + const arrived = new Promise(resolve => reader.once("data", resolve)); + stalled = true; + writer.write("x", err => log.push(`write callback: ${err?.message}`)); + reader.end(); + await turn(); + stalled = false; + for (const [chunk, callback] of held.splice(0)) { + sides[side === "client" ? "server" : "client"].push(chunk); + callback(); + } + await arrived; + await turn(); + assert.deepStrictEqual({ received, log }, { received: "x", log: ["write callback: undefined"] }); + } finally { + client.destroy(); + server.destroy(); + } + }); + + for (const depth of [2, 3]) { + test(`${depth} TLS sessions deep: what a ${side} wrote arrives when the peer closes its side first`, async () => { + const { clients, servers, close } = await nestedSessions(depth); + const [writer, reader] = side === "client" ? [clients.at(-1), servers.at(-1)] : [servers.at(-1), clients.at(-1)]; + const log = []; + let received = 0; + try { + // The sessions below report that the one above is gone in their own ways. Only what the writer is told counts. + for (const socket of [...clients, ...servers]) socket.on("error", () => {}); + reader.on("data", chunk => (received += chunk.length)); + writer.resume(); + const readerEnded = new Promise(ended => reader.once("end", ended)); + // More than the kernel takes at once, so most of it is queued in the session over TCP when the writer closes. + const first = Buffer.alloc(8 * 1024 * 1024, "a"); + writer.write(first, err => log.push(`write callback: ${err?.code}`)); + writer.write("tail", err => log.push(`queued write callback: ${err?.code}`)); + // A turn later only what the kernel did not take is left. Node writes to the handle of the session below, past its queue. + await turn(); + if (isBun) assert.notStrictEqual((side === "client" ? clients : servers)[0].writableLength, 0); + reader.end(); + await readerEnded; + assert.deepStrictEqual( + { received, log }, + { received: first.length + 4, log: ["write callback: undefined", "queued write callback: undefined"] }, + ); + } finally { + close(); + } + }); + } +} + +for (const [how, leave] of [ + [ + "ends its TLS socket and then destroys the connection", + (inner, outer) => (inner.end(), setImmediate(() => outer.destroy())), + ], + ["destroys the connection", (inner, outer) => outer.destroy()], + ["destroys its TLS socket", inner => inner.destroy()], +]) { + test(`2 TLS sessions deep: the connection closes when it still has ciphertext to send and the peer ${how}`, async () => { + const { clients, servers, close } = await nestedSessions(2); + try { + for (const socket of [...clients, ...servers]) socket.on("error", () => {}); + const closed = clients.map(socket => new Promise(resolve => socket.once("close", resolve))); + clients[1].resume(); + servers[1].resume(); + clients[1].write(Buffer.alloc(8 * 1024 * 1024, "a")); + await turn(); + if (isBun) assert.notStrictEqual(clients[0].writableLength, 0); + leave(servers[1], servers[0]); + await Promise.all(closed); + } finally { + close(); + } + }); +} + +// A stream that is no net.Socket has nothing behind it that still has to send: it goes with the TLS socket. +for (const [how, leave] of [ + ["destroy()", socket => socket.destroy()], + ["destroy(err)", socket => socket.destroy(new Error("gone"))], + ["destroySoon()", socket => socket.destroySoon()], +]) { + test(`over a Duplex: ${how} of the TLS socket destroys a Duplex whose other end stays open`, async () => { + const [transport, otherEnd] = duplexPair(); + const server = new tls.TLSSocket(otherEnd, { isServer: true, key, cert, allowHalfOpen: true }); + const client = tls.connect({ socket: transport, rejectUnauthorized: false }); + try { + for (const socket of [client, server, transport, otherEnd]) socket.on("error", () => {}); + await Promise.all([ + new Promise(secured => client.once("secureConnect", secured)), + new Promise(secured => server.once("secure", secured)), + ]); + let received = ""; + server.on("data", chunk => (received += chunk)); + const closed = new Promise(resolve => transport.once("close", resolve)); + client.write("last words"); + leave(client); + await closed; + assert.strictEqual(transport.destroyed, true); + // Only a destroy() gives up what is on its way. + if (!how.startsWith("destroy(")) { + await turn(); + assert.strictEqual(received, "last words"); + } + } finally { + client.destroy(); + server.destroy(); + transport.destroy(); + otherEnd.destroy(); + } + }); +} + +test("over a Duplex: the close of the TLS socket does not resume a Duplex that it did not pause", async () => { + const [transport, otherEnd] = duplexPair(); + const server = new tls.TLSSocket(otherEnd, { isServer: true, key, cert }); + const client = tls.connect({ socket: transport, rejectUnauthorized: false }); + try { + for (const socket of [client, server, transport, otherEnd]) socket.on("error", () => {}); + client.resume(); + await Promise.all([ + new Promise(secured => client.once("secureConnect", secured)), + new Promise(secured => server.once("secure", secured)), + ]); + await turn(); + let resumed = 0; + const resume = transport.resume; + transport.resume = function () { + resumed++; + return resume.call(this); + }; + const closed = new Promise(resolve => client.once("close", resolve)); + client.destroy(); + await closed; + assert.strictEqual(resumed, 0); + } finally { + client.destroy(); + server.destroy(); + transport.destroy(); + otherEnd.destroy(); + } +}); + +// The session below is a net.Socket, which outlives the one above. Its peer keeps its side open in these. +test("2 TLS sessions deep: the timeout of the session below still fires after the one above is destroyed", async () => { + const { clients, servers, close } = await nestedSessions(2, { allowHalfOpen: true }); + try { + for (const socket of [...clients, ...servers]) socket.on("error", () => {}); + const timedOut = new Promise(resolve => servers[0].setTimeout(50, resolve)); + servers[1].destroy(); + // Node destroys the session below with the one above. + await Promise.race([timedOut, new Promise(resolve => servers[0].once("close", resolve))]); + } finally { + close(); + } +}); + +for (const side of ["client", "server"]) { + test(`2 TLS sessions deep: a ${side} hears of a reset that cuts what it wrote, after its TLS socket closed too`, async () => { + const accepted = Promise.withResolvers(); + const listener = net.createServer(accepted.resolve); + await new Promise(listening => listener.listen(0, "127.0.0.1", listening)); + const tcpClient = net.connect(listener.address().port, "127.0.0.1"); + const tcpServer = await accepted.promise; + const outerServer = new tls.TLSSocket(tcpServer, { isServer: true, key, cert }); + const outerClient = tls.connect({ socket: tcpClient, rejectUnauthorized: false }); + const innerServer = new tls.TLSSocket(outerServer, { isServer: true, key, cert }); + const innerClient = tls.connect({ socket: outerClient, rejectUnauthorized: false }); + const all = [tcpClient, tcpServer, outerClient, outerServer, innerClient, innerServer]; + const [inner, outer, peerTcp, peers] = + side === "client" + ? [innerClient, outerClient, tcpServer, [innerServer, outerServer, tcpServer]] + : [innerServer, outerServer, tcpClient, [innerClient, outerClient, tcpClient]]; + try { + const told = []; + for (const socket of all) socket.on("error", () => {}); + await Promise.all([ + new Promise(secured => innerClient.once("secureConnect", secured)), + new Promise(secured => innerServer.once("secure", secured)), + ]); + // The peer reads nothing, so most of this never leaves. + for (const socket of peers) socket.pause(); + inner.on("error", err => told.push(`TLS socket 'error': ${err.code}`)); + outer.on("error", err => told.push(`session below 'error': ${err.code}`)); + inner.write(Buffer.alloc(8 * 1024 * 1024, "a"), err => err && told.push(`write callback: ${err.code}`)); + const innerClosed = new Promise(resolve => inner.once("close", resolve)); + const outerClosed = new Promise(resolve => outer.once("close", resolve)); + inner.destroy(); + await innerClosed; + peerTcp.resetAndDestroy(); + await outerClosed; + assert.notDeepStrictEqual(told, []); + } finally { + for (const socket of all) socket.destroy(); + listener.close(); + } + }); +} + +// No session the application could have written over: the connection goes at once, whatever its peer does. +for (const transportKind of ["a TLS socket", "a Duplex"]) { + test(`over ${transportKind}: a client that rejects the server's certificate releases a transport whose peer stays`, async () => { + const { clients, servers, close } = await nestedSessions(1, { allowHalfOpen: true }); + let [transport, otherEnd] = [clients[0], servers[0]]; + if (transportKind === "a Duplex") [transport, otherEnd] = duplexPair(); + const server = new tls.TLSSocket(otherEnd, { isServer: true, key, cert, allowHalfOpen: true }); + const client = tls.connect({ socket: transport, servername: "agent1" }); + try { + for (const socket of [client, server, transport, otherEnd]) socket.on("error", () => {}); + const rejected = new Promise(resolve => client.once("error", resolve)); + const closed = new Promise(resolve => transport.once("close", resolve)); + assert.strictEqual((await rejected).code, "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + await closed; + assert.strictEqual(transport.destroyed, true); + } finally { + client.destroy(); + server.destroy(); + transport.destroy(); + otherEnd.destroy(); + close(); + } + }); +} + +test("over a Duplex: destroy() after end() releases a Duplex that never completes its writes", async () => { + let stalled = false; + const makeSide = peer => + new Duplex({ + read() {}, + write(chunk, encoding, callback) { + if (stalled) return; + peer().push(chunk); + callback(); + }, + }); + const transport = makeSide(() => otherEnd); + const otherEnd = makeSide(() => transport); + const server = new tls.TLSSocket(otherEnd, { isServer: true, key, cert }); + const client = tls.connect({ socket: transport, rejectUnauthorized: false }); + try { + for (const socket of [client, server, transport, otherEnd]) socket.on("error", () => {}); + await Promise.all([ + new Promise(secured => client.once("secureConnect", secured)), + new Promise(secured => server.once("secure", secured)), + ]); + await turn(); + stalled = true; + const closed = new Promise(resolve => transport.once("close", resolve)); + client.end("last words"); + await turn(); + client.destroy(); + await closed; + assert.strictEqual(transport.destroyed, true); + } finally { + client.destroy(); + server.destroy(); + transport.destroy(); + otherEnd.destroy(); + } +}); + +test("over a Duplex: a TLS socket whose peer was destroyed takes its Duplex with it", async () => { + const [transport, otherEnd] = duplexPair({ allowHalfOpen: false }); + const server = new tls.TLSSocket(otherEnd, { isServer: true, key, cert }); + const client = tls.connect({ socket: transport, rejectUnauthorized: false }); + try { + for (const socket of [client, server, transport, otherEnd]) socket.on("error", () => {}); + await Promise.all([ + new Promise(secured => client.once("secureConnect", secured)), + new Promise(secured => server.once("secure", secured)), + ]); + server.resume(); + const closed = [server, otherEnd].map(stream => new Promise(resolve => stream.once("close", resolve))); + client.destroy(); + await Promise.all(closed); + assert.strictEqual(otherEnd.destroyed, true); + } finally { + client.destroy(); + server.destroy(); + transport.destroy(); + otherEnd.destroy(); + } +}); + +// The peers keep their sides open in these two. +test("3 TLS sessions deep: a client that rejects the innermost certificate releases the connection", async () => { + const { clients, servers, close } = await nestedSessions(2, { allowHalfOpen: true }); + const server = new tls.TLSSocket(servers[1], { isServer: true, key, cert, allowHalfOpen: true }); + const client = tls.connect({ socket: clients[1], servername: "agent1" }); + try { + for (const socket of [...clients, ...servers, client, server]) socket.on("error", () => {}); + const rejected = new Promise(resolve => client.once("error", resolve)); + const closed = [...clients, client].map(socket => new Promise(resolve => socket.once("close", resolve))); + assert.strictEqual((await rejected).code, "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); + await Promise.all(closed); + } finally { + client.destroy(); + server.destroy(); + close(); + } +}); + +test("3 TLS sessions deep: the timeout of the session over TCP still fires after the one above it is destroyed", async () => { + const { clients, servers, close } = await nestedSessions(3, { allowHalfOpen: true }); + try { + for (const socket of [...clients, ...servers]) socket.on("error", () => {}); + const timedOut = new Promise(resolve => servers[0].setTimeout(50, resolve)); + servers[1].destroy(); + // Node destroys the session over TCP with the one above it. + await Promise.race([timedOut, new Promise(resolve => servers[0].once("close", resolve))]); + } finally { + close(); + } +}); diff --git a/test/js/node/tls/renegotiation.test.ts b/test/js/node/tls/renegotiation.test.ts index 7fdde3ada6ba..9f59cfe68696 100644 --- a/test/js/node/tls/renegotiation.test.ts +++ b/test/js/node/tls/renegotiation.test.ts @@ -1397,6 +1397,41 @@ it.concurrent( }, ); +it.concurrent("a write made in the middle of a renegotiation leaves when it ends, over a Duplex", async () => { + const tcp = netConnect(quiet.staysOpen, "127.0.0.1"); + tcp.on("error", () => {}); + const APPLICATION_DATA = 23; + const sent: number[] = []; + // The first thing an established session writes here is the ClientHello of the renegotiation. + const clientHello = Promise.withResolvers<() => void>(); + let established = false; + const duplex = new Duplex({ + read() {}, + write(chunk: Buffer, _encoding: string, callback: () => void) { + const forward = () => void tcp.write(chunk, callback); + if (!established) return forward(); + if (sent.push(chunk[0]) === 1) clientHello.resolve(forward); + else forward(); + }, + }); + tcp.on("data", (chunk: Buffer) => duplex.push(chunk)); + const socket = tlsConnect({ ca: tls.cert, servername: "localhost", socket: duplex }); + try { + const failed = Promise.withResolvers(); + socket.on("error", failed.reject); + socket.once("secureConnect", () => (established = true)); + const forward = await Promise.race([clientHello.promise, failed.promise]); + const written = Promise.withResolvers(); + socket.write("parked", err => (err ? written.reject(err) : written.resolve())); + forward(); + await Promise.race([written.promise, failed.promise]); + expect(sent.at(-1)).toBe(APPLICATION_DATA); + } finally { + socket.destroy(); + tcp.destroy(); + } +}); + it.concurrent("a transport that closes in the middle of a renegotiation is no report over a Duplex", async () => { const events = await nodeTlsEvents("a Duplex", quiet.cut, Infinity, false); // Bun also emits 'end'. From b6119ad53a93d35b18803fa8329ed1bbc994a4f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:51:53 +0000 Subject: [PATCH 151/174] node:tls: count a falsy key, cert, ca or crl on tls.Server as absent tls.Server#setSecureContext() stored a falsy key, cert, ca or crl ("", false, 0) as it was. The native TLS config then rejected the value. The server failed at listen(), and setSecureContext() on a listening server threw. Node stores undefined for each of the four in the same function. Do the same. tls.createSecureContext(), tls.connect() and server.addContext() keep rejecting a falsy crl, as in Node. --- src/js/node/tls.ts | 9 +- test/js/node/tls/node-tls-context.test.ts | 21 +++ test/js/node/tls/node-tls-server.test.ts | 180 ++++++++++++++++++++++ test/js/node/tls/ssl-ctx-cache.test.ts | 34 ++++ 4 files changed, 240 insertions(+), 4 deletions(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index ffe65c61309a..0b7a288cd183 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1192,7 +1192,8 @@ function Server(options, secureConnectionListener): void { validateSecureContextOptions(options); options = processPfxOptions(options); - let cert = options.cert; + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1431-L1474: a falsy key, cert, ca or crl counts as absent + let cert = options.cert || undefined; // Assign unconditionally so a later setSecureContext() that omits an // option clears the previous call's value (Node resets each omitted // field) instead of silently keeping stale key material. @@ -1201,13 +1202,13 @@ function Server(options, secureConnectionListener): void { } next.cert = cert; - let key = options.key; + let key = options.key || undefined; if (key) { throwOnInvalidTLSArray("options.key", key); } next.key = key; - let ca = options.ca; + let ca = options.ca || undefined; // InternalSecureContext applies tls.setDefaultCACertificates() itself; a named pipe listener builds from this field. if (_defaultCACertificatesOverride !== undefined && ca == null) { ca = _defaultCACertificatesOverride; @@ -1222,7 +1223,7 @@ function Server(options, secureConnectionListener): void { } next.ca = ca; - const crl = options.crl; + const crl = options.crl || undefined; if (crl) { throwOnInvalidTLSArray("options.crl", crl); } diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index 75a669edb25b..a0287cc56d02 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -1393,6 +1393,27 @@ it("validates crl the same way on both createSecureContext and Server.setSecureC } }); +it("only tls.Server counts a falsy crl as absent", () => { + // tls.Server#setSecureContext() stores undefined for a falsy crl. configSecureContext(), + // behind every other entry point, only skips null and undefined: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1471-L1474 + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/secure-context.js#L261-L271 + const server = tls.createServer({ key: agent1Key, cert: agent1Cert }); + for (const [crl, code] of [ + ["", "ERR_CRYPTO_OPERATION_FAILED"], + [false, "ERR_INVALID_ARG_TYPE"], + [0, "ERR_INVALID_ARG_TYPE"], + ] as [never, string][]) { + const rejected = expect.objectContaining({ code }); + expect(() => tls.createSecureContext({ crl })).toThrow(rejected); + expect(() => server.addContext("example.com", { crl })).toThrow(rejected); + expect(() => tls.connect({ socket: new Duplex({ read() {} }), crl })).toThrow(rejected); + expect((tls.createServer({ crl }) as any).crl).toBeUndefined(); + } + // An empty array is truthy. tls.Server keeps it, and it names no CRL. + expect((tls.createServer({ crl: [] }) as any).crl).toEqual([]); +}); + it("accepts BoringSSL kCipherAliases selectors that are not literal suite names", () => { // vendor/boringssl/ssl/ssl_cipher.cc kCipherAliases: AES128, AES256, kPSK, // aPSK, FIPS all match a non-empty cipher list. Node built against BoringSSL diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 90d97638ddaa..12a55ba6398f 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -2687,6 +2687,186 @@ describe("tls.Server secure-context options", () => { expect(authorized).toBe(false); }); + // tls.Server#setSecureContext() stores undefined for a falsy key, cert, ca or crl: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1431-L1474 + describe("a falsy key, cert, ca or crl counts as absent, like Node", () => { + const keys = (name: string) => readFileSync(join(import.meta.dir, "..", "test", "fixtures", "keys", name), "utf8"); + const identity = { key: agent6Key, cert: agent6CertChain }; + // This ca1 issued this agent1. This ca2 issued this agent3, and ca2-crl-agent3.pem revokes it. + const agent1 = { key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }; + const agent3 = { key: keys("agent3-key.pem"), cert: keys("agent3-cert.pem") }; + const ca1 = keys("ca1-cert.pem"); + const ca2 = keys("ca2-cert.pem"); + const revokesAgent3 = keys("ca2-crl-agent3.pem"); + const mtls = { ...identity, ca: ca2, requestCert: true, rejectUnauthorized: false }; + const accepted = { authorized: true, authorizationError: null }; + const unknownIssuer = { authorized: false, authorizationError: "UNABLE_TO_VERIFY_LEAF_SIGNATURE" }; + const revoked = { authorized: false, authorizationError: "CERT_REVOKED" }; + const names = ["key", "cert", "ca", "crl"] as const; + const cells = names.flatMap(name => ["", false, 0].map(value => [name, value] as const)); + + async function listen(server: net.Server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return (server.address() as AddressInfo).port; + } + + // How `server` judges an agent3 client that connects to `port`. + async function judge(server: Server, port: number) { + const verdict = Promise.withResolvers<{ authorized: boolean; authorizationError: unknown }>(); + const onSecureConnection = (socket: TLSSocket) => { + verdict.resolve({ authorized: socket.authorized, authorizationError: socket.authorizationError }); + socket.end(); + }; + server.once("secureConnection", onSecureConnection); + server.once("error", verdict.reject); + const closed = Promise.withResolvers(); + const client = connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ...agent3 }); + client.on("error", verdict.reject); + client.on("close", () => { + closed.resolve(); + verdict.reject(new Error("the client closed before 'secureConnection'")); + }); + client.resume(); + try { + const judged = await verdict.promise; + await closed.promise; + return judged; + } finally { + client.destroy(); + server.off("secureConnection", onSecureConnection); + server.off("error", verdict.reject); + } + } + + it.each(cells)("%s: %p listens and is not stored", async (name, value) => { + const server: any = createServer({ ...identity, [name]: value }); + try { + await listen(server); + expect(server[name]).toBeUndefined(); + } finally { + server.close(); + } + }); + + it("-0, NaN, 0n and null are not stored either", () => { + for (const name of names) { + for (const value of [-0, NaN, 0n, null]) { + expect([name, value, (createServer({ [name]: value }) as any)[name]]).toEqual([name, value, undefined]); + } + } + }); + + it.each([ + ["new tls.Server()", (options: any) => new Server(options)], + ["tls.Server() without new", (options: any) => (Server as any)(options)], + ["a tls.Server subclass", (options: any) => new (class extends Server {})(options)], + ["http2.createSecureServer()", (options: any) => http2.createSecureServer(options)], + ])("%s drops them as tls.createServer() does", async (_name, construct) => { + const server = construct({ key: "", cert: false, ca: 0, crl: "" }); + try { + await listen(server); + expect([server.key, server.cert, server.ca, server.crl]).toEqual([undefined, undefined, undefined, undefined]); + } finally { + server.close(); + } + }); + + it.each(["", false, 0])("with key and cert %p, the server serves its addContext() certificate", async falsy => { + const server: Server = createServer({ key: falsy, cert: falsy } as any, socket => socket.end("served")); + let client: TLSSocket | undefined; + try { + server.addContext("agent1", agent1); + client = connect({ port: await listen(server), host: "127.0.0.1", servername: "agent1", ca: ca1 }); + let data = ""; + client.setEncoding("utf8").on("data", chunk => (data += chunk)); + await once(client, "end"); + expect({ authorized: client.authorized, data }).toEqual({ authorized: true, data: "served" }); + } finally { + client?.destroy(); + server.close(); + } + }); + + it("a falsy ca judges a client as an absent ca does", async () => { + for (const ca of ["", false, 0, undefined]) { + const server: Server = createServer({ ...mtls, ca } as any); + try { + expect([ca, await judge(server, await listen(server))]).toEqual([ca, unknownIssuer]); + } finally { + server.close(); + } + } + }); + + it("setSecureContext() before listen() drops the CRL the constructor got", async () => { + const server: Server = createServer({ ...mtls, crl: revokesAgent3 }); + try { + server.setSecureContext({ ...mtls, crl: "" as any }); + expect(await judge(server, await listen(server))).toEqual(accepted); + expect((server as any).crl).toBeUndefined(); + } finally { + server.close(); + } + }); + + it("setSecureContext() on a listening server accepts each falsy value", async () => { + const server: any = createServer(identity); + try { + await listen(server); + for (const [name, value] of cells) { + server.setSecureContext({ ...identity, [name]: value }); + expect([name, value, server[name]]).toEqual([name, value, undefined]); + } + } finally { + server.close(); + } + }); + + it("setSecureContext() on a listening server stops the revocation checks until it gets a CRL again", async () => { + const server: Server = createServer({ ...mtls, crl: revokesAgent3 }); + try { + const port = await listen(server); + expect(await judge(server, port)).toEqual(revoked); + + server.setSecureContext({ ...mtls, crl: false as any }); + expect(await judge(server, port)).toEqual(accepted); + + server.setSecureContext({ ...mtls, crl: revokesAgent3 }); + expect(await judge(server, port)).toEqual(revoked); + } finally { + server.close(); + } + }); + + it("setSecureContext() on a listening server drops the CA until it gets one again", async () => { + const server: Server = createServer(mtls); + try { + const port = await listen(server); + expect(await judge(server, port)).toEqual(accepted); + + server.setSecureContext({ ...mtls, ca: "" as any }); + expect(await judge(server, port)).toEqual(unknownIssuer); + + server.setSecureContext(mtls); + expect(await judge(server, port)).toEqual(accepted); + } finally { + server.close(); + } + }); + + it("serves a socket handed in via server.emit('connection')", async () => { + const tlsServer: Server = createServer({ ...mtls, crl: 0 } as any); + const rawServer = net.createServer(raw => tlsServer.emit("connection", raw)); + try { + expect(await judge(tlsServer, await listen(rawServer))).toEqual(accepted); + } finally { + rawServer.close(); + tlsServer.close(); + } + }); + }); + it("still rejects an unverifiable client certificate when rejectUnauthorized is 0", async () => { // The server trusts no CA, so the client certificate cannot be verified; // Node's `rejectUnauthorized !== false` rule makes 0 behave like true and diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index 05e32e48d3f0..e96010578b2b 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -417,6 +417,40 @@ test("ca: [] skips the setDefaultCACertificates override (distinct from ca: unde } }); +test("setDefaultCACertificates() applies to a server whose ca is falsy, as to one with no ca", async () => { + // tls.Server counts a falsy ca as absent: + // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1451-L1454 + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f), "utf8"); + const agent1 = { key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }; + const prevCerts = tls.getCACertificates("default"); + tls.setDefaultCACertificates([keys("ca1-cert.pem")]); + try { + for (const ca of ["", false, 0]) { + const server = tls.createServer({ ...agent1, ca, requestCert: true, rejectUnauthorized: false } as any); + try { + const authorized = Promise.withResolvers(); + server.on("secureConnection", socket => { + authorized.resolve(socket.authorized); + socket.end(); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as import("net").AddressInfo).port; + // ca1, now a process default, issued the client's certificate. + const client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false, ...agent1 }); + await once(client, "secureConnect"); + expect([ca, await authorized.promise]).toEqual([ca, true]); + client.end(); + await once(client, "close"); + } finally { + server.close(); + } + } + } finally { + tls.setDefaultCACertificates(prevCerts); + } +}); + test("setDefaultCACertificates() applies to a server's client-cert verification (no explicit ca)", async () => { // The server path (setSecureContext -> Bun.listen) does not go through // InternalSecureContext; the process-default override must still apply so From abf551561b98a768d0f71619a1d38c639490a8a5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 06:28:18 +0000 Subject: [PATCH 152/174] test: no type errors in the tests this branch adds or changes main's test/ has had none since #44614. 235 came back with this branch: the tests it adds were never checked, and where a hunk of #44614 met a change of this branch the rebase kept this branch's side. Annotations, type arguments, casts, `!` and `import type` only: each of the 31 files transpiles to the same JavaScript as before. --- test/js/bun/http/bun-serve-ssl.test.ts | 42 ++--- test/js/bun/http/bun-server.test.ts | 4 +- test/js/bun/http/proxy.test.ts | 2 +- test/js/bun/http/serve-http3.test.ts | 28 ++-- test/js/bun/http/serve.test.ts | 7 +- .../module-graph-callbacks.test.ts | 4 +- test/js/bun/net/socket.test.ts | 11 +- .../js/bun/net/tls-fatal-error-closes.test.ts | 2 +- test/js/first_party/ws/ws.test.ts | 3 +- .../node/errors/error-code-messages.test.ts | 2 +- test/js/node/http/node-https-server.test.ts | 6 +- .../js/node/http2/node-http2-upgrade.test.mts | 6 +- test/js/node/net/node-net.test.ts | 1 + test/js/node/quic/quic-sni.test.ts | 8 +- test/js/node/tls/node-tls-cert.test.ts | 6 +- test/js/node/tls/node-tls-connect.test.ts | 51 +++--- test/js/node/tls/node-tls-context.test.ts | 27 ++-- .../tls/node-tls-duplex-end-verify.test.ts | 148 +++++++++++------- test/js/node/tls/node-tls-server.test.ts | 21 +-- ...ode-tls-set-session-after-start.fixture.ts | 15 +- test/js/node/tls/node-tls-upgrade.test.ts | 2 +- .../tls/node-tls-wrapped-socket-close.test.ts | 2 +- .../node/tls/tls-default-ciphers-fixture.ts | 4 +- test/js/node/tls/tls-handshake-alert-utils.ts | 2 +- test/js/node/tls/tls-syscall-fault.test.ts | 4 +- .../sql/sql-close-pending-connection.test.ts | 4 +- test/js/sql/tls-sql.test.ts | 10 +- test/js/web/fetch/fetch.tls.wildcard.test.ts | 5 +- test/js/web/websocket/proxy-test-utils.ts | 2 +- .../js/web/websocket/websocket-client.test.ts | 8 +- .../websocket/websocket-close-code.test.ts | 2 +- 31 files changed, 251 insertions(+), 188 deletions(-) diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 269fed9b6172..a549c0be5b15 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -171,7 +171,13 @@ describe("Bun.serve per-serverName client certificate policy", () => { cert: readFileSync(join(tlsFixtures, "agent1-cert.pem"), "utf8"), }; - type ClientOptions = { key?: string; cert?: string; session?: Buffer; minVersion?: string; maxVersion?: string }; + type ClientOptions = { + key?: string; + cert?: string; + session?: Buffer; + minVersion?: tls.SecureVersion; + maxVersion?: tls.SecureVersion; + }; function request(port: number, servername: string, clientTls: ClientOptions = {}) { const { promise, resolve } = Promise.withResolvers<{ status: string; session: Buffer | undefined }>(); const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false, ...clientTls }); @@ -284,17 +290,17 @@ describe("Bun.serve per-serverName client certificate policy", () => { const outcomes = await Promise.all( spellings.map(async name => [ name, - (await request(server.port, name, pinned)).status, - (await request(server.port, name, { ...pinned, ...untrustedClient })).status, - (await request(server.port, name, { ...pinned, ...trustedClient })).status, + (await request(server.port!, name, pinned)).status, + (await request(server.port!, name, { ...pinned, ...untrustedClient })).status, + (await request(server.port!, name, { ...pinned, ...trustedClient })).status, ]), ); const closed = "connection closed without a response"; expect(outcomes).toEqual(spellings.map(name => [name, closed, closed, "HTTP/1.1 200 OK"])); // Only A-Z folds: U+00E9 and U+00C9 differ by 0x20 in their last UTF-8 byte and stay two names. expect({ - registered: (await request(server.port, "\u00e9.example", pinned)).status, - other: (await request(server.port, "\u00c9.example", pinned)).status, + registered: (await request(server.port!, "\u00e9.example", pinned)).status, + other: (await request(server.port!, "\u00c9.example", pinned)).status, }).toEqual({ registered: closed, other: "HTTP/1.1 200 OK" }); }, ); @@ -345,7 +351,7 @@ describe("Bun.serve per-serverName client certificate policy", () => { const { promise, resolve, reject } = Promise.withResolvers(); const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false }); socket.on("secureConnect", () => { - resolve(socket.getPeerCertificate()?.subject?.CN ?? "-"); + resolve((socket.getPeerCertificate()?.subject?.CN ?? "-") as string); socket.destroy(); }); socket.on("error", reject); @@ -396,10 +402,10 @@ describe("Bun.serve per-serverName client certificate policy", () => { tls: tlsConfig, fetch: req => new Response(`served ${req.headers.get("host")}`), }); - const servedCN = await peerCN(server.port, "admin.example.com"); - const { status: gatedNoCert } = await request(server.port, "admin.example.com"); - const { status: gatedTrustedCert } = await request(server.port, "admin.example.com", trustedClient); - const { status: defaultNoCert } = await request(server.port, "localhost"); + const servedCN = await peerCN(server.port!, "admin.example.com"); + const { status: gatedNoCert } = await request(server.port!, "admin.example.com"); + const { status: gatedTrustedCert } = await request(server.port!, "admin.example.com", trustedClient); + const { status: defaultNoCert } = await request(server.port!, "localhost"); expect({ servedCN, gatedNoCert, gatedTrustedCert, defaultNoCert }).toEqual({ servedCN: "agent3", gatedNoCert: "connection closed without a response", @@ -420,8 +426,8 @@ describe("Bun.serve per-serverName client certificate policy", () => { ], fetch: () => new Response("served"), }); - const servedCN = await peerCN(server.port, "admin.example.com"); - const { status: noCert } = await request(server.port, "admin.example.com"); + const servedCN = await peerCN(server.port!, "admin.example.com"); + const { status: noCert } = await request(server.port!, "admin.example.com"); expect({ servedCN, noCert }).toEqual({ servedCN: "agent3", noCert: "HTTP/1.1 200 OK" }); }); @@ -447,7 +453,7 @@ describe("Bun.serve per-serverName client certificate policy", () => { // The loop runs a few handshakes per iteration and queues the rest, so // ClientHellos sent in one go are not all processed when the first // handshake completes. - for (let i = 0; i < 32; i++) raws.push(net.connect({ host: "127.0.0.1", port: server.port })); + for (let i = 0; i < 32; i++) raws.push(net.connect({ host: "127.0.0.1", port: server.port! })); for (const raw of raws) raw.on("error", () => {}); await Promise.all(raws.map(raw => once(raw, "connect"))); @@ -458,7 +464,7 @@ describe("Bun.serve per-serverName client certificate policy", () => { let certificate = "no handshake"; let received = ""; const socket = tls.connect({ socket: raw, servername: "admin.example.com", rejectUnauthorized: false }, () => { - certificate = socket.getPeerCertificate().subject.CN; + certificate = socket.getPeerCertificate().subject.CN as string; firstHandshake.resolve(); socket.write("GET / HTTP/1.1\r\nHost: admin.example.com\r\nConnection: close\r\n\r\n"); }); @@ -499,7 +505,7 @@ test.each(["Bun.serve", "Bun.listen"] as const)("%s serves every identity of key function served(options: tls.ConnectionOptions) { const { promise, resolve } = Promise.withResolvers(); const socket = tls.connect({ port: server.port, host: "127.0.0.1", rejectUnauthorized: false, ...options }, () => { - resolve(socket.getPeerCertificate().subject.CN); + resolve(socket.getPeerCertificate().subject.CN as string); socket.destroy(); }); socket.on("error", e => resolve((e as NodeJS.ErrnoException).code!)); @@ -533,7 +539,7 @@ test.each(["Bun.serve", "Bun.listen"] as const)( : Bun.listen({ port: 0, hostname: "127.0.0.1", tls: identities, socket: { data() {} } }); const { promise, resolve, reject } = Promise.withResolvers(); const socket = tls.connect({ port: server.port, host: "127.0.0.1", rejectUnauthorized: false }, () => - resolve(socket.getPeerCertificate().subject.CN), + resolve(socket.getPeerCertificate().subject.CN as string), ); socket.on("error", reject); try { @@ -565,7 +571,7 @@ test("keyFile/certFile/caFile/dhParamsFile reject a path with a NUL byte instead expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls: options, socket: { data() {} } })).toThrow( thrown(field), ); - expect(() => tls.createSecureContext(options as tls.SecureContextOptions)).toThrow(thrown(field)); + expect(() => tls.createSecureContext(options as Bun.TLSOptions as tls.SecureContextOptions)).toThrow(thrown(field)); } expect(() => Bun.serve({ port: 0, tls: { keyFile: "", certFile }, fetch: () => new Response() })).toThrow( "Unable to access keyFile path", diff --git a/test/js/bun/http/bun-server.test.ts b/test/js/bun/http/bun-server.test.ts index 681936a5fa0c..64294ec1bf3f 100644 --- a/test/js/bun/http/bun-server.test.ts +++ b/test/js/bun/http/bun-server.test.ts @@ -2246,8 +2246,8 @@ test("reading an upload whose client went away before the first read rejects", a }, }); const socket = await Bun.connect({ - hostname: server.hostname, - port: server.port, + hostname: server.hostname!, + port: server.port!, socket: { open(socket) { socket.write("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\npartial"); diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 2e903c86a8f3..3090ca41588c 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -3422,7 +3422,7 @@ test("an https origin that ends the TLS handshake with close_notify fails the tu // so only the alert says that no TLS session will come. const closeNotify = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); const established = Buffer.from("HTTP/1.1 200 Connection Established\r\n\r\n"); - const outcomes = []; + const outcomes: unknown[] = []; // "with the reply": the alert is in the same write as the reply to CONNECT, ahead of the ClientHello. for (const alert of ["after the ClientHello", "with the reply"]) { const sockets: net.Socket[] = []; diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index 73ffdb169ade..cf4186ba1005 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -4,7 +4,8 @@ import { createHash, createPrivateKey, randomBytes, X509Certificate } from "cryp import { readFileSync } from "fs"; import { bunEnv, bunExe, isASAN, tempDir, tls } from "harness"; import { once } from "node:events"; -import { connect, QuicEndpoint } from "node:quic"; +import type { SocketAddress } from "node:net"; +import { connect, QuicEndpoint, type SessionOptions } from "node:quic"; import { connect as connectTLS } from "node:tls"; import { join } from "path"; @@ -1917,9 +1918,9 @@ describe("Bun.serve HTTP/3 request validation", () => { }; const lenient = { servername: "lenient.example.com" }; const results = { - defaultNoCert: await h3Exchange(server.port, requestHeaders("/")), - lenientNoCert: await h3Exchange(server.port, requestHeaders("/", { ":authority": lenient.servername }), lenient), - lenientUnverifiable: await h3Exchange(server.port, requestHeaders("/", { ":authority": lenient.servername }), { + defaultNoCert: await h3Exchange(server.port!, requestHeaders("/")), + lenientNoCert: await h3Exchange(server.port!, requestHeaders("/", { ":authority": lenient.servername }), lenient), + lenientUnverifiable: await h3Exchange(server.port!, requestHeaders("/", { ":authority": lenient.servername }), { ...lenient, ...agent2, }), @@ -2080,11 +2081,14 @@ describe("Bun.serve HTTP/3 SNI", () => { // fetch() lowercases the URL host. node:quic sends `servername` as written // and exposes the certificate the server picked. async function servedCN(port: number, servername: string): Promise { - const session = await connect({ address: "127.0.0.1", port }, { alpn: "h3", servername, verifyPeer: "manual" }); + const session = await connect( + { address: "127.0.0.1", port } as SocketAddress, + { alpn: "h3", servername, verifyPeer: "manual" } as SessionOptions, + ); try { await session.opened; const cert = session.peerCertificate; - const x509 = cert instanceof X509Certificate ? cert : new X509Certificate(Buffer.from(cert)); + const x509 = cert instanceof X509Certificate ? cert : new X509Certificate(Buffer.from(cert as any)); return x509.subject.match(/CN=([^\s,]+)/)![1]; } finally { await session.close(); @@ -2103,9 +2107,9 @@ describe("Bun.serve HTTP/3 SNI", () => { fetch: () => new Response("ok"), }); const served = { - admin: await servedCN(server.port, "admin.example.com"), - dottedAdmin: await servedCN(server.port, "admin.example.com."), - other: await servedCN(server.port, "other.example.com"), + admin: await servedCN(server.port!, "admin.example.com"), + dottedAdmin: await servedCN(server.port!, "admin.example.com."), + other: await servedCN(server.port!, "other.example.com"), }; // The TCP listener selects the same entry for these names. expect(served).toEqual({ admin: "agent3", dottedAdmin: "agent3", other: "agent1" }); @@ -2134,10 +2138,10 @@ describe("Bun.serve HTTP/3 SNI", () => { "wild.example", "other.example", ]) { - served[servername] = await servedCN(server.port, servername); + served[servername] = await servedCN(server.port!, servername); const socket = connectTLS({ host: "127.0.0.1", port: server.port, servername, rejectUnauthorized: false }); await once(socket, "secureConnect"); - overTCP[servername] = socket.getPeerCertificate().subject.CN; + overTCP[servername] = socket.getPeerCertificate().subject.CN as string; socket.destroy(); } expect(overTCP).toEqual(served); @@ -2179,7 +2183,7 @@ describe("Bun.serve HTTP/3 SNI", () => { "a.Gated.Example.", ]; const exchange = (servername: string, options = {}) => - h3Exchange(server.port, requestHeaders("/"), { servername, ...options }); + h3Exchange(server.port!, requestHeaders("/"), { servername, ...options }); const outcomes = await Promise.all( spellings.map(async name => [ name, diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 9a6bd96dbcb0..a32d0998f37a 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -3332,7 +3332,12 @@ it.concurrent.each([ return new Response(); }, }); - const client = nodeTls.connect({ port: server.port, host: "127.0.0.1", ca: tls.cert, allowHalfOpen: true }); + const client = nodeTls.connect({ + port: server.port, + host: "127.0.0.1", + ca: tls.cert, + allowHalfOpen: true, + } as nodeTls.ConnectionOptions); client.on("error", aborted.reject); try { await once(client, "secureConnect"); diff --git a/test/js/bun/module-graph/module-graph-callbacks.test.ts b/test/js/bun/module-graph/module-graph-callbacks.test.ts index 88b7f88eb05f..7235934d0f47 100644 --- a/test/js/bun/module-graph/module-graph-callbacks.test.ts +++ b/test/js/bun/module-graph/module-graph-callbacks.test.ts @@ -281,8 +281,8 @@ const dir = String( ); afterAll(() => rmSync(dir, { recursive: true, force: true })); -let hostHttp: Bun.Server; -let hostHttps: Bun.Server; +let hostHttp: Bun.Server; +let hostHttps: Bun.Server; beforeAll(() => { hostHttps = Bun.serve({ port: 0, diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 0f5156e2b781..eae574a4b794 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1623,7 +1623,7 @@ describe("TLS write() that ends short while another TLS socket is stalled", () = using server = Bun.listen({ hostname: "127.0.0.1", port: 0, - tls: { ...tls, maxVersion }, + tls: { ...tls, maxVersion } as Bun.TLSOptions, socket: side(accepted ? writer : reader), }); const connect = () => @@ -6162,9 +6162,9 @@ describe("a fatal TLS alert that arrives after the handshake", () => { tls: { ca: tls.cert }, socket: { handshake: (socket, success) => events.push(`handshake ${success} ${socket.getTLSVersion()}`), - data: () => events.push("data"), + data: (): any => events.push("data"), error: withErrorHandler - ? (_socket, err) => events.push(`error ${(err as Error & { code?: string }).code}: ${err.message}`) + ? (_socket, err): any => events.push(`error ${(err as Error & { code?: string }).code}: ${err.message}`) : undefined, close: (_socket, err) => { events.push(`close ${err}`); @@ -6219,7 +6219,7 @@ describe("tls.secureContext with no other tls option", () => { // A client that sends no ClientHello is sent nothing. const raw = net.connect(listener.port, "127.0.0.1"); const received: Buffer[] = []; - raw.on("data", chunk => received.push(chunk)); + raw.on("data", (chunk: Buffer) => received.push(chunk)); await once(raw, "connect"); raw.end(); await once(raw, "close"); @@ -6288,7 +6288,8 @@ describe.concurrent("Bun.listen() with tls.secureContext and the client certific }; const native = (policy: object) => (createSecureContext({ key, cert, ca, ...policy }) as any).context; const socket = { - handshake: (socket: Socket) => void socket.end(socket.getPeerCertificate()?.subject?.CN ?? "no certificate"), + handshake: (socket: Socket) => + void socket.end((socket.getPeerCertificate()?.subject?.CN ?? "no certificate") as string), data() {}, error() {}, }; diff --git a/test/js/bun/net/tls-fatal-error-closes.test.ts b/test/js/bun/net/tls-fatal-error-closes.test.ts index 275d2e13aeb9..f71abac77fb8 100644 --- a/test/js/bun/net/tls-fatal-error-closes.test.ts +++ b/test/js/bun/net/tls-fatal-error-closes.test.ts @@ -91,7 +91,7 @@ async function serverBehindRelay( }; socket.on("error", () => {}); socket.on("close", () => raw.destroy()); - socket.on("data", chunk => onData(socket, chunk, fault)); + socket.on("data", (chunk: Buffer) => onData(socket, chunk, fault)); }); const relay = await faultRelay(backend.port, "client"); return { diff --git a/test/js/first_party/ws/ws.test.ts b/test/js/first_party/ws/ws.test.ts index 94c02f249202..c0257e72e5b8 100644 --- a/test/js/first_party/ws/ws.test.ts +++ b/test/js/first_party/ws/ws.test.ts @@ -11,7 +11,8 @@ import net, { AddressInfo, connect } from "net"; import fs from "node:fs"; import path from "node:path"; import tls, { type TLSSocket } from "tls"; -import { Server, WebSocket, WebSocketServer } from "ws"; +// @ts-expect-error @types/ws: no ESM `Server` +import { type ClientOptions, Server, WebSocket, WebSocketServer } from "ws"; const strings = [ { diff --git a/test/js/node/errors/error-code-messages.test.ts b/test/js/node/errors/error-code-messages.test.ts index ca69ceacd6bf..c376420b6a07 100644 --- a/test/js/node/errors/error-code-messages.test.ts +++ b/test/js/node/errors/error-code-messages.test.ts @@ -301,7 +301,7 @@ test("ERR_IPC_CHANNEL_CLOSED message from process.send() in the child", async () cmd: [bunExe(), "child.js"], env: bunEnv, cwd: String(dir), - stdio: ["ignore", "pipe", "pipe", "ipc"], + stdio: ["ignore", "pipe", "pipe", "ipc" as any], ipc() {}, }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index b363c4fca5dc..3c4f3b72bbba 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -46,7 +46,7 @@ describe.concurrent("https.Server with no usable key and cert", () => { test.each<[string, (handler: http.RequestListener) => http.Server]>([ ["createServer(requestListener)", handler => https.createServer(handler)], ["createServer({}, requestListener)", handler => https.createServer({}, handler)], - ["createServer(undefined, requestListener)", handler => https.createServer(undefined, handler)], + ["createServer(undefined, requestListener)", handler => https.createServer(undefined as any, handler)], ["createServer({ key: undefined, cert: undefined })", handler => https.createServer({ key: undefined, cert: undefined }, handler)], // prettier-ignore ["createServer({ requestCert: true })", handler => https.createServer({ requestCert: true }, handler)], ["new https.Server({}, requestListener)", handler => new https.Server({}, handler)], @@ -94,7 +94,7 @@ test.skipIf(!process.versions.bun).each<[string, () => object]>([ describe("new https.Server() applies the ALPN defaults of createServer()", () => { function wire(protocols: string[]) { const out = {} as { ALPNProtocols: Buffer }; - tls.convertALPNProtocols(protocols, out); + (tls as any).convertALPNProtocols(protocols, out); return out.ALPNProtocols; } @@ -277,7 +277,7 @@ describe("https.createServer forwards every TLS server option", () => { await using server = https.createServer({ ...validCert, [name]: value }); expect(await handshake(await listen(server), {})).toStartWith("TLS_"); if (!process.versions.bun) return; - await using viaHttp = http.createServer({ ...validCert, [name]: value }); + await using viaHttp = http.createServer({ ...validCert, [name]: value } as http.ServerOptions); expect(await handshake(await listen(viaHttp), {})).toStartWith("TLS_"); }); diff --git a/test/js/node/http2/node-http2-upgrade.test.mts b/test/js/node/http2/node-http2-upgrade.test.mts index f56e059fd97e..118faf531820 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -335,7 +335,7 @@ describe("HTTP/2 upgrade — the client closes its side first", () => { const log: string[] = []; const first = Buffer.alloc(16 * 1024 * 1024, "a"); const writing = Promise.withResolvers(); - h2Server.on("error", err => log.push(`server 'error': ${err.code}`)); + h2Server.on("error", (err: NodeJS.ErrnoException) => log.push(`server 'error': ${err.code}`)); h2Server.on("unknownProtocol", socket => { socket.on("error", (err: NodeJS.ErrnoException) => log.push(`'error': ${err.code}`)); socket.resume(); @@ -720,7 +720,9 @@ describe("HTTP/2 upgrade — failed handshake", () => { client.write(Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00])), ); // BoringSSL reads the alert as the peer's close. OpenSSL refuses an alert ahead of the ClientHello. - const expected = process.features.openssl_is_boringssl ? "ECONNRESET" : "ERR_SSL_UNEXPECTED_MESSAGE"; + const expected = (process.features as { openssl_is_boringssl?: boolean }).openssl_is_boringssl + ? "ECONNRESET" + : "ERR_SSL_UNEXPECTED_MESSAGE"; assert.deepStrictEqual({ event, code }, { event: "tlsClientError", code: expected }); }); }); diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index e2df05ba14bc..957a90819872 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -31,6 +31,7 @@ import { Server, Socket, SocketAddress, + // @ts-expect-error legacy alias Stream, } from "node:net"; import { join } from "node:path"; diff --git a/test/js/node/quic/quic-sni.test.ts b/test/js/node/quic/quic-sni.test.ts index a67c46a71be8..af944e671366 100644 --- a/test/js/node/quic/quic-sni.test.ts +++ b/test/js/node/quic/quic-sni.test.ts @@ -231,7 +231,7 @@ test("a client verifies an Ed25519 server certificate", async () => { sni: { "*": { keys: [key], certs: [cert] } }, alpn: ["quic-test"], }); - const session = await connect(server.address, { alpn: "quic-test", servername: "localhost", ca: [cert] }); + const session = await connect(server.address!, { alpn: "quic-test", servername: "localhost", ca: [cert] }); try { expect(await session.opened).toMatchObject({ validationErrorCode: undefined, validationErrorReason: undefined }); } finally { @@ -243,6 +243,10 @@ test("connect() refuses a servername with a NUL byte", async () => { await using server = await listen(ignoreErrors, { sni: { "*": identity1 }, alpn: ["quic-test"] }); expect( (async () => - connect(server.address, { alpn: "quic-test", servername: "agent2.example\0evil", verifyPeer: "manual" }))(), + connect(server.address!, { + alpn: "quic-test", + servername: "agent2.example\0evil", + verifyPeer: "manual", + } as SessionOptions))(), ).rejects.toThrow("servername must not contain null bytes"); }); diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index 92f6cebec39d..04ac8fa1a308 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -11,14 +11,14 @@ const clientTls = { key: readFileSync(join(import.meta.dir, "fixtures", "ec10-key.pem"), "utf8"), cert: readFileSync(join(import.meta.dir, "fixtures", "ec10-cert.pem"), "utf8"), ca: readFileSync(join(import.meta.dir, "fixtures", "ca5-cert.pem"), "utf8"), -}; +} as Certs; // The copy of ca2 under ./fixtures has a 1024-bit key, which Node rejects as "CA certificate key too weak". const upstreamKeys = join(import.meta.dir, "..", "test", "fixtures", "keys"); const serverTls = { key: readFileSync(join(upstreamKeys, "agent10-key.pem"), "utf8"), cert: readFileSync(join(upstreamKeys, "agent10-cert.pem"), "utf8"), ca: readFileSync(join(upstreamKeys, "ca2-cert.pem"), "utf8"), -}; +} as Certs; function split(file: any, into: any) { const certs = /([^]*END CERTIFICATE-----\r?\n)(-----BEGIN[^]*)/.exec(file) as RegExpExecArray; @@ -196,7 +196,7 @@ it("Request cert from TLS1.3 client that doesn't have one.", async () => { await closed; // BoringSSL and OpenSSL name the same alert differently. - const alert = process.features.openssl_is_boringssl + const alert = (process.features as { openssl_is_boringssl?: boolean }).openssl_is_boringssl ? "ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED" : "ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED"; expect(events).toEqual(["secureConnect", `error ${alert}`, "end", "close false"]); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 1c55d13aaf52..5c33ea224b1a 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -281,7 +281,7 @@ async function withFaultProxy( toServer = net.connect((upstream.address() as AddressInfo).port, "127.0.0.1"); sockets.push(c, toServer); c.on("data", chunk => toServer!.write(chunk)); - toServer.on("data", chunk => { + toServer.on("data", (chunk: Buffer) => { if (appendOnce) { chunk = Buffer.concat([chunk, appendOnce]); appendOnce = undefined; @@ -477,7 +477,7 @@ for (const { name, connect } of tests) { expect(result).toEqual({ kind: "error", - code: process.features.openssl_is_boringssl + code: (process.features as { openssl_is_boringssl?: boolean }).openssl_is_boringssl ? "ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE" : "ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE", }); @@ -867,7 +867,7 @@ describe("a fatal post-handshake SSL error over a Duplex", () => { // Good data and the bad record arrive in one chunk. The engine delivers // the data first, the listener's write hits the now-fatal SSL, and the // read's error must still be the one that surfaces. - const result = await withFaultProxy({ connect: duplexProxy }, async peers => { + const result = await withFaultProxy({ connect: duplexProxy as typeof tlsConnect }, async peers => { const { client, server, appendToNextServerChunk } = peers; const events = faultEvents(client, server); client.on("data", () => client.write("back")); @@ -885,12 +885,15 @@ describe("a fatal post-handshake SSL error over a Duplex", () => { }); it("sends its own fatal alert to the peer", async () => { - const code = await withFaultProxy({ connect: duplexProxy }, async ({ client, server, toClient }) => { - client.on("error", () => {}); - const alerted = once(server, "error"); - toClient.write(BAD_RECORD); - return (await alerted)[0].code; - }); + const code = await withFaultProxy( + { connect: duplexProxy as typeof tlsConnect }, + async ({ client, server, toClient }) => { + client.on("error", () => {}); + const alerted = once(server, "error"); + toClient.write(BAD_RECORD); + return (await alerted)[0].code; + }, + ); expect(code).toBe(ALERT_BAD_RECORD_MAC); }); @@ -1332,7 +1335,7 @@ describe("a TLS socket over a Duplex transport reads it with backpressure", () = rejectUnauthorized: false, // Only an onread socket stops its handle from pause(). onread: { buffer: Buffer.alloc(64), callback: () => {} }, - }); + } as tls.ConnectionOptions); using _ = destroyedOnExit([client, server]); client.pause(); await Promise.race([ @@ -2727,7 +2730,7 @@ describe("a TLS server wrap over a Duplex transport whose peer ends the handshak // as the peer's close at every point of the handshake. OpenSSL refuses one ahead of the ClientHello and reads one // behind it as the end of the stream. The client side runs in node-tls-duplex-end-verify.test.ts. const closeNotify = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); - const isBoringSSL = process.features.openssl_is_boringssl; + const isBoringSSL = (process.features as { openssl_is_boringssl?: boolean }).openssl_is_boringssl; const aheadOfClientHello = isBoringSSL ? "ECONNRESET" : "ERR_SSL_UNEXPECTED_MESSAGE"; const serverContext = (options: tls.SecureContextOptions = {}) => ({ isServer: true, @@ -3758,7 +3761,7 @@ describe("a TLS 1.3 Bun.connect client that closes once its handshake is done", const fromClient: Buffer[] = []; const relay = net.createServer(downstream => { const upstream = net.connect(listener.port, "127.0.0.1"); - downstream.on("data", chunk => { + downstream.on("data", (chunk: Buffer) => { fromClient.push(chunk); upstream.write(chunk); }); @@ -4539,7 +4542,7 @@ describe("how a TLS client's way of closing reaches the server", () => { }); it.each(rows)("%s %s", async (version, mode, expected) => { - expect(await closeReport(mode, version)).toEqual(expected); + expect(await closeReport(mode, version)).toEqual(expected); }); // node gives this verdict too. It also reports the junk record as an error of the socket. @@ -4588,7 +4591,7 @@ describe("a server that turns the client down once its handshake is done", () => ] as const; it.each(rows)("%s %s", async (version, mode, expected) => { - expect(await refuseReport(mode, version)).toEqual(expected); + expect(await refuseReport(mode, version)).toEqual(expected); }); it.skipIf(!nodeExe())("node gives the same reports", async () => { @@ -5255,7 +5258,7 @@ describe("signature algorithms a peer may sign with", () => { const server = net.createServer(socket => { let hello = Buffer.alloc(0); socket.on("error", () => {}); - socket.on("data", chunk => { + socket.on("data", (chunk: Buffer) => { hello = Buffer.concat([hello, chunk]); if (hello.length < 5 || hello.length < 5 + hello.readUInt16BE(3)) return; // record header, handshake header, version, random; then session id, cipher suites, compression methods @@ -5516,7 +5519,7 @@ describe("tls.connect() attaches onConnectEnd to 'end' exactly once", () => { rejectUnauthorized: false, autoSelectFamily: true, lookup: lookupIPv6LoopbackFirst, - }); + } as tls.ConnectionOptions); const counts = await countsThroughHandshake(socket); expect({ ...counts, attempted: socket.autoSelectFamilyAttemptedAddresses }).toEqual({ duringSecureConnect: 1, @@ -5574,12 +5577,12 @@ describe("tls.connect() attaches onConnectEnd to 'end' exactly once", () => { rejectUnauthorized: false, autoSelectFamily: true, lookup: lookupIPv6LoopbackFirst, - }); + } as tls.ConnectionOptions); try { - const received = Promise.withResolvers(); + const received = Promise.withResolvers(); server.once("secureConnection", serverSide => received.resolve(serverSide.servername)); socket.on("error", received.reject); - socket.once("connectionAttemptFailed", () => socket.setServername("retry.example")); + socket.once("connectionAttemptFailed", () => (socket as any).setServername("retry.example")); expect(await received.promise).toBe("retry.example"); expect(onConnectEndCount(socket)).toBe(0); } finally { @@ -5762,7 +5765,7 @@ describe("tls.connect({ socket }) over a net.Socket that is still connecting", ( raw.on("connect", () => log.push("wrapped socket's later 'connect' listener")); client.on("connect", () => { log.push( - `connect connecting=${client.connecting} pending=${client.pending} secureConnecting=${client.secureConnecting}`, + `connect connecting=${client.connecting} pending=${client.pending} secureConnecting=${(client as any).secureConnecting}`, ); client.write("from connect;"); }); @@ -5931,7 +5934,7 @@ describe("a setServername() made before TLSSocket#connect() is the SNI of the ha const NAME = "set.before.connect"; async function names(options: (port: number) => net.TcpSocketConnectOpts) { - const seen = Promise.withResolvers(); + const seen = Promise.withResolvers(); await using server = tls.createServer(COMMON_CERT_, socket => { seen.resolve(socket.servername); socket.end(); @@ -5940,7 +5943,7 @@ describe("a setServername() made before TLSSocket#connect() is the SNI of the ha // @ts-expect-error @types/node requires a socket const socket: TLSSocket = new TLSSocket(undefined, { rejectUnauthorized: false }); try { - socket.setServername(NAME); + (socket as any).setServername(NAME); socket.connect(options(portOf(server)), function (this: TLSSocket) { // @ts-expect-error not in @types/node this._start(); @@ -6257,7 +6260,7 @@ describe("over a duplex, 'session' is emitted before the data that followed it o held: Buffer[] | null = null; constructor(readonly raw: net.Socket) { super(); - raw.on("data", chunk => { + raw.on("data", (chunk: Buffer) => { if (this.held) this.held.push(chunk); else this.push(chunk); }); @@ -6381,7 +6384,7 @@ describe("over a duplex, 'session' is emitted before the data that followed it o it("an HTTP/2 request completes over a Duplex that emits 'drain' in its _write()", async () => { const server = http2.createSecureServer(COMMON_CERT_); - server.on("stream", stream => { + server.on("stream", (stream: http2.ServerHttp2Stream) => { stream.respond({ ":status": 200 }); stream.end("ok"); }); diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index a0287cc56d02..ac31c5eb5377 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -421,7 +421,7 @@ describe("tls.Server", () => { let received = ""; const socket = tls.connect( { socket: raw, servername: servernames[i], rejectUnauthorized: false, key: agent3Key, cert: agent3Cert }, - () => (certificate = socket.getPeerCertificate().subject.CN), + () => (certificate = socket.getPeerCertificate().subject.CN as string), ); socket.on("data", chunk => (received += chunk)); socket.on("error", () => {}); @@ -729,7 +729,7 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)("session resumption across SNI co describe.each(["TLSv1.2", "TLSv1.3"] as const)("ALPN after SNI selected a SecureContext (%s)", version => { const defaults = { key: agent2Key, cert: agent2Cert }; const selected = { key: agent1Key, cert: agent1Cert }; - type SNICb = (err: Error | null, ctx?: unknown) => void; + type SNICb = (err: Error | null, ctx?: any) => void; const sync = (_: string, cb: SNICb) => cb(null, tls.createSecureContext(selected)); const deferred = (_: string, cb: SNICb) => void setImmediate(cb, null, tls.createSecureContext(selected)); @@ -1011,7 +1011,7 @@ describe("SNI matching is case-insensitive", () => { function servedCN(port: number, servername: string) { const { promise, resolve, reject } = Promise.withResolvers(); const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false }, () => { - resolve(socket.getPeerCertificate()?.subject?.CN); + resolve(socket.getPeerCertificate()?.subject?.CN as string | undefined); socket.end(); }); socket.on("error", reject); @@ -1123,10 +1123,10 @@ describe("SNI matching is case-insensitive", () => { fetch: () => new Response("OK"), }); expect({ - upper: await servedCN(server.port, "A.EXAMPLE.COM"), - mixed: await servedCN(server.port, "a.Example.com"), - wildcardUpper: await servedCN(server.port, "B.TEST.COM"), - noMatch: await servedCN(server.port, "other.example.org"), + upper: await servedCN(server.port!, "A.EXAMPLE.COM"), + mixed: await servedCN(server.port!, "a.Example.com"), + wildcardUpper: await servedCN(server.port!, "B.TEST.COM"), + noMatch: await servedCN(server.port!, "other.example.org"), }).toEqual({ upper: "agent1", mixed: "agent1", @@ -1552,11 +1552,10 @@ it("getCiphers() lists the supported cipher names, lower-cased and sorted", () = it("tls.SecureContext is the class of what createSecureContext() returns", () => { const context = tls.createSecureContext({}); - expect(context).toBeInstanceOf(tls.SecureContext); - expect(context.constructor).toBe(tls.SecureContext); - expect(Object.keys(tls.SecureContext.prototype)).toEqual([]); - expect({}).not.toBeInstanceOf(tls.SecureContext); - expect(new tls.SecureContext()).toBeInstanceOf(tls.SecureContext); - // @ts-expect-error the types only admit `new` - expect(tls.SecureContext()).toBeInstanceOf(tls.SecureContext); + expect(context).toBeInstanceOf((tls as any).SecureContext); + expect(context.constructor).toBe((tls as any).SecureContext); + expect(Object.keys((tls as any).SecureContext.prototype)).toEqual([]); + expect({}).not.toBeInstanceOf((tls as any).SecureContext); + expect(new (tls as any).SecureContext()).toBeInstanceOf((tls as any).SecureContext); + expect((tls as any).SecureContext()).toBeInstanceOf((tls as any).SecureContext); }); diff --git a/test/js/node/tls/node-tls-duplex-end-verify.test.ts b/test/js/node/tls/node-tls-duplex-end-verify.test.ts index a0f23f631f74..670bd4cabd80 100644 --- a/test/js/node/tls/node-tls-duplex-end-verify.test.ts +++ b/test/js/node/tls/node-tls-duplex-end-verify.test.ts @@ -1163,7 +1163,7 @@ function recordClient(client, events, closed) { // end(), and its peer makes the handshake fail. `peer(otherEnd)` plays the peer on the other end of the Duplex, and // can return a function that closes what it opened. Returns the ordered events of the client. async function failedHandshakeOverDuplex(rejectUnauthorized, peer) { - const events = []; + const events: string[] = []; const { promise, resolve } = Promise.withResolvers(); const [transport, otherEnd] = duplexPair(); transport.on("error", () => {}); @@ -1182,9 +1182,11 @@ const SERVER_KEY_EXCHANGE = 12; // A TLS 1.2 server with a trusted certificate for "agent1". The relay in front of it changes the last byte of the // signature in ServerKeyExchange, so the peer proves no possession of the key. The chain is already verified then. async function trustedChainWithBadKeyProof(otherEnd) { - const server = tls.createServer({ key, cert, maxVersion: "TLSv1.2" }, socket => socket.on("error", () => {})); + const server = tls.createServer({ key, cert, maxVersion: "TLSv1.2" }, socket => + socket.on("error", () => {}), + ) as Listening; server.on("tlsClientError", () => {}); - await new Promise(listening => server.listen(0, "127.0.0.1", listening)); + await new Promise(listening => server.listen(0, "127.0.0.1", listening)); const upstream = net.connect(server.address().port, "127.0.0.1"); upstream.on("error", () => {}); otherEnd.on("data", chunk => upstream.write(chunk)); @@ -1230,9 +1232,15 @@ for (const [failure, peer] of [ // A TLS 1.2 server with a trusted certificate that requires a client certificate. The client has none, so the server's // alert arrives after the client verified the chain. async function serverThatRequiresClientCert(otherEnd) { - const server = tls.createServer({ key, cert, ca: clientCA, requestCert: true, maxVersion: "TLSv1.2" }); + const server = tls.createServer({ + key, + cert, + ca: clientCA, + requestCert: true, + maxVersion: "TLSv1.2", + }) as Listening; server.on("tlsClientError", () => {}); - await new Promise(listening => server.listen(0, "127.0.0.1", listening)); + await new Promise(listening => server.listen(0, "127.0.0.1", listening)); const upstream = net.connect(server.address().port, "127.0.0.1"); upstream.on("error", () => {}); otherEnd.pipe(upstream).pipe(otherEnd); @@ -1254,7 +1262,7 @@ const ALERT = 0x15; const FATAL = 2; test("over a Duplex: a client that refuses the server's key proof sends its fatal alert", async () => { - const sent = []; + const sent: number[][] = []; await failedHandshakeOverDuplex(true, async otherEnd => { eachRecord(otherEnd, record => sent.push([record[0], record[5]])); return trustedChainWithBadKeyProof(otherEnd); @@ -1271,11 +1279,11 @@ test("over a Duplex: a server that shares no cipher with the client sends its fa isServer: true, secureContext: tls.createSecureContext({ key, cert }), }); - const serverFailed = new Promise(resolve => server.once("error", resolve)); + const serverFailed = new Promise(resolve => server.once("error", resolve)); const client = tls.connect({ socket: otherEnd, maxVersion: "TLSv1.2", ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256" }); - const clientFailed = new Promise(resolve => client.once("error", resolve)); + const clientFailed = new Promise(resolve => client.once("error", resolve)); assert.strictEqual((await serverFailed).code, "ERR_SSL_NO_SHARED_CIPHER"); - assert.match((await clientFailed).code, /^ERR_SSL_.*ALERT_HANDSHAKE_FAILURE$/); + assert.match((await clientFailed).code!, /^ERR_SSL_.*ALERT_HANDSHAKE_FAILURE$/); server.destroy(); }); @@ -1284,7 +1292,7 @@ test("over a Duplex: a server that shares no cipher with the client sends its fa const CLOSE_NOTIFY = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); // A transport with no file descriptor. `peer(chunk, transport)` gets each chunk that the TLS socket writes to it. -function transportWithPeer(peer = () => {}) { +function transportWithPeer(peer: (chunk: Buffer, transport: Duplex) => void = () => {}) { const transport = new Duplex({ read() {}, write(chunk, encoding, callback) { @@ -1297,9 +1305,9 @@ function transportWithPeer(peer = () => {}) { // A peer that calls `answer(transport)` once: on a later turn of the event loop than the first flight of the TLS // socket, or inside the write() of that flight with `inWrite`. -function answerFirstFlight(answer, inWrite = false) { +function answerFirstFlight(answer: (transport: Duplex) => void, inWrite = false) { let answered = false; - return (chunk, transport) => { + return (chunk: Buffer, transport: Duplex) => { if (answered) return; answered = true; if (inWrite) answer(transport); @@ -1308,12 +1316,12 @@ function answerFirstFlight(answer, inWrite = false) { } // The events of `socket` in order. Resolves at 'close'. -function eventsUntilClose(socket) { - const events = []; - const { promise, resolve } = Promise.withResolvers(); +function eventsUntilClose(socket: tls.TLSSocket) { + const events: string[] = []; + const { promise, resolve } = Promise.withResolvers(); socket.on("secureConnect", () => events.push("secureConnect")); socket.on("end", () => events.push("end")); - socket.on("error", err => events.push(`error ${err.code}: ${err.message}`)); + socket.on("error", (err: NodeJS.ErrnoException) => events.push(`error ${err.code}: ${err.message}`)); socket.on("close", hadError => { events.push(`close ${hadError}`); resolve(events); @@ -1401,8 +1409,8 @@ test("over a TLS socket: a close_notify in answer to the inner ClientHello fails const server = tls.createServer({ key, cert }, socket => { socket.on("error", () => {}); socket.once("data", () => socket.write(CLOSE_NOTIFY)); - }); - await new Promise(listening => server.listen(0, "127.0.0.1", listening)); + }) as Listening; + await new Promise(listening => server.listen(0, "127.0.0.1", listening)); const outer = tls.connect({ port: server.address().port, host: "127.0.0.1", rejectUnauthorized: false }); outer.on("error", () => {}); try { @@ -1421,8 +1429,8 @@ for (const rejectUnauthorized of [true, false]) { const peer = net.createServer(socket => { socket.on("error", () => {}); socket.once("data", () => socket.write(CLOSE_NOTIFY)); - }); - await new Promise(listening => peer.listen(0, "127.0.0.1", listening)); + }) as Listening; + await new Promise(listening => peer.listen(0, "127.0.0.1", listening)); try { const client = tls.connect({ port: peer.address().port, host: "127.0.0.1", rejectUnauthorized }); assert.deepStrictEqual(await eventsUntilClose(client), DISCONNECTED_IN_HANDSHAKE); @@ -1434,7 +1442,7 @@ for (const rejectUnauthorized of [true, false]) { test("on a TCP socket: a close_notify in place of the client's last TLS 1.2 flight is a 'tlsClientError'", async () => { const server = tls.createServer({ key, cert, maxVersion: "TLSv1.2" }); - const reported = new Promise(resolve => server.once("tlsClientError", resolve)); + const reported = new Promise(resolve => server.once("tlsClientError", resolve)); // The proxy forwards the ClientHello and answers the server's flight itself. const { port, close } = await behindProxy(server, (downstream, upstream) => { downstream.once("data", clientHello => upstream.write(clientHello)); @@ -1454,15 +1462,15 @@ test("on a TCP socket: a close_notify in place of the client's last TLS 1.2 flig // The peer can go away while the handshake of a socket that called end() still runs. The socket closes with no error, // and the server reports the handshake that never finished. `half`: the peer sends the first half of its last flight // before its FIN. -for (const maxVersion of ["TLSv1.2", "TLSv1.3"]) { +for (const maxVersion of ["TLSv1.2", "TLSv1.3"] as const) { for (const half of [false, true]) { test(`${maxVersion}: a server that end()s reports the handshake that its peer left ${half ? "in the middle of a flight" : "before its last flight"}`, async () => { - const events = []; + const events: string[] = []; const refused = Promise.withResolvers(); const serverClosed = Promise.withResolvers(); let serverSocket; const server = tls.createServer({ key, cert, maxVersion }, () => events.push("secureConnection")); - server.on("tlsClientError", err => { + server.on("tlsClientError", (err: NodeJS.ErrnoException) => { events.push(`tlsClientError ${err.code}`); refused.resolve(); }); @@ -1475,7 +1483,7 @@ for (const maxVersion of ["TLSv1.2", "TLSv1.3"]) { server, (downstream, upstream) => { let chunks = 0; - const held = []; + const held: Buffer[] = []; downstream.on("data", chunk => { // The ClientHello goes through. The proxy holds the client's last flight, and the server calls end(). if (++chunks === 1) return void upstream.write(chunk); @@ -1518,14 +1526,19 @@ for (const maxVersion of ["TLSv1.2", "TLSv1.3"]) { const turn = () => new Promise(resolve => setImmediate(resolve)); // `depth` TLS sessions, one inside the other, over one TCP connection. Index 0 is the session over TCP. -async function nestedSessions(depth, { allowHalfOpen = false } = {}) { - const accepted = Promise.withResolvers(); - const listener = tls.createServer({ key, cert, allowHalfOpen }, accepted.resolve); - await new Promise(listening => listener.listen(0, "127.0.0.1", listening)); +async function nestedSessions(depth: number, { allowHalfOpen = false } = {}) { + const accepted = Promise.withResolvers(); + const listener = tls.createServer({ key, cert, allowHalfOpen }, accepted.resolve) as Listening; + await new Promise(listening => listener.listen(0, "127.0.0.1", listening)); const clients = [ - tls.connect({ port: listener.address().port, host: "127.0.0.1", rejectUnauthorized: false, allowHalfOpen }), + tls.connect({ + port: listener.address().port, + host: "127.0.0.1", + rejectUnauthorized: false, + allowHalfOpen, + } as tls.ConnectionOptions), ]; - const servers = []; + const servers: tls.TLSSocket[] = []; const close = () => { for (const socket of [...clients, ...servers]) socket.destroy(); listener.close(); @@ -1533,11 +1546,11 @@ async function nestedSessions(depth, { allowHalfOpen = false } = {}) { try { servers.push(await accepted.promise); while (clients.length < depth) { - servers.push(new tls.TLSSocket(servers.at(-1), { isServer: true, key, cert })); + servers.push(new tls.TLSSocket(servers.at(-1)!, { isServer: true, key, cert })); clients.push(tls.connect({ socket: clients.at(-1), rejectUnauthorized: false })); await Promise.all([ - new Promise(secured => clients.at(-1).once("secureConnect", secured)), - new Promise(secured => servers.at(-1).once("secure", secured)), + new Promise(secured => clients.at(-1)!.once("secureConnect", secured)), + new Promise(secured => servers.at(-1)!.once("secure", secured)), ]); } } catch (err) { @@ -1550,9 +1563,9 @@ async function nestedSessions(depth, { allowHalfOpen = false } = {}) { for (const side of ["client", "server"]) { test(`over a Duplex: a ${side} write that the Duplex has yet to complete is no error when the peer closes its side`, async () => { // Holds the chunk and the callback of each write of `side` from `stall()` on. - const held = []; + const held: [Buffer, (error?: Error | null) => void][] = []; let stalled = false; - const makeSide = (name, peer) => + const makeSide = (name: string, peer: () => Duplex) => new Duplex({ read() {}, write(chunk, encoding, callback) { @@ -1569,7 +1582,7 @@ for (const side of ["client", "server"]) { const server = new tls.TLSSocket(sides.server, { isServer: true, key, cert }); const client = tls.connect({ socket: sides.client, rejectUnauthorized: false }); const [writer, reader] = side === "client" ? [client, server] : [server, client]; - const log = []; + const log: string[] = []; let received = ""; try { writer.on("error", err => log.push(`'error': ${err.message}`)); @@ -1604,8 +1617,9 @@ for (const side of ["client", "server"]) { for (const depth of [2, 3]) { test(`${depth} TLS sessions deep: what a ${side} wrote arrives when the peer closes its side first`, async () => { const { clients, servers, close } = await nestedSessions(depth); - const [writer, reader] = side === "client" ? [clients.at(-1), servers.at(-1)] : [servers.at(-1), clients.at(-1)]; - const log = []; + const [writer, reader] = + side === "client" ? [clients.at(-1)!, servers.at(-1)!] : [servers.at(-1)!, clients.at(-1)!]; + const log: string[] = []; let received = 0; try { // The sessions below report that the one above is gone in their own ways. Only what the writer is told counts. @@ -1615,8 +1629,8 @@ for (const side of ["client", "server"]) { const readerEnded = new Promise(ended => reader.once("end", ended)); // More than the kernel takes at once, so most of it is queued in the session over TCP when the writer closes. const first = Buffer.alloc(8 * 1024 * 1024, "a"); - writer.write(first, err => log.push(`write callback: ${err?.code}`)); - writer.write("tail", err => log.push(`queued write callback: ${err?.code}`)); + writer.write(first, (err?: NodeJS.ErrnoException | null) => log.push(`write callback: ${err?.code}`)); + writer.write("tail", (err?: NodeJS.ErrnoException | null) => log.push(`queued write callback: ${err?.code}`)); // A turn later only what the kernel did not take is left. Node writes to the handle of the session below, past its queue. await turn(); if (isBun) assert.notStrictEqual((side === "client" ? clients : servers)[0].writableLength, 0); @@ -1640,7 +1654,7 @@ for (const [how, leave] of [ ], ["destroys the connection", (inner, outer) => outer.destroy()], ["destroys its TLS socket", inner => inner.destroy()], -]) { +] as [string, (inner: tls.TLSSocket, outer: tls.TLSSocket) => void][]) { test(`2 TLS sessions deep: the connection closes when it still has ciphertext to send and the peer ${how}`, async () => { const { clients, servers, close } = await nestedSessions(2); try { @@ -1664,10 +1678,15 @@ for (const [how, leave] of [ ["destroy()", socket => socket.destroy()], ["destroy(err)", socket => socket.destroy(new Error("gone"))], ["destroySoon()", socket => socket.destroySoon()], -]) { +] as [string, (socket: tls.TLSSocket) => void][]) { test(`over a Duplex: ${how} of the TLS socket destroys a Duplex whose other end stays open`, async () => { const [transport, otherEnd] = duplexPair(); - const server = new tls.TLSSocket(otherEnd, { isServer: true, key, cert, allowHalfOpen: true }); + const server = new tls.TLSSocket(otherEnd, { + isServer: true, + key, + cert, + allowHalfOpen: true, + } as tls.TLSSocketOptions); const client = tls.connect({ socket: transport, rejectUnauthorized: false }); try { for (const socket of [client, server, transport, otherEnd]) socket.on("error", () => {}); @@ -1731,7 +1750,7 @@ test("2 TLS sessions deep: the timeout of the session below still fires after th const { clients, servers, close } = await nestedSessions(2, { allowHalfOpen: true }); try { for (const socket of [...clients, ...servers]) socket.on("error", () => {}); - const timedOut = new Promise(resolve => servers[0].setTimeout(50, resolve)); + const timedOut = new Promise(resolve => servers[0].setTimeout(50, resolve)); servers[1].destroy(); // Node destroys the session below with the one above. await Promise.race([timedOut, new Promise(resolve => servers[0].once("close", resolve))]); @@ -1742,9 +1761,9 @@ test("2 TLS sessions deep: the timeout of the session below still fires after th for (const side of ["client", "server"]) { test(`2 TLS sessions deep: a ${side} hears of a reset that cuts what it wrote, after its TLS socket closed too`, async () => { - const accepted = Promise.withResolvers(); - const listener = net.createServer(accepted.resolve); - await new Promise(listening => listener.listen(0, "127.0.0.1", listening)); + const accepted = Promise.withResolvers(); + const listener = net.createServer(accepted.resolve) as Listening; + await new Promise(listening => listener.listen(0, "127.0.0.1", listening)); const tcpClient = net.connect(listener.address().port, "127.0.0.1"); const tcpServer = await accepted.promise; const outerServer = new tls.TLSSocket(tcpServer, { isServer: true, key, cert }); @@ -1757,7 +1776,7 @@ for (const side of ["client", "server"]) { ? [innerClient, outerClient, tcpServer, [innerServer, outerServer, tcpServer]] : [innerServer, outerServer, tcpClient, [innerClient, outerClient, tcpClient]]; try { - const told = []; + const told: string[] = []; for (const socket of all) socket.on("error", () => {}); await Promise.all([ new Promise(secured => innerClient.once("secureConnect", secured)), @@ -1765,9 +1784,12 @@ for (const side of ["client", "server"]) { ]); // The peer reads nothing, so most of this never leaves. for (const socket of peers) socket.pause(); - inner.on("error", err => told.push(`TLS socket 'error': ${err.code}`)); - outer.on("error", err => told.push(`session below 'error': ${err.code}`)); - inner.write(Buffer.alloc(8 * 1024 * 1024, "a"), err => err && told.push(`write callback: ${err.code}`)); + inner.on("error", (err: NodeJS.ErrnoException) => told.push(`TLS socket 'error': ${err.code}`)); + outer.on("error", (err: NodeJS.ErrnoException) => told.push(`session below 'error': ${err.code}`)); + inner.write( + Buffer.alloc(8 * 1024 * 1024, "a"), + (err?: NodeJS.ErrnoException | null) => err && told.push(`write callback: ${err.code}`), + ); const innerClosed = new Promise(resolve => inner.once("close", resolve)); const outerClosed = new Promise(resolve => outer.once("close", resolve)); inner.destroy(); @@ -1786,13 +1808,18 @@ for (const side of ["client", "server"]) { for (const transportKind of ["a TLS socket", "a Duplex"]) { test(`over ${transportKind}: a client that rejects the server's certificate releases a transport whose peer stays`, async () => { const { clients, servers, close } = await nestedSessions(1, { allowHalfOpen: true }); - let [transport, otherEnd] = [clients[0], servers[0]]; + let [transport, otherEnd]: [Duplex, Duplex] = [clients[0], servers[0]]; if (transportKind === "a Duplex") [transport, otherEnd] = duplexPair(); - const server = new tls.TLSSocket(otherEnd, { isServer: true, key, cert, allowHalfOpen: true }); + const server = new tls.TLSSocket(otherEnd, { + isServer: true, + key, + cert, + allowHalfOpen: true, + } as tls.TLSSocketOptions); const client = tls.connect({ socket: transport, servername: "agent1" }); try { for (const socket of [client, server, transport, otherEnd]) socket.on("error", () => {}); - const rejected = new Promise(resolve => client.once("error", resolve)); + const rejected = new Promise(resolve => client.once("error", resolve)); const closed = new Promise(resolve => transport.once("close", resolve)); assert.strictEqual((await rejected).code, "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); await closed; @@ -1870,11 +1897,16 @@ test("over a Duplex: a TLS socket whose peer was destroyed takes its Duplex with // The peers keep their sides open in these two. test("3 TLS sessions deep: a client that rejects the innermost certificate releases the connection", async () => { const { clients, servers, close } = await nestedSessions(2, { allowHalfOpen: true }); - const server = new tls.TLSSocket(servers[1], { isServer: true, key, cert, allowHalfOpen: true }); + const server = new tls.TLSSocket(servers[1], { + isServer: true, + key, + cert, + allowHalfOpen: true, + } as tls.TLSSocketOptions); const client = tls.connect({ socket: clients[1], servername: "agent1" }); try { for (const socket of [...clients, ...servers, client, server]) socket.on("error", () => {}); - const rejected = new Promise(resolve => client.once("error", resolve)); + const rejected = new Promise(resolve => client.once("error", resolve)); const closed = [...clients, client].map(socket => new Promise(resolve => socket.once("close", resolve))); assert.strictEqual((await rejected).code, "UNABLE_TO_VERIFY_LEAF_SIGNATURE"); await Promise.all(closed); @@ -1889,7 +1921,7 @@ test("3 TLS sessions deep: the timeout of the session over TCP still fires after const { clients, servers, close } = await nestedSessions(3, { allowHalfOpen: true }); try { for (const socket of [...clients, ...servers]) socket.on("error", () => {}); - const timedOut = new Promise(resolve => servers[0].setTimeout(50, resolve)); + const timedOut = new Promise(resolve => servers[0].setTimeout(50, resolve)); servers[1].destroy(); // Node destroys the session over TCP with the one above it. await Promise.race([timedOut, new Promise(resolve => servers[0].once("close", resolve))]); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 12a55ba6398f..9d0b55a5d5df 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1878,7 +1878,7 @@ describe("setSecureContext() on a listening server", () => { const settings = cluster.settings; cluster.setupPrimary({ exec: join(import.meta.dir, "tls-cluster-set-secure-context-fixture.mjs"), execArgv: [] }); const worker = cluster.fork(bunEnv); - cluster.settings = settings; + (cluster as any).settings = settings; const exited = once(worker, "exit"); try { const served = await Promise.race([ @@ -2623,7 +2623,7 @@ describe("tls.Server secure-context options", () => { ["tls.createServer", createServer], ["http2.createSecureServer", http2.createSecureServer], ])("%s() itself throws on a certificate the native loader rejects", (_, create) => { - expect(() => create({ key: agent6Key, cert: "not a certificate" })).toThrow( + expect(() => (create as typeof createServer)({ key: agent6Key, cert: "not a certificate" })).toThrow( expect.objectContaining({ code: "ERR_OSSL_PEM_NO_START_LINE" }), ); }); @@ -3905,7 +3905,7 @@ describe("key/cert arrays", () => { sigalgs: "rsa_pss_rsae_sha256", maxVersion: version, }, - socket => resolve(socket.getPeerCertificate().subject?.CN), + socket => resolve(socket.getPeerCertificate().subject?.CN as string | undefined), ); tlsServer.on("tlsClientError", reject); using server = await listen(tlsServer); @@ -4037,7 +4037,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("server names after close() (%s)" const { promise, resolve } = Promise.withResolvers(); const options = { socket, servername, rejectUnauthorized: false, minVersion: version, maxVersion: version }; const client = connect(options, () => { - resolve((client.getPeerCertificate() as PeerCertificate).subject.CN); + resolve((client.getPeerCertificate() as PeerCertificate).subject.CN as string); client.destroy(); }); client.on("error", () => {}); @@ -4208,7 +4208,7 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("addContext() on connections the }); it("a user SNICallback replaces the entries", async () => { - const tlsServer = createServer({ ...agent(1), SNICallback: (_, cb) => cb(null, null) }); + const tlsServer = createServer({ ...agent(1), SNICallback: (_, cb) => cb(null, null as any) }); tlsServer.addContext("exact.example", agent(2)); using injected = await inject(tlsServer); expect(await injected.servedCN("exact.example")).toBe("agent1"); @@ -4250,11 +4250,14 @@ it("addContext() with a NUL in the name registers nothing under the part before await Promise.all([once(server, "listening"), once(front, "listening")]); server.addContext("after.example\0evil", agent(2)); const served: Record = {}; - for (const [path, { port }] of Object.entries({ accepted: server.address(), injected: front.address() })) { + for (const [path, { port }] of Object.entries({ + accepted: server.address() as AddressInfo, + injected: front.address() as AddressInfo, + })) { for (const servername of ["before.example", "after.example"]) { const client = connect({ port, host: "127.0.0.1", servername, rejectUnauthorized: false }); await once(client, "secureConnect"); - served[`${path} ${servername}`] = (client.getPeerCertificate() as PeerCertificate).subject.CN; + served[`${path} ${servername}`] = (client.getPeerCertificate() as PeerCertificate).subject.CN as string; client.destroy(); } } @@ -4427,7 +4430,7 @@ it.each([ const secure = Promise.withResolvers(); const rawServer = net.createServer(raw => { const socket = new TLSSocket(raw, { isServer: true, ...identity, ...options }); - socket.on("secure", () => secure.resolve(socket.getPeerCertificate()?.subject?.CN)); + socket.on("secure", () => secure.resolve(socket.getPeerCertificate()?.subject?.CN as string | undefined)); socket.on("data", data => socket.write(data)); socket.on("error", secure.reject); }); @@ -4880,7 +4883,7 @@ describe("fatal TLS error after the handshake", () => { const upstream = net.connect(serverPort, "127.0.0.1"); upstream.pipe(downstream); let index = 0; - downstream.on("data", chunk => { + downstream.on("data", (chunk: Buffer) => { const [bytes, fin] = tamper(chunk, index++); if (fin) upstream.end(bytes); else upstream.write(bytes); diff --git a/test/js/node/tls/node-tls-set-session-after-start.fixture.ts b/test/js/node/tls/node-tls-set-session-after-start.fixture.ts index b5f157a4567e..2698b2f6b480 100644 --- a/test/js/node/tls/node-tls-set-session-after-start.fixture.ts +++ b/test/js/node/tls/node-tls-set-session-after-start.fixture.ts @@ -53,7 +53,7 @@ function handshakeFinished(socket: { setServername(name: string): void }): boole /** The connection must still carry data after the refused offer. */ async function lateOnClient( client: tls.TLSSocket, - result = attempt(() => client.setSession(session)), + result = attempt(() => (client as any).setSession(session)), ): Promise { const echoed = new Promise(resolve => client.once("data", d => resolve(String(d)))); client.write("ping"); @@ -91,7 +91,10 @@ const doors: Record Promise> = { async "node-duplex-in-flight"() { let result: { threw: string | null } | undefined; let client: tls.TLSSocket | undefined; - const socket = await duplexTo(port, () => client && (result ??= attempt(() => client!.setSession(session)))); + const socket = await duplexTo( + port, + () => client && (result ??= attempt(() => (client as any).setSession(session))), + ); client = tls.connect({ ...clientOptions, socket }); client.on("error", () => {}); await once(client, "secureConnect"); @@ -110,7 +113,7 @@ const doors: Record Promise> = { ca: fs.readFileSync(path.join(keys, "ca1-cert.pem")), rejectUnauthorized: true, checkServerIdentity() { - result = attempt(() => client.setSession(session)); + result = attempt(() => (client as any).setSession(session)); return undefined; }, }); @@ -146,7 +149,7 @@ const doors: Record Promise> = { const { promise, resolve } = Promise.withResolvers(); const own = tls.createServer(serverOptions, socket => { socket.on("error", () => {}); - resolve({ ...attempt(() => socket.setSession(session)), side: "server" }); + resolve({ ...attempt(() => (socket as any).setSession(session)), side: "server" }); }); await once(own.listen(0, "127.0.0.1"), "listening"); poke((own.address() as net.AddressInfo).port); @@ -162,7 +165,7 @@ const doors: Record Promise> = { secureContext: tls.createSecureContext(serverOptions), }); secure.on("error", () => {}); - secure.on("secure", () => resolve({ ...attempt(() => secure.setSession(session)), side: "server" })); + secure.on("secure", () => resolve({ ...attempt(() => (secure as any).setSession(session)), side: "server" })); }); await once(plain.listen(0, "127.0.0.1"), "listening"); poke((plain.address() as net.AddressInfo).port); @@ -313,7 +316,7 @@ async function fromEvent(event: "keylog" | "session", options: tls.ConnectionOpt const client = tls.connect({ ...clientOptions, ...options }); client.on("error", () => {}); const { promise, resolve } = Promise.withResolvers<{ threw: string | null }>(); - client.once(event, () => resolve(attempt(() => client.setSession(session)))); + client.once(event, () => resolve(attempt(() => (client as any).setSession(session)))); const [result] = await Promise.all([promise, once(client, "secureConnect")]); return lateOnClient(client, result); } diff --git a/test/js/node/tls/node-tls-upgrade.test.ts b/test/js/node/tls/node-tls-upgrade.test.ts index f23a366d3c5f..e41a1cdc8629 100644 --- a/test/js/node/tls/node-tls-upgrade.test.ts +++ b/test/js/node/tls/node-tls-upgrade.test.ts @@ -376,7 +376,7 @@ test("a Duplex that is paused with the ClientHello buffered when it is wrapped s test("TLS over TLS: an outer socket that is paused with the inner ClientHello unshifted still handshakes", async () => { const server = tls.createServer(certs, outer => { outer.on("error", () => {}); - outer.once("data", chunk => { + outer.once("data", (chunk: Buffer) => { outer.pause(); if (chunk.length > 8) outer.unshift(chunk.subarray(8)); wrapAndEcho(outer); diff --git a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts index 0caa85badc82..9b7b928d177e 100644 --- a/test/js/node/tls/node-tls-wrapped-socket-close.test.ts +++ b/test/js/node/tls/node-tls-wrapped-socket-close.test.ts @@ -456,7 +456,7 @@ describe("the wrapped socket goes away before it connected", () => { events.length = 0; raw.connect(port, "127.0.0.1"); const received: Buffer[] = []; - raw.on("data", chunk => received.push(chunk)); + raw.on("data", (chunk: Buffer) => received.push(chunk)); await once(raw, "close"); assert.deepStrictEqual([Buffer.concat(received).toString(), ...events], ["plain", "raw close"]); } finally { diff --git a/test/js/node/tls/tls-default-ciphers-fixture.ts b/test/js/node/tls/tls-default-ciphers-fixture.ts index 2b9bf9687165..7b5cc6aab842 100644 --- a/test/js/node/tls/tls-default-ciphers-fixture.ts +++ b/test/js/node/tls/tls-default-ciphers-fixture.ts @@ -67,7 +67,7 @@ function upgrade(raw: net.Socket, leftover: Buffer) { const postgres = await listen( net.createServer(raw => { raw.on("error", () => {}); - raw.once("data", chunk => { + raw.once("data", (chunk: Buffer) => { raw.write(pgSSLResponse("S")); upgrade(raw, chunk.subarray(8)); }); @@ -78,7 +78,7 @@ const mysql = await listen( raw.on("error", () => {}); raw.write(mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_SSL })); let buffered = Buffer.alloc(0); - raw.on("data", function onData(chunk) { + raw.on("data", function onData(chunk: Buffer) { buffered = Buffer.concat([buffered, chunk]); const end = 4 + (buffered[0] | (buffered[1] << 8) | (buffered[2] << 16)); if (buffered.length < 4 || buffered.length < end) return; diff --git a/test/js/node/tls/tls-handshake-alert-utils.ts b/test/js/node/tls/tls-handshake-alert-utils.ts index d985324388ea..e45697a80288 100644 --- a/test/js/node/tls/tls-handshake-alert-utils.ts +++ b/test/js/node/tls/tls-handshake-alert-utils.ts @@ -42,7 +42,7 @@ export async function startMalformedServerHelloServer(path?: string) { const server = net.createServer(socket => { let received = Buffer.alloc(0); let answered = false; - socket.on("data", chunk => { + socket.on("data", (chunk: Buffer) => { received = Buffer.concat([received, chunk]); if (!answered && tlsRecords(received).length > 0) { answered = true; diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 68b99de8fcb7..9ca5ca0d27fb 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -112,7 +112,7 @@ function fdOf(socket: tls.TLSSocket): number { function collect(socket: tls.TLSSocket) { const chunks: Buffer[] = []; - socket.on("data", c => chunks.push(c)); + socket.on("data", (c: Buffer) => chunks.push(c)); return () => Buffer.concat(chunks); } @@ -372,7 +372,7 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { authorized: p.client.authorized, authorizationError: p.client.authorizationError, serverEncrypted: p.serverSock.encrypted, - }).toEqual({ authorized: true, authorizationError: null, serverEncrypted: true }); + }).toEqual({ authorized: true, authorizationError: null as any, serverEncrypted: true }); }); test("recv → short reads at TLS record boundary (5 bytes = header only) still decrypt", async () => { diff --git a/test/js/sql/sql-close-pending-connection.test.ts b/test/js/sql/sql-close-pending-connection.test.ts index 2625af577861..f6b77ed8fb3a 100644 --- a/test/js/sql/sql-close-pending-connection.test.ts +++ b/test/js/sql/sql-close-pending-connection.test.ts @@ -471,7 +471,7 @@ function tlsPeer( }, error() {}, }, - }); + } as Bun.TLSUpgradeOptions); }, close() {}, error() {}, @@ -628,7 +628,7 @@ for (const [name, peer, url] of tlsDrivers) { query.catch(() => {}); // The query is written at the end of this turn of the event loop. await new Promise(setImmediate); - await sql.close({ timeout: "0" }); + await sql.close({ timeout: "0" as any }); act(client); expect(await outcome.promise).toBe(expected); if (expected === "end") expect(received).toBeGreaterThan(64 * 1024); diff --git a/test/js/sql/tls-sql.test.ts b/test/js/sql/tls-sql.test.ts index 48fbba360793..4941edada534 100644 --- a/test/js/sql/tls-sql.test.ts +++ b/test/js/sql/tls-sql.test.ts @@ -496,7 +496,7 @@ async function queryServerWithoutTls( socket.on("error", () => {}); let buffered = Buffer.alloc(0); let started = false; - socket.on("data", data => { + socket.on("data", (data: Buffer) => { buffered = Buffer.concat([buffered, data]); while (!started) { if (buffered[0] === 0x16) { @@ -616,7 +616,7 @@ test.each([ return; } let buffered = Buffer.alloc(0); - socket.on("data", chunk => { + socket.on("data", (chunk: Buffer) => { buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { if (!mysqlAckSessionSetup(socket, payload)) socket.write(mysqlOkPacket(seq + 1)); }); @@ -626,7 +626,7 @@ test.each([ // The client's plaintext request for TLS: the SSLRequest message, resp. packet. const requestLength = adapter === "postgres" ? 8 : 36; - let records = Buffer.alloc(0); + let records: Buffer = Buffer.alloc(0); const closed = Promise.withResolvers(); const { port, server } = await listeningServer(client => { client.on("error", () => {}); @@ -634,13 +634,13 @@ test.each([ if (adapter === "mysql") { client.write(mysqlHandshakeV10({ capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_SSL })); } - client.once("data", request => { + client.once("data", (request: Buffer) => { if (adapter === "postgres") client.write(pgSSLResponse("S")); const upstream = net.connect((terminator.address() as net.AddressInfo).port, "127.0.0.1"); upstream.on("error", () => {}); records = request.subarray(requestLength); upstream.write(records); - client.on("data", chunk => { + client.on("data", (chunk: Buffer) => { records = Buffer.concat([records, chunk]); }); client.pipe(upstream).pipe(client); diff --git a/test/js/web/fetch/fetch.tls.wildcard.test.ts b/test/js/web/fetch/fetch.tls.wildcard.test.ts index eae745ed9713..c493024e498c 100644 --- a/test/js/web/fetch/fetch.tls.wildcard.test.ts +++ b/test/js/web/fetch/fetch.tls.wildcard.test.ts @@ -794,11 +794,11 @@ describe("TLS certificate name matching: fetch() / checkServerIdentity / checkHo const { promise, resolve, reject } = Promise.withResolvers(); const socket = await Bun.connect({ hostname: "127.0.0.1", - port: server.port, + port: server.port!, tls: { ca: material.cert, serverName }, socket: { handshake(s) { - const error = s.getAuthorizationError(); + const error = s.getAuthorizationError() as NodeJS.ErrnoException | null; resolve({ code: error?.code, message: error?.message }); }, data() {}, @@ -944,7 +944,6 @@ describe("TLS certificate name matching: fetch() / checkServerIdentity / checkHo const url = `https://127.0.0.1:${(server.address() as AddressInfo).port}/`; const results: string[] = []; for (const serverName of ["fe80::1%eth0", "5.6.7.8", "::1", "name.test", "127.1", "fe80::1%br_lan"]) { - // @ts-expect-error Bun extension const response = fetch(url, { tls: { ca: zoneCert.cert, serverName }, keepalive: false }); results.push(await response.then(r => r.text()).catch(e => e.code)); } diff --git a/test/js/web/websocket/proxy-test-utils.ts b/test/js/web/websocket/proxy-test-utils.ts index c2c201b431aa..a41751cb77a9 100644 --- a/test/js/web/websocket/proxy-test-utils.ts +++ b/test/js/web/websocket/proxy-test-utils.ts @@ -236,7 +236,7 @@ export async function startRawWssServer( departures.push(departure(socket)); let request = ""; let upgraded = false; - socket.on("data", chunk => { + socket.on("data", (chunk: Buffer) => { if (upgraded) return onFrames(socket, chunk); request += chunk.toString("latin1"); if (!request.includes("\r\n\r\n")) return; diff --git a/test/js/web/websocket/websocket-client.test.ts b/test/js/web/websocket/websocket-client.test.ts index a04885f8ce65..e5fccd0faa2e 100644 --- a/test/js/web/websocket/websocket-client.test.ts +++ b/test/js/web/websocket/websocket-client.test.ts @@ -9,7 +9,7 @@ import net, { type AddressInfo, createServer } from "node:net"; import * as path from "node:path"; import tls from "node:tls"; import { Worker } from "node:worker_threads"; -import { WebSocket as NodeWS, WebSocketServer } from "ws"; +import { type ClientOptions, WebSocket as NodeWS, WebSocketServer } from "ws"; import { clientEvents, startRecordingProxy, startRenegotiatingWssServer } from "./proxy-test-utils"; function test( label: string, @@ -865,8 +865,8 @@ describe("WebSocket TLS server identity", () => { checkServerIdentity(hostname: string, cert: tls.PeerCertificate) { calls.push({ hostname, - subject: cert.subject.CN, - altnames: cert.subjectaltname, + subject: cert.subject.CN as string, + altnames: cert.subjectaltname!, fingerprint256: cert.fingerprint256, raw: Buffer.isBuffer(cert.raw), }); @@ -1353,7 +1353,7 @@ describe("WebSocket TLS server identity", () => { return new Error("PIN-REJECT"); }, }, - }); + } as ClientOptions); // The first event decides. A close with no error before it must fail the assertion, not hang. const outcome = await new Promise(resolve => { ws.on("open", () => resolve("open")); diff --git a/test/js/web/websocket/websocket-close-code.test.ts b/test/js/web/websocket/websocket-close-code.test.ts index e2b7563cfacb..7b1e43b382b8 100644 --- a/test/js/web/websocket/websocket-close-code.test.ts +++ b/test/js/web/websocket/websocket-close-code.test.ts @@ -263,7 +263,7 @@ describe.concurrent("wss:// after the close event", () => { peer.on("error", () => {}); peer.pipe(client); client.on("close", () => peer.destroy()); - client.on("data", chunk => { + client.on("data", (chunk: Buffer) => { fromClient = Buffer.concat([fromClient, chunk]); if (!frozen) peer.write(chunk); }); From e370cb51831d4d07a5f19eb309e582458cac3691 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 06:53:26 +0000 Subject: [PATCH 153/174] node:tls: selectServerName stops at an exception A name that fails to resolve left the exception pending while the loop went on to the next entry of the Map. --- src/jsc/bindings/NodeTLS.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/NodeTLS.cpp b/src/jsc/bindings/NodeTLS.cpp index b4fe1c11bbea..0e1c1a58d008 100644 --- a/src/jsc/bindings/NodeTLS.cpp +++ b/src/jsc/bindings/NodeTLS.cpp @@ -342,8 +342,10 @@ JSC_DEFINE_HOST_FUNCTION(selectServerName, (JSC::JSGlobalObject * globalObject, auto* string = name.isString() ? asString(name) : nullptr; if (!string) continue; auto view = string->view(globalObject); + if (scope.exception()) [[unlikely]] + break; // No ClientHello can carry a NUL. - if (scope.exception() || view->contains('\0')) continue; + if (view->contains('\0')) continue; values.append(value); sni_add(tree, view->utf8().legacyCStringPointer(), reinterpret_cast(static_cast(values.size()))); } From 55c24231d35b7501eb16ad9ef5a255ed1c4451d9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 07:11:58 +0000 Subject: [PATCH 154/174] node:tls: selectServerName throws when its list of contexts cannot grow A dropped append left the name pointing at the context of the entry before it. --- src/jsc/bindings/NodeTLS.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/jsc/bindings/NodeTLS.cpp b/src/jsc/bindings/NodeTLS.cpp index 0e1c1a58d008..ead9708660e3 100644 --- a/src/jsc/bindings/NodeTLS.cpp +++ b/src/jsc/bindings/NodeTLS.cpp @@ -347,6 +347,10 @@ JSC_DEFINE_HOST_FUNCTION(selectServerName, (JSC::JSGlobalObject * globalObject, // No ClientHello can carry a NUL. if (view->contains('\0')) continue; values.append(value); + if (values.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + break; + } sni_add(tree, view->utf8().legacyCStringPointer(), reinterpret_cast(static_cast(values.size()))); } auto selected = reinterpret_cast(sni_find(tree, byteCast(servername.spanIncludingNullTerminator()).data())); From 346274c3ae97365db452660222ef23d3df3ca7bd Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 07:23:45 +0000 Subject: [PATCH 155/174] test(tls): the connectionListener count test says what became of a missing connection It is listed as flaky on macOS and only ever said "Expected: 50, Received: 49". It now also counts the accepted connections and collects 'tlsClientError' and the 'error' of both ends, and expects none. --- test/js/node/tls/node-tls-server.test.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 9d0b55a5d5df..9b45eaad0f8c 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -623,19 +623,27 @@ it("createServer registers the callback as a regular 'secureConnection' listener it("connectionListener should emit the right amount of times, and with alpnProtocol available", async () => { let count = 0; + let accepted = 0; + // What became of a connection that the listener never got. + const problems: string[] = []; const promises: Promise[] = []; - const server: Server = createServer( + await using server: Server = createServer( { ...COMMON_CERT, ALPNProtocols: ["bun"], }, socket => { count++; + socket.on("error", (err: NodeJS.ErrnoException) => problems.push(`server socket 'error': ${err.code}`)); expect(socket.alpnProtocol).toBe("bun"); socket.end(); }, ); server.setMaxListeners(100); + server.on("connection", () => accepted++); + server.on("tlsClientError", (err: NodeJS.ErrnoException, socket: TLSSocket) => + problems.push(`'tlsClientError' from port ${socket.remotePort}: ${err.message}`), + ); server.listen(0); await once(server, "listening"); @@ -651,15 +659,24 @@ it("connectionListener should emit the right amount of times, and with alpnProto ALPNProtocols: ["bun"], }, () => { - socket.on("close", resolve); + secured = true; socket.resume(); socket.end(); }, ); + let secured = false; + let port: number | undefined; + socket.on("connect", () => (port = socket.localPort)); + socket.on("error", (err: NodeJS.ErrnoException) => + problems.push( + `'error' of the client on port ${port}, ${secured ? "after" : "before"} 'secureConnect': ${err.message}`, + ), + ); + socket.on("close", resolve); } await Promise.all(promises); - expect(count).toBe(50); + expect({ accepted, count, problems }).toEqual({ accepted: 50, count: 50, problems: [] }); }); it("destroying the socket from inside SNICallback or ALPNCallback does not crash the process", async () => { From 9f0bc74e768be5e63ebb8ebc9c905975d0ad4a43 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 07:56:51 +0000 Subject: [PATCH 156/174] usockets: a FIN does not end a socket that waits in the low-priority queue On kqueue the read knote of a socket that is not polling for reads stays registered as a sentinel, so that the peer's FIN or reset still reaches the dispatcher, as an eof with the readable bit masked out. The dispatcher puts that eof off for a paused socket, whose unread bytes come first, but not for one parked in the low-priority queue, where TLS handshakes wait for their turn (5 per loop iteration). A TLS 1.3 client that ends right after its handshake sends Finished, close_notify and FIN. With its server socket parked, the eof came first: the handshake failed with 'socket hang up', and the close over the unread bytes reset the client. 1 to 9 of the 50 connections of the connectionListener test in node-tls-server.test.ts on macOS x64, in 7 of 48 builds of other branches. On this branch in 6 of 8, where Finished leaves in one segment with the close_notify. Leaving the queue re-arms the reads, and the read loop reaches the FIN behind the data. epoll reports no eof for a FIN alone. --- packages/bun-usockets/src/loop.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index e2f68299d35c..f28b41ffb650 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -903,6 +903,12 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in * here, so let the next poll re-report it together with READABLE and * the read loop drain it, instead of closing over the unread tail. */ eof = 0; + } else if (eof_deferrable && !hangup && s->flags.low_prio_state == 1) { + /* Parked in the low-priority queue, where kqueue's sentinel knote still reports the + * peer's FIN. What the peer sent ahead of it is unread: a TLS 1.3 client that ends + * right after its handshake has its Finished there, and an end here would fail the + * server's handshake and reset that client. Leaving the queue re-arms the reads. */ + eof = 0; } if (eof && error && !read_fin) { /* The eof hint next to an error flag is the reset taking both directions From 64e75cd99d2a0de1a8fbbf70cbe4248d741e946d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 08:37:58 +0000 Subject: [PATCH 157/174] test: squid is healthy when it listens, not when its process exists A test file that its PR changes runs first, so websocket-proxy.test.ts started 2 ms behind "squid ready" and both Squid tests failed with "Connection ended", at the first attempt of all 11 builds of this branch and of none of 107 others, where the file runs 85 s after the coordinator has brought squid up. --- test/docker/docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml index ca8b2c8af0c5..79b4910ad615 100644 --- a/test/docker/docker-compose.yml +++ b/test/docker/docker-compose.yml @@ -220,7 +220,8 @@ services: extra_hosts: - "host.docker.internal:host-gateway" healthcheck: - test: ["CMD-SHELL", "pgrep squid > /dev/null"] + # The process is there well before it listens, and a test file that its PR changes runs first, right behind "ready". + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/3128"] interval: 5s timeout: 5s retries: 30 From 68de4be7c08b00dc1fb578687cbcfe27037a628c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 08:37:59 +0000 Subject: [PATCH 158/174] test(http3): the rejection that stop(true) causes is handled from the start The fetch could reject, with the code the test expects, while the test was still waiting to read "STOPPED" from the child's stderr, and failed it as an unhandled rejection: 5 of 11 builds of this branch, where the file runs third, while the containers of the job start. --- test/js/bun/http/serve-http3.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/js/bun/http/serve-http3.test.ts b/test/js/bun/http/serve-http3.test.ts index cf4186ba1005..6ac74d399ce5 100644 --- a/test/js/bun/http/serve-http3.test.ts +++ b/test/js/bun/http/serve-http3.test.ts @@ -1022,7 +1022,8 @@ describe("Bun.serve HTTP/3 lifecycle", () => { `; await withCustomServer(script, async (port, send, waitForStderr) => { expect(await fetchH3(port, "/").then(r => r.text())).toBe("ok"); - const inflight = fetchH3(port, "/stop", { + // Handled from the start: the close can reach the client before "STOPPED" is read from the pipe. + const outcome = fetchH3(port, "/stop", { method: "POST", body: new ReadableStream({ start(c) { @@ -1030,10 +1031,12 @@ describe("Bun.serve HTTP/3 lifecycle", () => { c.close(); }, }), - }); + }).then( + r => r.text(), + e => e.code, + ); await waitForStderr(/STOPPED/); - const outcome = await inflight.then(r => r.text()).catch(e => e.code); - expect(outcome).toBe("HTTP3StreamReset"); + expect(await outcome).toBe("HTTP3StreamReset"); }); }); From 2fbae9b04453180c31bfe6c4d00f6c6c05995a65 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 08:38:00 +0000 Subject: [PATCH 159/174] test(tls): the close deadline test no longer counts on what the kernel's buffers do A close waits for sealed ciphertext that the kernel refuses, and the test took for granted that it would refuse. It need not: - As the first test of a process on Linux, the kernel took the rest of the last record when destroy() tried it once more, so that close had nothing to wait for and the fd was gone at once. Behind any other test it said EAGAIN. Where fault injection is compiled in, the send is now refused for that fd, and the wait is asserted. Elsewhere either is right. - On macOS, in 4 of 11 builds, the loopback buffers took all that the writers had left. Their closes ended after 2 or 4 s with no peer cut short, and the test looked at the destroyed socket's fd then, ahead of its own deadline. It now waits for that fd itself, and only Linux has to cut the peers short. What each writer reported, what each peer received and when each close came are in the message of the assertion. It still times out on main, which has no deadline. --- test/js/bun/net/socket.test.ts | 37 +++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index eae574a4b794..3a309f4c5a14 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1754,30 +1754,47 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn await Promise.all([once(destroyed, "secureConnect"), lastPeerPaused.promise]); const destroyedFd: number = (destroyed as any)._handle.fd; destroyed.write(Buffer.alloc(12 * step.length, "a")); + // The close tries the unsent rest of the last record once more, and whether the kernel takes it by now is the kernel's call. + const refuses = socketFaultInjection.available(); + if (refuses) socketFaultInjection.set({ syscall: "send", action: "zero", fd: destroyedFd, repeat: -1 }); destroyed.destroy(); const openAfterDestroy = fdIsOpen(destroyedFd); + if (refuses) socketFaultInjection.clear(); await once(destroyed, "close"); const drip = setInterval(() => peers[DRIPPING].resume(), 2000); try { const before = { fdIsOpen: openAfterDestroy, wrote: probeBatching(probe) }; + const waitedFrom = performance.now(); + const closedAfter = closed.map(promise => promise.then(() => Math.round(performance.now() - waitedFrom))); await Promise.all(closed); + // Its own deadline: the other closes end early where their peers' kernel buffers took everything. + while (fdIsOpen(destroyedFd) && performance.now() - waitedFrom < 16_000) await Bun.sleep(100); const after = { fdIsOpen: fdIsOpen(destroyedFd), wrote: probeBatching(probe) }; const batches = socketFaultInjection.available(); const missing = (peer: number) => reported[peer] - peers[peer].data.received; - expect({ - before, - after, - timeouts, - cutShort: [missing(SILENT) > 0, missing(DRIPPING) > 0], - missing: missing(UNANSWERING), - }).toEqual({ + // Whether the peers stalled their writers at all is up to the kernel's buffers. + const measured = { + reported, + received: peers.map(peer => peer.data.received), + closedAfterMs: await Promise.all(closedAfter), + }; + expect( + { + before, + after, + timeouts, + cutShort: [missing(SILENT) > 0, missing(DRIPPING) > 0], + missing: missing(UNANSWERING), + }, + JSON.stringify(measured), + ).toEqual({ // fstat() does not take a Windows socket handle. - before: { fdIsOpen: !isWindows, wrote: batches ? 16 * 1024 : undefined }, + before: { fdIsOpen: isWindows ? false : refuses || expect.any(Boolean), wrote: batches ? 16 * 1024 : undefined }, after: { fdIsOpen: false, wrote: batches ? 64 * 1024 : undefined }, timeouts: 0, - // Loopback buffers outside Linux are small enough for the drips to read everything in time. - cutShort: [true, isLinux ? true : expect.any(Boolean)], + // Outside Linux the loopback buffers can take all that a writer has left, and then its peer stalls nothing. + cutShort: isLinux ? [true, true] : [expect.any(Boolean), expect.any(Boolean)], missing: 0, }); } finally { From 1646526a40bced4a38c42bbbf7ac91a228551195 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 09:06:51 +0000 Subject: [PATCH 160/174] test: await the assertion that fetch rejects an array for tls Nothing waited for it, so the case could not fail. --- test/js/bun/net/socket.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 3a309f4c5a14..825c6092797f 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1074,7 +1074,7 @@ describe.each([ }); it("fetch rejects", async () => { - expect(fetch("https://127.0.0.1:1/", { tls: makeTls() as any })).rejects.toThrow(message); + await expect(fetch("https://127.0.0.1:1/", { tls: makeTls() as any })).rejects.toThrow(message); }); }); From e85d1369cb805cce63e31f7269a8301fe35d039b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 09:06:52 +0000 Subject: [PATCH 161/174] test(tls): the close deadline test checks when the closes came The times were only in the message of the assertion, so a deadline of 2 s or of 20 s passed. On Linux, where the peers do stall their writers, both closes have to come 7 to 16 s after the end() (10 s on a 4 s tick). The dripping peer read every 2 s: five reads of up to 512 KiB against the 2.7 MB that were left, and in 1 of 12 runs on a loaded machine it got all of it and was not cut short. Every 3 s leaves over 1 MB. --- test/js/bun/net/socket.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 825c6092797f..f1b56ba41338 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1762,7 +1762,8 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn if (refuses) socketFaultInjection.clear(); await once(destroyed, "close"); - const drip = setInterval(() => peers[DRIPPING].resume(), 2000); + // Three reads of at most 512 KiB ahead of the deadline: progress that does not put it off, and well short of what is left. + const drip = setInterval(() => peers[DRIPPING].resume(), 3000); try { const before = { fdIsOpen: openAfterDestroy, wrote: probeBatching(probe) }; const waitedFrom = performance.now(); @@ -1785,6 +1786,8 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn after, timeouts, cutShort: [missing(SILENT) > 0, missing(DRIPPING) > 0], + // 10 s on a 4 s tick is 8 to 12 s, counted from a little after the end(). + atTheDeadline: measured.closedAfterMs.slice(0, 2).map(ms => ms > 7_000 && ms < 16_000), missing: missing(UNANSWERING), }, JSON.stringify(measured), @@ -1795,6 +1798,7 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn timeouts: 0, // Outside Linux the loopback buffers can take all that a writer has left, and then its peer stalls nothing. cutShort: isLinux ? [true, true] : [expect.any(Boolean), expect.any(Boolean)], + atTheDeadline: isLinux ? [true, true] : [expect.any(Boolean), expect.any(Boolean)], missing: 0, }); } finally { From 94b39de3eec6fec1d9f34ae167ffe829d841cd8a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 09:32:27 +0000 Subject: [PATCH 162/174] test(tls): the close deadline test times each close from its own end() The deadline runs from the end(), and the test counted from after its setup: two more handshakes, a 12 MiB write, a destroy() and its 'close'. On a slow build, over 1 s of that would put a close that came on time under the floor of 7 s. --- test/js/bun/net/socket.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index f1b56ba41338..fdd1c5a0f925 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1731,6 +1731,7 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn }); let timeouts = 0; const reported: number[] = [0]; + const endedAt: number[] = []; // A write that ends short leaves ciphertext unsent, and end() in the same tick finds it there. const fillAndEnd = (socket: Socket) => { let total = 0; @@ -1739,16 +1740,17 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn // The owner's own timeout is over with its close. socket.timeout(1); socket.end(); + endedAt.push(performance.now()); }; const probeReady = Promise.withResolvers(); await connected(probeReady.resolve); const probe = await probeReady.promise; - const closed: Promise[] = []; + const closed: Promise[] = []; for (const _peer of [SILENT, DRIPPING, UNANSWERING]) { - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve } = Promise.withResolvers(); closed.push(promise); - await connected(fillAndEnd, resolve); + await connected(fillAndEnd, () => resolve(performance.now())); } const destroyed = tlsConnect({ port: server.port, host: "127.0.0.1", ca: tls.cert }); await Promise.all([once(destroyed, "secureConnect"), lastPeerPaused.promise]); @@ -1767,8 +1769,7 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn try { const before = { fdIsOpen: openAfterDestroy, wrote: probeBatching(probe) }; const waitedFrom = performance.now(); - const closedAfter = closed.map(promise => promise.then(() => Math.round(performance.now() - waitedFrom))); - await Promise.all(closed); + const closedAfterMs = (await Promise.all(closed)).map((at, i) => Math.round(at - endedAt[i])); // Its own deadline: the other closes end early where their peers' kernel buffers took everything. while (fdIsOpen(destroyedFd) && performance.now() - waitedFrom < 16_000) await Bun.sleep(100); const after = { fdIsOpen: fdIsOpen(destroyedFd), wrote: probeBatching(probe) }; @@ -1778,7 +1779,7 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn const measured = { reported, received: peers.map(peer => peer.data.received), - closedAfterMs: await Promise.all(closedAfter), + closedAfterMs, }; expect( { @@ -1786,7 +1787,7 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn after, timeouts, cutShort: [missing(SILENT) > 0, missing(DRIPPING) > 0], - // 10 s on a 4 s tick is 8 to 12 s, counted from a little after the end(). + // 10 s on a 4 s tick is 8 to 12 s after the end(). atTheDeadline: measured.closedAfterMs.slice(0, 2).map(ms => ms > 7_000 && ms < 16_000), missing: missing(UNANSWERING), }, From 949c2d96ec92f86b6552c74e66720320d5249cf2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 09:57:25 +0000 Subject: [PATCH 163/174] tls: a send that the kernel rejects costs nothing that the peer sent The engine ends only the write side when the kernel rejects a send(), reads what the peer sent, then closes. Three ways around that, found by running the same seeded scenarios on this branch, on main and on Node: - node:tls failed the write at once, and the stream destroyed the socket in the next tick. In 'secureConnection' that is the middle of the read that carries the client's Finished, its message and its close_notify: the message was gone. An https client that was still uploading lost the response the same way and saw only EPIPE. The write now fails with the engine's close, with its own error (write EPIPE), behind 'data' and 'end' as in Node. - A close_notify that is refused was reported as a reset. With both directions finished, net.ts takes that for teardown noise and destroys the socket quietly, and a paused reader lost what it had not read: no 'data', no 'end', no 'error'. The close_notify is sealed behind all that the owner wrote, so with the peer's own already in, nothing is lost and the close is clean. And teardown noise no longer destroys a socket that has something left to read. - The end of the stream behind a rejected send took the loop's "FIN back after ours" branch: a clean close, with no word about a handshake that was still pending. tls.createServer({ allowHalfOpen: true }) then never destroyed the socket of a client that reset after its ClientHello: no 'tlsClientError', and server.close() never called back. One bit says that the engine shut the write side down, not the owner, and that close goes through the engine. A ClientHello that is rejected now fails with Node's "Client network socket disconnected before secure TLS connection was established". --- packages/bun-usockets/src/crypto/openssl.c | 16 ++++ packages/bun-usockets/src/internal/internal.h | 3 + packages/bun-usockets/src/loop.c | 3 +- src/js/node/net.ts | 33 ++++++-- test/js/node/tls/node-tls-server.test.ts | 76 +++++++++++++++++++ test/js/node/tls/tls-syscall-fault.test.ts | 16 +++- 6 files changed, 138 insertions(+), 9 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index f8d55e8bb10c..743b51cd3979 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -533,6 +533,7 @@ static int ssl_raw_write(struct loop_ssl_data *loop_ssl_data, struct us_socket_t int written = us_internal_socket_raw_write(s, data, length, &send_error); if (send_error) { loop_ssl_data->ssl_send_error = send_error; + s->ssl_send_rejected = 1; us_internal_socket_raw_shutdown(s); /* No write may close under its caller: us_internal_ssl_on_writable does. */ us_internal_rearm_writable(s); @@ -1794,6 +1795,7 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, s->ssl_pending_close_code = 0; s->ssl_first_flight_before_fin = 0; s->ssl_shutdown_after_first_flight = 0; + s->ssl_send_rejected = 0; s->ssl_is_server = is_client ? 0 : 1; s->ssl_inline_reject = 0; s->ssl_verify_failed = 0; @@ -2423,9 +2425,23 @@ struct us_socket_t *us_internal_ssl_on_timeout(struct us_socket_t *s) { /* The read side delivers what the peer sent before it closes, unless the owner already let go. */ static struct us_socket_t *ssl_close_after_rejected_send(struct us_socket_t *s, int send_error) { if (!s->ssl_close_after_spill && us_socket_queued_input(s) == LIBUS_QUEUED_INPUT_DATA) return s; + if (s->ssl_handshake_state == HANDSHAKE_PENDING) { + ssl_set_loop_data(s); + ssl_trigger_handshake_econnreset(s); + if (ssl_gone(s)) return s; + } + /* The close_notify is sealed behind all that the owner wrote: only this reply to the peer's own was refused. */ + const int both_ended = SSL_SENT_SHUTDOWN | SSL_RECEIVED_SHUTDOWN; + if ((SSL_get_shutdown(s_ssl(s)) & both_ended) == both_ended) { + return us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + } return us_internal_socket_close_raw(s, send_error > 2 ? send_error : LIBUS_ECONNRESET, NULL); } +struct us_socket_t *us_internal_ssl_on_end_after_rejected_send(struct us_socket_t *s) { + return ssl_close_after_rejected_send(s, LIBUS_ECONNRESET); +} + struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { /* Past its FIN a socket polls for writable only when ssl_raw_write asked for this event. */ if (!us_internal_socket_can_raw_write(s)) return ssl_close_after_rejected_send(s, LIBUS_ECONNRESET); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 69493e37b8ff..5c87844169fa 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -260,6 +260,7 @@ struct us_socket_t *us_internal_ssl_on_writable(us_socket_r s); struct us_socket_t *us_internal_ssl_on_timeout(us_socket_r s); struct us_socket_t *us_internal_ssl_on_close(us_socket_r s, int code, void *reason); struct us_socket_t *us_internal_ssl_on_end(us_socket_r s); +struct us_socket_t *us_internal_ssl_on_end_after_rejected_send(us_socket_r s); int us_internal_ssl_is_low_prio(us_socket_r s); int us_internal_ssl_is_handshake_finished(us_socket_r s); @@ -377,6 +378,8 @@ struct us_socket_t { unsigned char ssl_first_flight_before_fin : 1; /* us_internal_ssl_shutdown held its FIN back for that step. */ unsigned char ssl_shutdown_after_first_flight : 1; + /* ssl_raw_write shut the write side down, not the owner. */ + unsigned char ssl_send_rejected : 1; /* Consecutive send() failures with an errno that is neither * would-block/transient nor a known peer-gone error (see * us_socket_write_check_error). Reset by any send that makes progress. diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index f28b41ffb650..44f2c1ffa9cc 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -926,7 +926,8 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in } if (us_socket_is_shut_down(s)) { /* We got FIN back after sending it */ - s = us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + s = s->ssl && s->ssl_send_rejected ? us_internal_ssl_on_end_after_rejected_send(s) + : us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); return; } if (s->flags.allow_half_open && !hangup && s->read_eof) { diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 5402d273dfb9..cb390e2883bf 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -350,13 +350,30 @@ const kOnreadPendingEnd = Symbol("kOnreadPendingEnd"); const kOnreadReadRequested = Symbol("kOnreadReadRequested"); const kOnreadEmptyTail = Buffer.alloc(0); const kwriteCallback = Symbol("writeCallback"); +const kRejectedWrite = Symbol("kRejectedWrite"); const kSocketClass = Symbol("kSocketClass"); // A completed write whose status is a negative errno: Node hands it to the write // callback as errnoException(status, 'write') and destroys the stream when no // callback is pending. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L81-L92 function failWrite(self, negErrno, callback) { - const er = writeErrnoException(negErrno); + if (self.encrypted && !self[kclosed]) { + // The TLS engine ended the write side only. It reads what the peer sent, then closes, and the write fails with that close. + self._pendingData = null; + self[kwriteCallback] = callback; + self[kRejectedWrite] = writeErrnoException(negErrno); + return; + } + failWriteWith(self, writeErrnoException(negErrno), callback); +} +function failRejectedWrite(self) { + const er = self[kRejectedWrite]; + if (er === undefined) return false; + self[kRejectedWrite] = undefined; + failWriteWith(self, er, self[kwriteCallback]); + return true; +} +function failWriteWith(self, er, callback) { self._pendingData = null; self[kwriteCallback] = null; if (callback) { @@ -800,7 +817,7 @@ const SocketHandlers = { if (!self) return; const callback = self[kwriteCallback]; self.connecting = false; - if (callback) { + if (callback && self[kRejectedWrite] === undefined) { const writeChunk = self._pendingData; const res = socket.$write(writeChunk || "", self._pendingEncoding || "utf8"); if (typeof res === "number" && res < 0) { @@ -972,6 +989,7 @@ function deferEndForOnreadTail(self) { } function SocketEmitEndNT(self, _err?) { + if (self[kclosed] && failRejectedWrite(self)) return; // A read error delivered with the close (e.g. a received RST surfacing as // ECONNRESET) is not a clean EOF — Node destroys the socket with the error // ("read ECONNRESET") instead of emitting a graceful 'end'. Guard on @@ -1038,11 +1056,12 @@ function SocketEmitEndNT(self, _err?) { } if (!self[kended]) { finishSocketEnd(self); - } else if (_err && !self.destroyed) { + } else if (_err && !self.destroyed && !(teardownNoise && !self.readableEnded)) { // An error excluded from the synthesis above (teardown noise, or no // listener attached): nothing more is coming, but the socket still has to // finish its lifecycle - close it quietly instead of leaving it open with - // no further events. + // no further events. What a finished exchange left unread is still the + // reader's: its 'end' closes the socket. self.destroy(); } // A write that was waiting on the native drain can never complete once the @@ -1654,7 +1673,7 @@ const SocketHandlers2 = { const { self } = socket.data; const callback = self[kwriteCallback]; self.connecting = false; - if (callback) { + if (callback && self[kRejectedWrite] === undefined) { const writeChunk = self._pendingData; const res = socket.$write(writeChunk || "", self._pendingEncoding || "utf8"); if (typeof res === "number" && res < 0) { @@ -1701,6 +1720,7 @@ const SocketHandlers2 = { if (self[kclosed]) return; self[kclosed] = true; const leftToTLSSocket = closeWithTLSSocket(self, socket); + if (failRejectedWrite(self)) return; // A received RST surfacing as ECONNRESET with the close is not a clean // EOF - Node destroys the socket with "read ECONNRESET" instead of a // graceful 'end'. Only surface it when the closing handle is still the @@ -3042,7 +3062,8 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { if (res < 0) { // The kernel rejected the send outright (peer reset): $write returned the // negative errno; deliver it like the EBADF/EPIPE branch above. - callback(writeErrnoException(res)); + if (this.encrypted) failWrite(this, res, callback); + else callback(writeErrnoException(res)); return false; } if (res) { diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 9b45eaad0f8c..15dfefbde5bf 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -5162,3 +5162,79 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("accepted and injected connection } }); }); + +// The kernel rejects a send() to a peer that is gone. That ends the writes, not the reads. +describe("a send that the kernel rejects", () => { + const turns = async (count: number) => { + for (let i = 0; i < count; i++) await new Promise(setImmediate); + }; + async function listening(server: Server) { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { host: "127.0.0.1", port: (server.address() as AddressInfo).port, rejectUnauthorized: false }; + } + function observed(socket: TLSSocket) { + const seen = { received: 0, events: [] as string[] }; + socket.on("data", chunk => void (seen.received += chunk.length)); + socket.on("end", () => seen.events.push("end")); + // Whether a send is rejected at all is up to how fast the reset of the peer arrives. + socket.on("error", () => {}); + return new Promise(resolve => socket.on("close", () => resolve(seen))); + } + + it("does not drop what the peer sent before it closed", async () => { + const outcome = Promise.withResolvers<{ received: number; events: string[] }>(); + await using server: Server = createServer(COMMON_CERT, socket => { + outcome.resolve(observed(socket)); + // The Finished of the client, its message and its close_notify are one read, and this runs in the middle of it. + socket.write(Buffer.alloc(70000, "a")); + socket.write(Buffer.alloc(16384, "b")); + }); + const client = connect(await listening(server), () => + client.write(Buffer.alloc(100, "c"), () => client.destroySoon()), + ); + client.on("error", () => {}); + expect(await outcome.promise).toEqual({ received: 100, events: ["end"] }); + }); + + it("of a close_notify leaves a paused reader what it has not read", async () => { + const outcome = Promise.withResolvers<{ received: number; events: string[] }>(); + await using server: Server = createServer(COMMON_CERT, socket => { + socket.pause(); + outcome.resolve(observed(socket)); + socket.pause(); + turns(3).then(() => socket.write("reply", () => socket.end())); + turns(5).then(() => socket.resume()); + }); + const client = connect(await listening(server), () => { + client.write(Buffer.alloc(16383, "c")); + client.destroySoon(); + }); + client.on("error", () => {}).pause(); + expect(await outcome.promise).toEqual({ received: 16383, events: ["end"] }); + }); + + it.each([true, false])("in the handshake is a 'tlsClientError' (allowHalfOpen: %p)", async allowHalfOpen => { + const clientHello = Promise.withResolvers(); + await using recorder = net.createServer(socket => socket.on("error", () => {}).once("data", clientHello.resolve)); + recorder.listen(0, "127.0.0.1"); + await once(recorder, "listening"); + const recorded = connect({ host: "127.0.0.1", port: (recorder.address() as AddressInfo).port }); + recorded.on("error", () => {}); + const hello = await clientHello.promise; + recorded.destroy(); + + const server: Server = createServer({ ...COMMON_CERT, allowHalfOpen }); + const reported = once(server, "tlsClientError"); + const { host, port } = await listening(server); + const raw = net.connect(port, host, () => { + raw.write(hello); + raw.resetAndDestroy(); + }); + raw.on("error", () => {}); + const [error] = await reported; + expect(error.code).toBe("ECONNRESET"); + // The connection is over for the server too. + await new Promise(resolve => server.close(resolve)); + }); +}); diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 9ca5ca0d27fb..95ebfb2b9db1 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -136,7 +136,8 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { const WRITE_RESET = { name: "Error", code: "ECONNRESET", syscall: "write", message: "write ECONNRESET" }; expect(await written.promise).toMatchObject(WRITE_RESET); await seen.closed; - expect({ events: seen.events, errors: seen.errors }).toEqual({ + // The reads go on until the close, so the FIN that the peer answers with can come first. + expect({ events: seen.events.filter(event => event !== "end"), errors: seen.errors }).toEqual({ events: ["error", "close(hadError=true)"], errors: [WRITE_RESET], }); @@ -192,7 +193,18 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { const client = observe(c); await client.closed; fault.clear(); - expect({ events: client.events, errors: client.errors, destroyed: c.destroyed }).toEqual(RESET_CLOSE); + expect({ events: client.events, errors: client.errors, destroyed: c.destroyed }).toEqual({ + events: ["end", "error", "close(hadError=true)"], + errors: [ + { + name: "Error", + code: "ECONNRESET", + syscall: undefined, + message: "Client network socket disconnected before secure TLS connection was established", + }, + ], + destroyed: true, + }); }); // https://github.com/oven-sh/bun/issues/24845: the kernel rejects every send() and reports nothing on the read side. From 009de1a328a47a8117d1019d52ddd8bde1ebdd58 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 10:08:34 +0000 Subject: [PATCH 164/174] node:https: closeAllConnections() does not wait for a client that stopped reading destroy() closes the handle, and the close of a TLS handle waits for ciphertext that write() reported and the kernel has not taken, up to its deadline of 10 s. The 'close' of a server's socket comes with the native close, so with one stalled client server.close() called back 12 s after closeAllConnections(), every time. On main it does so at once or never. A close waits at most once: closeAllConnections() closes the handle a second time. --- src/js/node/_http_server.ts | 11 +++++- test/js/node/http/node-https-server.test.ts | 42 +++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 6384ab88f178..319106d8808a 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -518,7 +518,7 @@ Server.prototype.closeAllConnections = function () { const tracked = this[kTrackedConnections]; if (tracked && tracked.size > 0) { for (const socket of $Array.from(tracked) as NodeHTTPServerSocket[]) { - if (!socket[kHandedOff]) socket.destroy(); + if (!socket[kHandedOff]) socket.destroyNow(); } } }; @@ -1684,6 +1684,7 @@ function getNodeHTTPServerSocket() { #pendingCallback = null; #pendingAbortMessage; #closeHandled = false; + #closingHandle; #resetSupported; #closeError: Error | undefined = undefined; declare encrypted: boolean; @@ -1804,6 +1805,7 @@ function getNodeHTTPServerSocket() { // is deferred to #onClose so the dispatch promise resolves only after the // native on_abort has released the pending-request ref. this.#pendingAbortMessage = this._httpMessage; + this.#closingHandle = handle; handle.onclose = this.#onCloseForDestroy.bind(this, callback, err, handle); if (this.resetAndClosing) { this.resetAndClosing = false; @@ -1921,7 +1923,14 @@ function getNodeHTTPServerSocket() { (pendingCallback as Function)(writeFailure); } } + // The close of a TLS handle waits for ciphertext that write() reported and a stalled peer does not take, but only once. + destroyNow() { + this.destroy(); + const handle = this.#closingHandle; + if (handle && !handle.closed) handle.close(); + } #onCloseForDestroy(closeCallback, err: Error | undefined, handle) { + this.#closingHandle = undefined; this.#onClose(handle); // Thread the destroy error through to the streams machinery (like // Node.js's net.Socket._destroy passing the exception to its callback), diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index 3c4f3b72bbba..26845e3af834 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -423,3 +423,45 @@ describe("server.blockList", () => { expect(exitCode).toBe(0); }); }); + +// A close waits, up to 10 s, for ciphertext that write() reported and the kernel has not taken. closeAllConnections() does not. +test("closeAllConnections() does not wait for a client that stopped reading", async () => { + const stalled = Promise.withResolvers(); + const server = https.createServer(validCert, (_req, res) => { + const chunk = Buffer.alloc(1024 * 1024, "a"); + let left = 16; + (function more() { + while (left-- > 0) { + if (!res.write(chunk)) { + stalled.resolve(); + return void res.once("drain", more); + } + } + res.end(); + })(); + }); + const port = await listen(server); + // In a process of its own: nothing on this loop reads for it. + await using client = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const socket = require("node:tls").connect({ port: ${port}, host: "127.0.0.1", rejectUnauthorized: false }, () => { + socket.pause(); + socket.end("GET / HTTP/1.1\\r\\nHost: localhost\\r\\n\\r\\n"); + }); + socket.on("error", () => {}); + process.stdin.resume();`, + ], + env: bunEnv, + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", + }); + await stalled.promise; + const closed = new Promise(resolve => server.close(resolve)); + server.closeAllConnections(); + // Within the timeout of the test, which is under the deadline of the close. + expect(await closed).toBeUndefined(); + client.kill(); +}); From f5522656c58ff6d21f95306ea8f78485a0785019 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 10:25:52 +0000 Subject: [PATCH 165/174] node:tls: a rejected write is held only while the reads go on, and holds the loop The write that the kernel rejected fails with the close of the engine, which reads first what the peer sent. Two holes in that, from review: - Past the FIN of the peer the handle no longer holds the loop. With nothing else pending the process exited with the write never failed: no callback, no 'error', no 'close'. The held write holds the loop as any pending write does. - A reader that stopped for backpressure, with input left in the kernel, may be waiting for this very write, while the engine waits for it to read. Its write fails at once, as in Node, and so does a held write when the reads stop. --- src/js/node/net.ts | 27 +++++++------ test/js/node/tls/tls-syscall-fault.test.ts | 44 ++++++++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index cb390e2883bf..3d071ea309b3 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -357,15 +357,20 @@ const kSocketClass = Symbol("kSocketClass"); // callback as errnoException(status, 'write') and destroys the stream when no // callback is pending. https://github.com/nodejs/node/blob/v26.3.0/lib/internal/stream_base_commons.js#L81-L92 function failWrite(self, negErrno, callback) { - if (self.encrypted && !self[kclosed]) { - // The TLS engine ended the write side only. It reads what the peer sent, then closes, and the write fails with that close. - self._pendingData = null; - self[kwriteCallback] = callback; - self[kRejectedWrite] = writeErrnoException(negErrno); - return; - } failWriteWith(self, writeErrnoException(negErrno), callback); } +// The TLS engine ended the write side only. It reads what the peer sent, then closes, and the write fails with that close. +// Not for a reader that stopped, which may be waiting for this very write: readStop() fails it too. +function holdRejectedWrite(self, handle, negErrno, callback) { + if (!self.encrypted || self[kclosed] || self[kPausedUnref]) return false; + if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return false; + self._pendingData = null; + self[kwriteCallback] = callback; + self[kRejectedWrite] = writeErrnoException(negErrno); + // It holds the loop as any pending write does (_write). + if (self[kended] && !self[kUserUnrefed]) handle.ref?.(); + return true; +} function failRejectedWrite(self) { const er = self[kRejectedWrite]; if (er === undefined) return false; @@ -822,7 +827,7 @@ const SocketHandlers = { const res = socket.$write(writeChunk || "", self._pendingEncoding || "utf8"); if (typeof res === "number" && res < 0) { // The retried send failed for good (peer gone): $write returned -errno. - failWrite(self, res, callback); + if (!holdRejectedWrite(self, socket, res, callback)) failWrite(self, res, callback); } else if (res) { self._pendingData = self[kwriteCallback] = null; unrefAfterDrain(self, socket); @@ -940,6 +945,7 @@ function readStop(self, handle) { // A socket over a generic duplex has no fd and never held the loop. if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return; self[kPausedUnref] = true; + if (failRejectedWrite(self)) return; if (!self[kwriteCallback]) handle?.unref?.(); } @@ -1679,7 +1685,7 @@ const SocketHandlers2 = { if (typeof res === "number" && res < 0) { // The retried send failed for good (peer gone): $write returned -errno. self[kBytesWritten] = socket.bytesWritten; - failWrite(self, res, callback); + if (!holdRejectedWrite(self, socket, res, callback)) failWrite(self, res, callback); } else if (res) { self[kBytesWritten] = socket.bytesWritten; self._pendingData = self[kwriteCallback] = null; @@ -3062,8 +3068,7 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { if (res < 0) { // The kernel rejected the send outright (peer reset): $write returned the // negative errno; deliver it like the EBADF/EPIPE branch above. - if (this.encrypted) failWrite(this, res, callback); - else callback(writeErrnoException(res)); + if (!holdRejectedWrite(this, socket, res, callback)) callback(writeErrnoException(res)); return false; } if (res) { diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index 95ebfb2b9db1..ad269a142521 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -187,6 +187,50 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { ); }); + // The write fails with the close of the engine, which reads first. A reader that stopped may be waiting for the write. + test("send → EPIPE fails the write of a reader that stopped for backpressure at once", async () => { + using p = await connectedTLSPair(); + const client = observe(p.client); + p.client.pause(); + // More than the client buffers, so it stops reading with input left in the kernel. + await new Promise(resolve => p.serverSock.write(Buffer.alloc(1024 * 1024, "a"), () => resolve())); + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1, fd: fdOf(p.client) }); + const written = Promise.withResolvers(); + p.client.write("hello", written.resolve); + expect(await written.promise).toMatchObject({ code: "EPIPE", syscall: "write" }); + await client.closed; + expect(client.events).toEqual(["error", "close(hadError=true)"]); + }); + + test("send → EPIPE past the FIN of the peer holds the loop until the write has failed", async () => { + await using ending = tls.createServer({ key: certs.key, cert: certs.cert, allowHalfOpen: true }, socket => { + socket.on("error", () => {}).end(); + }); + ending.listen(0, "127.0.0.1"); + await once(ending, "listening"); + // Half-open and past the FIN, the handle holds the loop only for a pending write, and nothing else does here. + await using child = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { socketFaultInjection: fault } = require("bun:internal-for-testing"); + const options = { port: ${(ending.address() as AddressInfo).port}, host: "127.0.0.1", rejectUnauthorized: false, allowHalfOpen: true }; + const socket = require("node:tls").connect(options); + socket.on("error", error => console.log("error", error.code)).on("close", () => console.log("close")).resume(); + socket.on("end", () => { + fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1, fd: socket._handle.fd }); + socket.write("x", error => console.log("write", error?.code)); + });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([child.stdout.text(), child.exited]); + expect(stdout.trim().split("\n")).toEqual(["write EPIPE", "error EPIPE", "close"]); + expect(exitCode).toBe(0); + }); + test("send → EPIPE for the ClientHello fails connect with an error (no hang)", async () => { fault.set({ syscall: "send", action: "errno", errno: "EPIPE", repeat: -1 }); const c = connect(); From 4f41d3f52c7fa35c9b273f219667aba5e128c0be Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 10:31:17 +0000 Subject: [PATCH 166/174] tls: the retry of a parked write does not come behind the owner's end() The end of the handshake owes the owner one writable event, for a write parked before it. The tail of a read pays it, unless the socket has unsent ciphertext then. The flag stayed set in two cases, and the event came behind end(), while the socket read on for the peer's close_notify. Bun.listen / Bun.connect take it for the end of their flush and close again, over unread input: a reset, and the kernel drops what it had not sent. - A writer that is saturated from open() on has unsent ciphertext at every tail, now that it is kept per connection. A writable event from the kernel is that retry, and clears the flag. 1.3 of 4 MiB that write() had reported were lost with the peer still uploading, 5 of 5 runs. main delivers them. - end() in open(), ahead of any read: behind the close_notify there is no write to retry. main loses 0.9 of 1 MiB there, and this branch did too. --- packages/bun-usockets/src/crypto/openssl.c | 4 ++ test/js/bun/net/socket.test.ts | 61 ++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 743b51cd3979..ce4b5194bbea 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2354,6 +2354,8 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s); static struct us_socket_t *ssl_retry_parked_write(struct us_socket_t *s) { if (!s->ssl_write_wants_read || s->ssl_spilled) return s; s->ssl_write_wants_read = 0; + /* Behind the close_notify there is no write to retry, and the owner would take the event for the end of its flush and close again. */ + if (SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) return s; return ssl_on_writable(s); } @@ -2477,6 +2479,8 @@ static struct us_socket_t *ssl_on_writable(struct us_socket_t *s) { if (s->ssl_handshake_state != HANDSHAKE_PENDING) { s->ssl_write_parked = 0; + /* This is the retry that ssl_retry_parked_write owes. Kept, it would come behind the owner's end(), as a second close. */ + s->ssl_write_wants_read = 0; /* loop.c drops the writable interest of a shut-down socket after this dispatch. */ s = ssl_close_if_fatal(us_dispatch_writable(s)); } diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index fdd1c5a0f925..8888052c2eff 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1677,6 +1677,67 @@ describe("TLS write() that ends short while another TLS socket is stalled", () = }); }); +// After its close_notify a socket reads on until the peer's. A close ahead of that, over unread input, is a reset, +// and the kernel drops what it had not sent yet. +it.each([ + ["that the kernel took at once", 1024 * 1024], + ["that waited for the kernel", 4 * 1024 * 1024], +])("TLS end() while the peer is still sending delivers a write %s", async (_name, size) => { + const upload = 8 * 1024 * 1024; + const payload = Buffer.alloc(upload, "a"); + type Side = { total: number; reported: number; received: number; events: string[] }; + const done = Promise.withResolvers(); + const sides: Side[] = []; + const handlers = (total: number, finished: (socket: Socket, side: Side) => void) => { + const side: Side = { total, reported: 0, received: 0, events: [] }; + sides.push(side); + const pump = (socket: Socket) => { + if (side.reported === total) return; + while (side.reported < total) { + const wrote = socket.write(payload.subarray(side.reported, total)); + if (wrote <= 0) return; + side.reported += wrote; + } + finished(socket, side); + }; + return { + open: pump, + drain: pump, + data: (_socket: Socket, chunk: Buffer) => void (side.received += chunk.length), + end(socket: Socket) { + side.events.push("end"); + if (side.reported === total) socket.end(); + }, + close(_socket: Socket, error?: Error) { + side.events.push(error ? `close: ${(error as NodeJS.ErrnoException).code}` : "close"); + if (sides.every(({ events }) => events.at(-1)?.startsWith("close"))) done.resolve(); + }, + }; + }; + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: handlers(size, socket => socket.end()), + }); + await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { ca: tls.cert }, + allowHalfOpen: true, + socket: handlers(upload, (socket, side) => void (side.events.includes("end") && socket.end())), + }); + await done.promise; + const [accepted, client] = sides; + expect({ + client: { received: client.received, events: client.events }, + server: { received: accepted.received, events: accepted.events }, + }).toEqual({ + client: { received: size, events: ["end", "close"] }, + server: { received: upload, events: ["end", "close"] }, + }); +}); + // A close must not cut off ciphertext that write() already reported, so it waits for the kernel to take // it. The owner has let go by then: only the deadline (10 s on a 4 s tick) ends the wait for such a peer. it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", async () => { From 4f78ad26730d3006313f5b61b4f332942b75be79 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 10:46:35 +0000 Subject: [PATCH 167/174] test(tls): the end() test does not ask the side that ended for an 'end' All bytes arrive on Windows too, in both directions, but the server, which has ended, closes there without hearing of the client's end first. --- test/js/bun/net/socket.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index 8888052c2eff..a0cfbfdc8865 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1729,12 +1729,13 @@ it.each([ }); await done.promise; const [accepted, client] = sides; + // The server has ended: whether it hears of the client's end ahead of the close is a matter of timing. expect({ client: { received: client.received, events: client.events }, - server: { received: accepted.received, events: accepted.events }, + server: { received: accepted.received, closed: accepted.events.at(-1) }, }).toEqual({ client: { received: size, events: ["end", "close"] }, - server: { received: upload, events: ["end", "close"] }, + server: { received: upload, closed: "close" }, }); }); From 2f998ea15a631cbfe6ceb47c687a03d9c7a5fda5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 10:46:36 +0000 Subject: [PATCH 168/174] node:tls: a reader that stopped has one turn to go on before its rejected write fails Failing it at once was too eager. A socket that got more than it buffers in one read has stopped reading until its 'readable' listener runs. A write from nextTick() that the kernel rejects came ahead of that, and all 86,385 bytes that the peer had sent were gone. The branch before, main and Node deliver them: one of 6,000 scenarios, found by running them again on the fix. Node reports a failed write one turn of the loop later. So does this now, for a reader that has not gone on by then. One that has is left to the close of the engine. --- src/js/node/net.ts | 13 +++++++++---- test/js/node/tls/node-tls-server.test.ts | 19 +++++++++++++++++++ test/js/node/tls/tls-syscall-fault.test.ts | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 3d071ea309b3..4d7fdcf2215d 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -360,17 +360,22 @@ function failWrite(self, negErrno, callback) { failWriteWith(self, writeErrnoException(negErrno), callback); } // The TLS engine ended the write side only. It reads what the peer sent, then closes, and the write fails with that close. -// Not for a reader that stopped, which may be waiting for this very write: readStop() fails it too. function holdRejectedWrite(self, handle, negErrno, callback) { - if (!self.encrypted || self[kclosed] || self[kPausedUnref]) return false; + if (!self.encrypted || self[kclosed]) return false; if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return false; self._pendingData = null; self[kwriteCallback] = callback; self[kRejectedWrite] = writeErrnoException(negErrno); // It holds the loop as any pending write does (_write). - if (self[kended] && !self[kUserUnrefed]) handle.ref?.(); + if ((self[kended] || self[kPausedUnref]) && !self[kUserUnrefed]) handle.ref?.(); + if (self[kPausedUnref]) setImmediate(failRejectedWriteOfStoppedReader, self); return true; } +// A reader that stopped may be waiting for this very write, while the engine waits for it to read. It has one turn of the +// loop to go on, which is when Node reports a failed write. +function failRejectedWriteOfStoppedReader(self) { + if (self[kPausedUnref]) failRejectedWrite(self); +} function failRejectedWrite(self) { const er = self[kRejectedWrite]; if (er === undefined) return false; @@ -945,7 +950,7 @@ function readStop(self, handle) { // A socket over a generic duplex has no fd and never held the loop. if (self[kupgraded] && !(self[kupgraded] instanceof Socket)) return; self[kPausedUnref] = true; - if (failRejectedWrite(self)) return; + if (self[kRejectedWrite] !== undefined) setImmediate(failRejectedWriteOfStoppedReader, self); if (!self[kwriteCallback]) handle?.unref?.(); } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 15dfefbde5bf..62e58c174ea5 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -5214,6 +5214,25 @@ describe("a send that the kernel rejects", () => { expect(await outcome.promise).toEqual({ received: 16383, events: ["end"] }); }); + it("leaves a reader that stopped for backpressure the time to go on", async () => { + const sent = 70000 + 16385; + const outcome = Promise.withResolvers(); + await using server: Server = createServer(COMMON_CERT, socket => { + let received = 0; + // More than the socket buffers arrives in the one read, so the reads stop until this runs. + socket.on("readable", () => { + for (let chunk; (chunk = socket.read()) !== null; ) received += chunk.length; + }); + socket.on("error", () => {}).on("close", () => outcome.resolve(received)); + process.nextTick(() => socket.write(Buffer.alloc(8 * 1024 * 1024, "a"))); + }); + const client = connect(await listening(server)); + client.on("error", () => {}); + client.write(Buffer.alloc(70000, "c")); + client.write(Buffer.alloc(16385, "c"), () => client.destroySoon()); + expect(await outcome.promise).toBe(sent); + }); + it.each([true, false])("in the handshake is a 'tlsClientError' (allowHalfOpen: %p)", async allowHalfOpen => { const clientHello = Promise.withResolvers(); await using recorder = net.createServer(socket => socket.on("error", () => {}).once("data", clientHello.resolve)); diff --git a/test/js/node/tls/tls-syscall-fault.test.ts b/test/js/node/tls/tls-syscall-fault.test.ts index ad269a142521..9fb1d51e5341 100644 --- a/test/js/node/tls/tls-syscall-fault.test.ts +++ b/test/js/node/tls/tls-syscall-fault.test.ts @@ -188,7 +188,7 @@ describe.skipIf(skip)("node:tls under injected syscall faults", () => { }); // The write fails with the close of the engine, which reads first. A reader that stopped may be waiting for the write. - test("send → EPIPE fails the write of a reader that stopped for backpressure at once", async () => { + test("send → EPIPE fails the write of a reader that stopped for backpressure and does not go on", async () => { using p = await connectedTLSPair(); const client = observe(p.client); p.client.pause(); From 2f75cb6deeca839a94f772eeda7bfca555e7652c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 11:03:41 +0000 Subject: [PATCH 169/174] test(tls): Windows drops what is unread in the kernel at a reset 70,000 of the 86,385 bytes arrive there: the rest had not been read when the reset of the client came. --- test/js/node/tls/node-tls-server.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 62e58c174ea5..76b2c8a08f5f 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -5214,7 +5214,8 @@ describe("a send that the kernel rejects", () => { expect(await outcome.promise).toEqual({ received: 16383, events: ["end"] }); }); - it("leaves a reader that stopped for backpressure the time to go on", async () => { + // At the reset of the client, Windows drops what is still unread in the kernel. + it.skipIf(isWindows)("leaves a reader that stopped for backpressure the time to go on", async () => { const sent = 70000 + 16385; const outcome = Promise.withResolvers(); await using server: Server = createServer(COMMON_CERT, socket => { From c3caf03f714c91263918acee7ab303d5978bed5c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 11:03:41 +0000 Subject: [PATCH 170/174] node:https: closeAllConnections() in the middle of a request does not wait either In the middle of its own request a handle only takes note of a close and closes behind the request. Both closes of closeAllConnections() ended there, and the one that followed was the first, which waits for a stalled client: 12 s again, in 3 of 6 runs. main never calls back there. One more close, a turn of the loop later. The test counted on the default timeout of 5 s to notice, and CI runs with 90 s. It measures the time now, which is how this showed. --- src/js/node/_http_server.ts | 8 ++++++- test/js/node/http/node-https-server.test.ts | 23 ++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/js/node/_http_server.ts b/src/js/node/_http_server.ts index 319106d8808a..c1a78364a7ba 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -86,6 +86,9 @@ const kPendingCloseGenerations = Symbol("http.server.pendingCloseGenerations"); const kListenerGeneration = Symbol("http.server.listenerGeneration"); // Set on the 'connect'/'upgrade' handoff; closeAll/closeIdleConnections() skip these, as in Node. const kHandedOff = Symbol("http.server.socketHandedOff"); +function closeHandle(handle) { + if (!handle.closed) handle.close(); +} const kHttpAllowHalfOpen = Symbol("http.server.httpAllowHalfOpen"); const kMaxHeadersCount = Symbol("http.server.maxHeadersCount"); @@ -1927,7 +1930,10 @@ function getNodeHTTPServerSocket() { destroyNow() { this.destroy(); const handle = this.#closingHandle; - if (handle && !handle.closed) handle.close(); + if (!handle || handle.closed) return; + handle.close(); + // In the middle of its own request a handle only takes note, and closes behind it, for the first time. + if (!handle.closed) setImmediate(closeHandle, handle); } #onCloseForDestroy(closeCallback, err: Error | undefined, handle) { this.#closingHandle = undefined; diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index 26845e3af834..1f250592ffd3 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -425,7 +425,10 @@ describe("server.blockList", () => { }); // A close waits, up to 10 s, for ciphertext that write() reported and the kernel has not taken. closeAllConnections() does not. -test("closeAllConnections() does not wait for a client that stopped reading", async () => { +test.each([ + ["in the middle of its request", 0], + ["behind its request", 3], +])("closeAllConnections() does not wait for a client that stopped reading, %s", async (_when, turns) => { const stalled = Promise.withResolvers(); const server = https.createServer(validCert, (_req, res) => { const chunk = Buffer.alloc(1024 * 1024, "a"); @@ -458,10 +461,16 @@ test("closeAllConnections() does not wait for a client that stopped reading", as stdout: "inherit", stderr: "inherit", }); - await stalled.promise; - const closed = new Promise(resolve => server.close(resolve)); - server.closeAllConnections(); - // Within the timeout of the test, which is under the deadline of the close. - expect(await closed).toBeUndefined(); - client.kill(); + // Closed from here on, whatever becomes of the client. + const closed = stalled.promise.then(async () => { + for (let i = 0; i < turns; i++) await new Promise(setImmediate); + const from = performance.now(); + const { promise, resolve } = Promise.withResolvers(); + server.close(() => resolve(performance.now() - from)); + server.closeAllConnections(); + return promise; + }); + client.exited.then(() => stalled.resolve()); + // The deadline, 10 s on a 4 s tick, is 8 s away at the least. + expect(await closed).toBeLessThan(5_000); }); From 0799cb575caf696047d7d2e5d0d074d07990c15c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 11:17:34 +0000 Subject: [PATCH 171/174] tls: the wait of a close for the peer's close_notify has a deadline too Bun.listen / Bun.connect end() sends the close_notify and leaves the fd open until the peer answers. The owner has let go by then, and terminate() no longer reaches the socket. The deadline of a close was armed only while ciphertext was unsent. With the kernel having taken it, as it does a little after a stall, a peer that neither reads nor answers kept the fd for good. So does main, except beside other stalled TLS sockets, where it closes at once: 27 of 720 connections of one of the differential runs. The same 10 s, counted from the last sign of the peer: one that is still sending is on its way to its close_notify. node:tls is not concerned: its end() is a shutdown(), which sends the FIN and reads on. --- packages/bun-usockets/src/crypto/openssl.c | 9 +++++- test/js/bun/net/socket.test.ts | 36 +++++++++++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index ce4b5194bbea..037bf928b186 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2109,7 +2109,7 @@ static int ssl_handle_shutdown(struct us_socket_t *s) { return 1; } -/* Seconds a close waits for the peer to take the spill (at most one flush unit, so 13 KB/s): Bun.serve's default idleTimeout. */ +/* Seconds a close waits for the peer to take the spill (at most one flush unit, so 13 KB/s), then for a sign of it: Bun.serve's default idleTimeout. */ #define US_SSL_CLOSE_AFTER_SPILL_TIMEOUT 10 struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void *reason) { @@ -2200,6 +2200,9 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void } /* Only a read delivers the peer's reply, and the owner that paused let go with this close. */ us_socket_resume(s); + /* So nobody else bounds the wait for a peer that neither reads nor answers (us_internal_ssl_on_timeout). */ + us_socket_timeout(s, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT); + us_socket_long_timeout(s, 0); return s; } #define ssl_close us_internal_ssl_close @@ -2564,6 +2567,10 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *socket, char *da ssl_close(s, 0, NULL); return NULL; } + /* close_notify without the FIN: a close waits for the peer's reply, and a peer that is still sending is on its way there. */ + if ((SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) && us_internal_socket_can_raw_write(s)) { + us_socket_timeout(s, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT); + } /* DO NOT call ssl_update_handshake() before the SSL_read loop. SSL_read * drives the handshake itself; firing on_handshake here lets the JS callback diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index a0cfbfdc8865..e1afc28cdd08 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1762,7 +1762,7 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn }; // In the order they connect. Half-open: a peer that reads the close_notify does not answer it. - const [PROBE, SILENT, DRIPPING, UNANSWERING, DESTROYED] = [0, 1, 2, 3, 4]; + const [PROBE, SILENT, DRIPPING, UNANSWERING, LATE, DESTROYED] = [0, 1, 2, 3, 4, 5]; const peers: Socket<{ received: number }>[] = []; const lastPeerPaused = Promise.withResolvers(); const keepsReading = (peer: Socket<{ received: number }>) => peer === peers[PROBE] || peer === peers[UNANSWERING]; @@ -1794,16 +1794,22 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn let timeouts = 0; const reported: number[] = [0]; const endedAt: number[] = []; - // A write that ends short leaves ciphertext unsent, and end() in the same tick finds it there. - const fillAndEnd = (socket: Socket) => { + const fill = (socket: Socket) => { let total = 0; for (let wrote = step.length; wrote === step.length; total += Math.max(wrote, 0)) wrote = socket.write(step); reported.push(total); + }; + const end = (socket: Socket) => { // The owner's own timeout is over with its close. socket.timeout(1); socket.end(); endedAt.push(performance.now()); }; + // A write that ends short leaves ciphertext unsent, and end() in the same tick finds it there. + const fillAndEnd = (socket: Socket) => { + fill(socket); + end(socket); + }; const probeReady = Promise.withResolvers(); await connected(probeReady.resolve); @@ -1814,6 +1820,17 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn closed.push(promise); await connected(fillAndEnd, () => resolve(performance.now())); } + const lateClosed = Promise.withResolvers(); + closed.push(lateClosed.promise); + const lateFilled = Promise.withResolvers(); + await connected( + socket => { + fill(socket); + lateFilled.resolve(socket); + }, + () => lateClosed.resolve(performance.now()), + ); + const late = await lateFilled.promise; const destroyed = tlsConnect({ port: server.port, host: "127.0.0.1", ca: tls.cert }); await Promise.all([once(destroyed, "secureConnect"), lastPeerPaused.promise]); const destroyedFd: number = (destroyed as any)._handle.fd; @@ -1825,6 +1842,12 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn const openAfterDestroy = fdIsOpen(destroyedFd); if (refuses) socketFaultInjection.clear(); await once(destroyed, "close"); + // The kernel takes the unsent rest after a while, and the next write with it. The close_notify then goes out too, and + // all that this close waits for is the reply of a peer that reads none of it. + for (let tries = 0; tries < 200 && late.write("a") !== 1; tries++) await Bun.sleep(10); + // With no timeout of the owner's that could end the wait by chance. + late.end(); + endedAt.push(performance.now()); // Three reads of at most 512 KiB ahead of the deadline: progress that does not put it off, and well short of what is left. const drip = setInterval(() => peers[DRIPPING].resume(), 3000); @@ -1850,7 +1873,10 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn timeouts, cutShort: [missing(SILENT) > 0, missing(DRIPPING) > 0], // 10 s on a 4 s tick is 8 to 12 s after the end(). - atTheDeadline: measured.closedAfterMs.slice(0, 2).map(ms => ms > 7_000 && ms < 16_000), + atTheDeadline: [SILENT, DRIPPING, LATE].map(peer => { + const ms = closedAfterMs[peer - 1]; + return ms > 7_000 && ms < 16_000; + }), missing: missing(UNANSWERING), }, JSON.stringify(measured), @@ -1861,7 +1887,7 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn timeouts: 0, // Outside Linux the loopback buffers can take all that a writer has left, and then its peer stalls nothing. cutShort: isLinux ? [true, true] : [expect.any(Boolean), expect.any(Boolean)], - atTheDeadline: isLinux ? [true, true] : [expect.any(Boolean), expect.any(Boolean)], + atTheDeadline: isLinux ? [true, true, true] : [expect.any(Boolean), expect.any(Boolean), true], missing: 0, }); } finally { From de95ca8a37377a1bfb8b9e553703a216aba237d6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 11:18:19 +0000 Subject: [PATCH 172/174] test(https): a client that exits early fails the closeAllConnections() test Its exit stood in for the stall, so that the listener goes in any case, and the test then passed with no connection to close. --- test/js/node/http/node-https-server.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/test/js/node/http/node-https-server.test.ts b/test/js/node/http/node-https-server.test.ts index 1f250592ffd3..26ad2d79ae37 100644 --- a/test/js/node/http/node-https-server.test.ts +++ b/test/js/node/http/node-https-server.test.ts @@ -429,14 +429,14 @@ test.each([ ["in the middle of its request", 0], ["behind its request", 3], ])("closeAllConnections() does not wait for a client that stopped reading, %s", async (_when, turns) => { - const stalled = Promise.withResolvers(); + const stalled = Promise.withResolvers(); const server = https.createServer(validCert, (_req, res) => { const chunk = Buffer.alloc(1024 * 1024, "a"); let left = 16; (function more() { while (left-- > 0) { if (!res.write(chunk)) { - stalled.resolve(); + stalled.resolve("a write that the client does not take"); return void res.once("drain", more); } } @@ -462,15 +462,16 @@ test.each([ stderr: "inherit", }); // Closed from here on, whatever becomes of the client. - const closed = stalled.promise.then(async () => { + const closed = stalled.promise.then(async closedAt => { for (let i = 0; i < turns; i++) await new Promise(setImmediate); const from = performance.now(); - const { promise, resolve } = Promise.withResolvers(); - server.close(() => resolve(performance.now() - from)); + const { promise, resolve } = Promise.withResolvers(); + server.close(() => resolve()); server.closeAllConnections(); - return promise; + await promise; + // The deadline, 10 s on a 4 s tick, is 8 s away at the least. + return { closedAt, wellAheadOfTheDeadline: performance.now() - from < 5_000 }; }); - client.exited.then(() => stalled.resolve()); - // The deadline, 10 s on a 4 s tick, is 8 s away at the least. - expect(await closed).toBeLessThan(5_000); + client.exited.then(() => stalled.resolve("the exit of the client")); + expect(await closed).toEqual({ closedAt: "a write that the client does not take", wellAheadOfTheDeadline: true }); }); From 41e5dabc2bd5bb489c9c25e469cdc27f21b2f9f5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sat, 10 Oct 2026 12:49:38 +0000 Subject: [PATCH 173/174] tls: the deadline of a close counts from the close, whatever the peer sends Every read put the wait for the peer's close_notify off by another 10 s, as a sign that the peer was on its way there. It is none: a TLS 1.3 peer may go on sending after our close_notify. One record every few seconds kept the fd, with an owner that has let go. The deadline is fixed now, as the one for unsent ciphertext is. --- packages/bun-usockets/src/crypto/openssl.c | 8 ++------ test/js/bun/net/socket.test.ts | 6 +++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 037bf928b186..22de54a57ba2 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -2109,7 +2109,7 @@ static int ssl_handle_shutdown(struct us_socket_t *s) { return 1; } -/* Seconds a close waits for the peer to take the spill (at most one flush unit, so 13 KB/s), then for a sign of it: Bun.serve's default idleTimeout. */ +/* Seconds a close waits for the peer to take the spill (at most one flush unit, so 13 KB/s), then for its close_notify: Bun.serve's default idleTimeout. */ #define US_SSL_CLOSE_AFTER_SPILL_TIMEOUT 10 struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void *reason) { @@ -2200,7 +2200,7 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void } /* Only a read delivers the peer's reply, and the owner that paused let go with this close. */ us_socket_resume(s); - /* So nobody else bounds the wait for a peer that neither reads nor answers (us_internal_ssl_on_timeout). */ + /* So nobody else bounds the wait for a peer that does not answer, whatever else it sends (us_internal_ssl_on_timeout). */ us_socket_timeout(s, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT); us_socket_long_timeout(s, 0); return s; @@ -2567,10 +2567,6 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *socket, char *da ssl_close(s, 0, NULL); return NULL; } - /* close_notify without the FIN: a close waits for the peer's reply, and a peer that is still sending is on its way there. */ - if ((SSL_get_shutdown(s_ssl(s)) & SSL_SENT_SHUTDOWN) && us_internal_socket_can_raw_write(s)) { - us_socket_timeout(s, US_SSL_CLOSE_AFTER_SPILL_TIMEOUT); - } /* DO NOT call ssl_update_handshake() before the SSL_read loop. SSL_read * drives the handshake itself; firing on_handshake here lets the JS callback diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index e1afc28cdd08..8bfba77a0ac2 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1850,7 +1850,11 @@ it("a TLS close that waits for unsent ciphertext ends at a fixed deadline", asyn endedAt.push(performance.now()); // Three reads of at most 512 KiB ahead of the deadline: progress that does not put it off, and well short of what is left. - const drip = setInterval(() => peers[DRIPPING].resume(), 3000); + const drip = setInterval(() => { + peers[DRIPPING].resume(); + // Nor does what a peer sends in place of its close_notify. + peers[LATE].write("still here"); + }, 3000); try { const before = { fdIsOpen: openAfterDestroy, wrote: probeBatching(probe) }; const waitedFrom = performance.now(); From 6d4db22cb0fc33baad485add3393011fa7ce51c3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:11:09 +0000 Subject: [PATCH 174/174] test(tls): an upload survives a renegotiation under write backpressure Two tests for a TLS 1.2 client socket, with a node fixture that reads and renegotiates only on command, so no step depends on relative speeds. The first parks an upload on backpressure, gives the client room for one record, and asks for a renegotiation. The ClientHello has to follow the ciphertext that usockets still holds for the socket. On main it lands inside an application record and the server fails the MAC. The second renegotiates, then uploads into backpressure. The upload has to get its 'drain'. On main the socket gets no writable event again. Both fail on main (71d0d43997). --- .../tls/renegotiation-backpressure-fixture.js | 121 +++++++++++ test/js/node/tls/renegotiation.test.ts | 205 ++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 test/js/node/tls/renegotiation-backpressure-fixture.js diff --git a/test/js/node/tls/renegotiation-backpressure-fixture.js b/test/js/node/tls/renegotiation-backpressure-fixture.js new file mode 100644 index 000000000000..62d23321f62d --- /dev/null +++ b/test/js/node/tls/renegotiation-backpressure-fixture.js @@ -0,0 +1,121 @@ +// TLS 1.2 server for the tests that mix a renegotiation with an upload that is +// under write backpressure. Runs in Node, because a Bun server does not +// renegotiate. It reads nothing on its own. The test drives it over stdin, so +// no step depends on relative speeds. A command applies to the latest +// connection: +// +// A -> read more bytes, stop, then report READ. The test asks for +// a fraction of what the client had to write to fill the +// connection: enough to reopen the receive window, which gives +// the client room for a record, and far too little to make the +// client writable. +// R -> ask for a renegotiation. Reports RENEG_REQUEST at once and +// RENEG_DONE err= when the new handshake is over. +// D -> keep reading from now on. +// E -> report GOT_ALL once bytes have arrived. +// +// A report is " mismatchAt=". The first +// report of a connection is OPEN. The number keeps a late report of an earlier +// connection apart from the current one. The offset is the first byte that +// broke the pattern the client uploads (byte[i] === i % 251), so a splice the +// cipher happens to accept still fails. +"use strict"; +const tls = require("node:tls"); + +const say = line => process.stdout.write(line + "\n"); + +let connections = 0; +let current = null; + +const server = tls.createServer( + { + cert: process.env.SERVER_CERT, + key: process.env.SERVER_KEY, + minVersion: "TLSv1.2", + maxVersion: "TLSv1.2", + // Bounds how much one read() pulls off the socket, and with it how much + // receive window each read opens. + highWaterMark: 16 * 1024, + }, + socket => { + const id = ++connections; + const connection = { + readUntil: 0, + reportRead: false, + closed: false, + offset: 0, + mismatchAt: -1, + expectTotal: Infinity, + tell(what) { + say(`${id} ${what} mismatchAt=${connection.mismatchAt}`); + }, + renegotiate() { + socket.renegotiate({}, error => + connection.tell(`RENEG_DONE err=${error ? error.code || error.message : "null"}`), + ); + connection.tell("RENEG_REQUEST"); + }, + reportTotal() { + if (connection.offset >= connection.expectTotal) { + connection.expectTotal = Infinity; + connection.tell("GOT_ALL"); + } + }, + }; + current = connection; + socket.pause(); + connection.tell("OPEN"); + + const pull = () => { + if (connection.closed) return; + while (connection.offset < connection.readUntil) { + const chunk = socket.read(); + if (!chunk) break; + for (let i = 0; i < chunk.length; i++) { + if (chunk[i] !== (connection.offset + i) % 251) { + if (connection.mismatchAt < 0) connection.mismatchAt = connection.offset + i; + break; + } + } + connection.offset += chunk.length; + connection.reportTotal(); + } + if (connection.reportRead && connection.offset >= connection.readUntil) { + connection.reportRead = false; + connection.tell("READ"); + } + setImmediate(pull); + }; + setImmediate(pull); + + socket.on("error", error => { + connection.closed = true; + connection.tell(`ERROR code=${error.code}`); + }); + socket.on("close", () => { + connection.closed = true; + connection.tell("CLOSE"); + }); + }, +); + +process.stdin.on("data", data => { + for (const command of data.toString().split("\n")) { + if (!current) continue; + if (command.startsWith("A ")) { + current.readUntil = current.offset + Number(command.slice(2)); + current.reportRead = true; + } else if (command === "R") { + current.renegotiate(); + } else if (command === "D") { + current.readUntil = Infinity; + } else if (command.startsWith("E ")) { + current.expectTotal = Number(command.slice(2)); + current.reportTotal(); + } + } +}); +// The test process is gone: do not outlive it. +process.stdin.on("end", () => process.exit(0)); + +server.listen(0, "127.0.0.1", () => say(`READY ${server.address().port}`)); diff --git a/test/js/node/tls/renegotiation.test.ts b/test/js/node/tls/renegotiation.test.ts index 9f59cfe68696..c1e2c7c7da34 100644 --- a/test/js/node/tls/renegotiation.test.ts +++ b/test/js/node/tls/renegotiation.test.ts @@ -1510,3 +1510,208 @@ it.concurrent( }); }, ); + +async function* lines(stream: ReadableStream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffered = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) return; + buffered += decoder.decode(value, { stream: true }); + let newline: number; + while ((newline = buffered.indexOf("\n")) >= 0) { + yield buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + } + } +} + +// A node TLS 1.2 server that reads and renegotiates only when told to, shared +// by the tests below: see renegotiation-backpressure-fixture.js. +async function startBackpressureServer() { + const server = Bun.spawn(["node", join(import.meta.dir, "renegotiation-backpressure-fixture.js")], { + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + env: { ...bunEnv, SERVER_CERT: tls.cert, SERVER_KEY: tls.key }, + }); + const output = lines(server.stdout); + const ready = await output.next(); + return { + port: Number(String(ready.value).slice("READY ".length)), + kill: () => server.kill(), + // The server's side of the connection that was opened last. Its reports + // come without the connection number; reports of earlier connections are + // skipped. + async accepted() { + let prefix = ""; + const nextLine = async () => { + while (true) { + const line = await output.next(); + if (line.done) return "the server exited"; + if (!prefix && / OPEN /.test(line.value)) prefix = line.value.slice(0, line.value.indexOf(" ") + 1); + if (prefix && line.value.startsWith(prefix)) return line.value.slice(prefix.length); + } + }; + expect(await nextLine()).toBe("OPEN mismatchAt=-1"); + return { + nextLine, + say(command: string) { + server.stdin.write(command + "\n"); + server.stdin.flush(); + }, + }; + }, + }; +} +let backpressureServer: ReturnType | undefined; +afterAll(async () => { + (await backpressureServer)?.kill(); +}); + +// byte[i] === i % 251. The fixture checks every byte it reads against this, so +// it also catches a splice that the cipher accepts. +const uploadPattern = Buffer.alloc(251 * 262); +for (let i = 0; i < uploadPattern.length; i++) uploadPattern[i] = i % 251; + +// Upload until a write comes back short, let the server open room for one +// record, then take the HelloRequest. Resolves to what the server saw. +async function renegotiateAgainstAParkedUpload() { + const server = await (backpressureServer ??= startBackpressureServer()); + let parked = Promise.withResolvers(); + let sent = 0; + let drains = 0; + let writing = true; + const pump = (socket: { write(chunk: Uint8Array): number }) => { + while (writing) { + const start = sent % 251; + const chunk = uploadPattern.subarray(start, start + 64 * 1024); + const written = socket.write(chunk); + sent += Math.max(0, written); + if (written < chunk.length) { + parked.resolve(); + return; + } + } + }; + + using socket = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { rejectUnauthorized: false }, + socket: { + handshake: pump, + drain(socket) { + drains++; + pump(socket); + }, + data() {}, + }, + }); + const peer = await server.accepted(); + + // The server reads nothing, so the upload fills the connection and parks. + // usockets now holds ciphertext for this socket in its spill buffer. + await parked.promise; + // The server reads a little, which gives the client room for one more + // record. The read must not make the client writable, or the spill drains + // before the HelloRequest arrives. It does on the first try where the kernel + // still grows the send buffer, so repeat until a read leaves the upload + // parked. + for (let round = 0; round < 6; round++) { + const drainsBefore = drains; + parked = Promise.withResolvers(); + peer.say(`A ${256 * 1024}`); + expect(await peer.nextLine()).toBe("READ mismatchAt=-1"); + // Two loop turns, so a writable event that the read caused has run. + await new Promise(resolve => setImmediate(resolve)); + await new Promise(resolve => setImmediate(resolve)); + if (drains === drainsBefore) break; + await parked.promise; + } + writing = false; + peer.say("R"); + expect(await peer.nextLine()).toBe("RENEG_REQUEST mismatchAt=-1"); + // Let the server read the rest, so the renegotiation can finish. + peer.say("D"); + return await peer.nextLine(); +} + +// A HelloRequest that arrives while the upload is parked on backpressure used +// to put the renegotiation ClientHello in the middle of an application record +// that usockets still held in its spill buffer. The server then failed the MAC +// on that record. Three connections, because about one in eight does not reach +// that state on an unfixed build. +it("keeps sealed ciphertext in order when a renegotiation starts under write backpressure", async () => { + const seen: string[] = []; + for (let connection = 0; connection < 3; connection++) { + seen.push(await renegotiateAgainstAParkedUpload()); + } + expect(seen).toEqual([ + "RENEG_DONE err=null mismatchAt=-1", + "RENEG_DONE err=null mismatchAt=-1", + "RENEG_DONE err=null mismatchAt=-1", + ]); +}); + +// After a renegotiation that the server follows with no data, the socket used +// to get no writable event again: an upload that ran into backpressure, or a +// write that landed while the renegotiation was still running, waited for +// 'drain' for ever. +it("delivers drain to an upload that hits backpressure after a renegotiation", async () => { + const server = await (backpressureServer ??= startBackpressureServer()); + const secure = Promise.withResolvers(); + const parked = Promise.withResolvers(); + let sent = 0; + let total = Infinity; + let uploading = false; + const pump = (socket: { write(chunk: Uint8Array): number }) => { + while (uploading && sent < total) { + const start = sent % 251; + const chunk = uploadPattern.subarray(start, start + Math.min(64 * 1024, total - sent)); + const written = socket.write(chunk); + sent += Math.max(0, written); + if (written < chunk.length) { + if (total === Infinity) { + // The first short write. One more megabyte has to follow it, and + // that needs a 'drain'. + total = sent + 1024 * 1024; + parked.resolve(); + } + return; + } + } + }; + + using socket = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + tls: { rejectUnauthorized: false }, + socket: { + handshake(socket) { + secure.resolve(); + pump(socket); + }, + drain: pump, + data() {}, + }, + }); + const peer = await server.accepted(); + + // Wait for the first handshake report, so the HelloRequest cannot arrive in + // the same read as the server's Finished. + await secure.promise; + peer.say("R"); + expect(await peer.nextLine()).toBe("RENEG_REQUEST mismatchAt=-1"); + expect(await peer.nextLine()).toBe("RENEG_DONE err=null mismatchAt=-1"); + + // The server reads nothing, so the upload parks: on the full connection, or + // at once when the client has not yet read the end of the renegotiation. + uploading = true; + pump(socket); + await parked.promise; + peer.say(`E ${total}`); + peer.say("D"); + expect(await peer.nextLine()).toBe("GOT_ALL mismatchAt=-1"); +});