From daebdb13def226338d6881a703f91802e620eab3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:38:25 +0000 Subject: [PATCH] tls: report a first handshake that the peer ends with close_notify (Duplex, named pipe, proxy tunnel) SSLWrapper::update_handshake_state took the SSL_ERROR_ZERO_RETURN branch for a first handshake and ran no callback. The owner got no handshake report and no close, so the connection stayed open for as long as the peer held the stream: tls.connect({ socket: duplex }) emitted nothing, and fetch and WebSocket through a CONNECT proxy waited for their timers. The branch now reports the handshake as failed, with the ECONNRESET report that openssl.c uses for a peer that leaves mid-handshake, and then closes. node:tls over a Duplex emits 'end', 'error' ECONNRESET and 'close', as Node does. The http2 upgrade of an injected socket maps the report to "socket hang up", like tls.Server. --- src/http/lib.rs | 2 +- src/js/node/_http2_upgrade.ts | 7 +- src/uws/lib.rs | 17 +- src/uws_sys/lib.rs | 13 ++ test/js/bun/http/proxy.test.ts | 39 +++++ .../js/node/http2/node-http2-upgrade.test.mts | 35 +++++ test/js/node/tls/node-tls-connect.test.ts | 75 +++++++++ .../tls/node-tls-duplex-end-verify.test.ts | 147 ++++++++++++++++++ test/js/node/tls/node-tls-namedpipes.test.ts | 58 +++++++ test/js/web/websocket/websocket-proxy.test.ts | 24 ++- 10 files changed, 413 insertions(+), 4 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index 265ba9db804e..be2e4e1f3882 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/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index 98739ae42758..8134c59f8817 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -202,7 +202,12 @@ function socketHandshake( const ctx = tlsSocket._ctx; if (!success) { - const err = verifyError || new Error("TLS handshake failed"); + let err: NodeJS.ErrnoException = verifyError || new Error("TLS handshake failed"); + // The peer left mid-handshake. Same wording as tlsHandshakeError in net.ts. + if (err.code === "ECONNRESET") { + const { ConnResetException } = require("internal/shared"); + err = new ConnResetException("socket hang up"); + } ctx.server.emit("tlsClientError", err, tlsSocket); tlsSocket.destroy(err); return; diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 31ee23f5b94f..310be8327b9a 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -395,6 +395,8 @@ pub mod ssl_wrapper { HandshakeError, /// Closed before the handshake finished, or a renegotiation was refused. Aborted, + /// The peer's close_notify ended the first handshake. + PeerClosed, } #[derive(Clone, Copy)] @@ -944,6 +946,8 @@ pub mod ssl_wrapper { (false, us_bun_verify_error_t::default()) } HandshakeOutcome::Aborted => (false, self.verify_error()), + // Not the X509 verdict and not an empty error: node:tls reads both as an established session. + HandshakeOutcome::PeerClosed => (false, us_bun_verify_error_t::peer_disconnected()), }; self.flags.set_authorized(success); // trigger the handshake callback @@ -1064,7 +1068,13 @@ pub mod ssl_wrapper { self.flags.set_received_ssl_shutdown(true); // 2-step shutdown let _ = self.shutdown(false); - self.handle_end_of_renegotiation(); + // No session will come: report the handshake that never finished, then close. + 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 @@ -1344,6 +1354,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 4deb071c8632..4102f37732ea 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 8f5ce175dd25..4873d5ca9f47 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -3262,6 +3262,45 @@ 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(); + } + } + expect(outcomes).toEqual([{ code: "EPROTO" }, { code: "EPROTO" }]); +}); + 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 57a8b73d93bc..5a62d7ef6f63 100644 --- a/test/js/node/http2/node-http2-upgrade.test.mts +++ b/test/js/node/http2/node-http2-upgrade.test.mts @@ -461,6 +461,41 @@ describe("HTTP/2 upgrade — server TLS options", () => { }); }); +describe("HTTP/2 upgrade — failed TLS handshake", () => { + test("a peer that ends the handshake with close_notify is reported as tlsClientError", async () => { + const h2Server = http2.createSecureServer(TLS); + h2Server.on("error", () => {}); + const netServer = net.createServer(socket => { + socket.on("error", () => {}); + h2Server.emit("connection", socket); + }); + const port = await new Promise(resolve => { + netServer.listen(0, "127.0.0.1", () => resolve((netServer.address() as net.AddressInfo).port)); + }); + // The alert is the first record, and the peer keeps the connection open behind it. + const peer = net.connect(port, "127.0.0.1", () => { + peer.write(Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00])); + }); + peer.on("error", () => {}); + try { + const [err] = await once(h2Server, "tlsClientError"); + if (typeof Bun !== "undefined") { + // BoringSSL reads the alert as the peer's close, at every point of the handshake. + assert.deepStrictEqual( + { code: err.code, message: err.message }, + { code: "ECONNRESET", message: "socket hang up" }, + ); + } else { + // OpenSSL refuses an alert ahead of the ClientHello. + assert.strictEqual(err.code, "ERR_SSL_UNEXPECTED_MESSAGE"); + } + } finally { + peer.destroy(); + 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-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 14c4c37e0720..7100b8b4098f 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -2118,6 +2118,81 @@ describe("a TLS socket over a Duplex transport reports that transport's error", }); }); +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, so the wrap reports what it reports for a peer + // that disconnects. Node's OpenSSL refuses an alert ahead of the ClientHello (ERR_SSL_UNEXPECTED_MESSAGE) and + // reads one behind it as a plain end of the stream. The client side of this case runs on both runtimes in + // node-tls-duplex-end-verify.test.ts. + const closeNotify = Buffer.from([0x15, 0x03, 0x03, 0x00, 0x02, 0x01, 0x00]); + const hangUp = { code: "ECONNRESET", message: "socket hang up" }; + 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 firstError = async (socket: TLSSocket) => { + const [err] = await once(socket, "error"); + return { code: err.code, message: err.message }; + }; + + it("the alert is the first record", async () => { + const transport = makeTransport(); + const wrapped = new TLSSocket(transport, serverContext()); + const failed = firstError(wrapped); + setImmediate(() => transport.push(closeNotify)); + expect(await failed).toEqual(hangUp); + 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 wrap answers the ClientHello with its first flight. The alert is the answer to that flight. In TLS 1.2 + // the client's next records are still plaintext, so the plaintext alert is one that the wrap can read. + 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 failed = firstError(wrapped); + setImmediate(() => transport.push(clientHello)); + expect(await failed).toEqual(hangUp); + expect({ answered, destroyed: wrapped.destroyed }).toEqual({ answered: true, destroyed: true }); + 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, message: err.message, destroyed: socket.destroyed }).toEqual({ + ...hangUp, + 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 81dd7b370813..e71a18147bc1 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 @@ -981,3 +981,150 @@ test("a bad record behind the client's Finished does not make a server accept an // Node reports the bad record. Its code depends on the cipher, so only the class of the error is fixed. assert.match(events[0], isBun ? /^tlsClientError DEPTH_ZERO_SELF_SIGNED_CERT$/ : /^tlsClientError ERR_SSL_/); }); + +// 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 and then the end of the transport report one failure", async () => { + const transport = transportWithPeer( + answerFirstFlight(transport => { + transport.push(CLOSE_NOTIFY); + transport.push(null); + }), + ); + 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(); + } +}); diff --git a/test/js/node/tls/node-tls-namedpipes.test.ts b/test/js/node/tls/node-tls-namedpipes.test.ts index 674c39ddac01..36f8ac2de272 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/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 415272b4c64b..2a1667d1a0e3 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -4,7 +4,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, @@ -289,6 +289,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