diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 1e9d83b4dda9..4431fe43c55a 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -143,6 +143,7 @@ const kSetKeepAliveInitialDelay = Symbol("kSetKeepAliveInitialDelay"); const kConnectOptions = Symbol("connect-options"); const kAttach = Symbol("kAttach"); const kCloseRawConnection = Symbol("kCloseRawConnection"); +const kOnUpgradedClose = Symbol("kOnUpgradedClose"); const kupgraded = Symbol("kupgraded"); const kAdoptedTLSRaw = Symbol("kAdoptedTLSRaw"); const ksocket = Symbol("ksocket"); @@ -256,6 +257,13 @@ function detachSocket(self) { function destroyNT(self, err) { self.destroy(err); } +// Node's wrap 'close' -> destroy(): https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 +function onUpgradedClose(self, connection) { + if (self[kupgraded] === connection) self.destroy(); +} +function destroyWhenUpgradedCloses(self, connection) { + connection.once("close", (self[kOnUpgradedClose] = onUpgradedClose.bind(null, self, connection))); +} let addAbortListener; function destroyWhenAborted(err) { if (!this.destroyed) { @@ -1582,6 +1590,7 @@ function Socket(options?) { this._parent = null; this._parentWrap = null; this[kupgraded] = null; + this[kOnUpgradedClose] = undefined; this[kSetNoDelay] = Boolean(noDelay); this[kSetKeepAlive] = Boolean(keepAlive); @@ -1888,6 +1897,8 @@ Socket.prototype[kAttach] = function (port, socket) { Socket.prototype[kCloseRawConnection] = function () { const connection = this[kupgraded]; + // Only a destroy the connection's owner started counts as it closing under this socket. + if (!connection.destroyed) connection.removeListener("close", this[kOnUpgradedClose]); connection.connecting = false; connection._handle = null; connection.unref(); @@ -2023,6 +2034,7 @@ Socket.prototype.connect = function connect(...args) { connection.on("end", events[1]); connection.on("drain", events[2]); connection.on("close", events[3]); + destroyWhenUpgradedCloses(this, connection); this._handle = result; } else { // upgradeTLS requires an established socket; a socket that is still @@ -2041,6 +2053,7 @@ Socket.prototype.connect = function connect(...args) { // replace socket connection._handle = raw; raw[kAdoptedTLSRaw] = true; + destroyWhenUpgradedCloses(this, connection); this.once("end", this[kCloseRawConnection]); raw.connecting = false; this._handle = tls; @@ -2074,6 +2087,7 @@ Socket.prototype.connect = function connect(...args) { connection.on("end", events[1]); connection.on("drain", events[2]); connection.on("close", events[3]); + destroyWhenUpgradedCloses(this, connection); this._handle = result; } else { this[kupgraded] = connection; @@ -2088,6 +2102,7 @@ Socket.prototype.connect = function connect(...args) { // replace socket connection._handle = raw; raw[kAdoptedTLSRaw] = true; + destroyWhenUpgradedCloses(this, connection); this.once("end", this[kCloseRawConnection]); raw.connecting = false; this._handle = tls; @@ -2384,6 +2399,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, connection.on("end", events[1]); connection.on("drain", events[2]); connection.on("close", events[3]); + destroyWhenUpgradedCloses(this, connection); this[kupgraded] = connection; this._handle = result; return; @@ -2413,6 +2429,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, connection.on("end", events[1]); connection.on("drain", events[2]); connection.on("close", events[3]); + destroyWhenUpgradedCloses(this, connection); this._handle = result; this.emit(kUpgradeAttached); return; @@ -2436,6 +2453,7 @@ Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, const [raw, tlsHandle] = result; connection._handle = raw; raw[kAdoptedTLSRaw] = true; + destroyWhenUpgradedCloses(this, connection); this.once("end", this[kCloseRawConnection]); raw.connecting = false; this._handle = tlsHandle; diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 5b99788ee43c..e29c3dd30bc5 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -735,7 +735,16 @@ function TLSSocket(socket?, options?) { throw $ERR_INVALID_ARG_TYPE("socket", "Duplex", socket); } - options = isNetSocketOrDuplex ? { ...options, allowHalfOpen: false } : options || socket || {}; + // The wrapped socket's allowHalfOpen wins: https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L592 + if (isNetSocketOrDuplex) { + options = { ...options, allowHalfOpen: socket.allowHalfOpen }; + } else { + options = options || socket || {}; + const wrapped = options.socket; + if (wrapped instanceof Duplex) { + options = { ...options, allowHalfOpen: wrapped.allowHalfOpen }; + } + } this._rejectUnauthorized = !!options.rejectUnauthorized; diff --git a/test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts b/test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts index d9fca88999fc..09eee9412584 100644 --- a/test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts +++ b/test/js/node/tls/node-tls-socket-allow-half-open-option.test.ts @@ -1,17 +1,412 @@ -import { describe, expect, it } from "bun:test"; -import { Duplex } from "node:stream"; -import { TLSSocket } from "tls"; +import { describe, expect, test } from "bun:test"; +import { tls as COMMON_CERT } from "harness"; +import net, { type AddressInfo } from "node:net"; +import { Duplex, PassThrough } from "node:stream"; +import tls, { TLSSocket } from "node:tls"; -describe("TLSSocket allowHalfOpen option with Duplex socket", () => { - // In both cases we should ignore allowHalfOpen option, regardless of read() {} implementation or not +// A TLSSocket built over an existing socket takes that socket's allowHalfOpen; +// the option only counts when the TLSSocket opens its own connection. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L592 +// tls.connect({ socket }) passes the socket to that same constructor argument. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1756-L1757 +// What a half-open TLSSocket outlives is the wrapped socket's EOF; the wrapped +// socket closing still destroys it. +// https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L739-L741 - it("ignores allowHalfOpen when socket is Duplex with or without read implementation", () => { - const duplexNoRead = new Duplex(); - const socketNoRead = new TLSSocket(duplexNoRead, { allowHalfOpen: true }); - expect(socketNoRead.allowHalfOpen).toBe(false); +function listen(server: net.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; +} - const duplexWithRead = new Duplex({ read() {} }); - const socketWithRead = new TLSSocket(duplexWithRead, { allowHalfOpen: true }); - expect(socketWithRead.allowHalfOpen).toBe(false); +function closed(socket: Duplex): Promise { + const { promise, resolve } = Promise.withResolvers(); + socket.once("close", () => resolve()); + return promise; +} + +// A generic Duplex carrying a socket's bytes (what tunnelling code hands to +// tls.connect({ socket })); it closes once the socket it carries has closed. +function bridge(raw: net.Socket): Duplex { + 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)); + raw.on("close", () => duplex.destroy()); + return duplex; +} + +describe("TLSSocket allowHalfOpen", () => { + test("new TLSSocket(socket) takes the wrapped socket's allowHalfOpen, ignoring the option", () => { + const halfOpenSocket = new net.Socket({ allowHalfOpen: true }); + const defaultSocket = new net.Socket(); + // stream.Duplex defaults to allowHalfOpen: true; net.Socket defaults to false. + const defaultDuplex = new Duplex({ read() {} }); + const duplexWithoutRead = new Duplex(); + const halfOpenDisabledDuplex = new Duplex({ allowHalfOpen: false, read() {} }); + try { + expect({ + halfOpenSocket: new TLSSocket(halfOpenSocket, { allowHalfOpen: false }).allowHalfOpen, + defaultSocket: new TLSSocket(defaultSocket, { allowHalfOpen: true }).allowHalfOpen, + defaultDuplex: new TLSSocket(defaultDuplex, { allowHalfOpen: false }).allowHalfOpen, + duplexWithoutRead: new TLSSocket(duplexWithoutRead, { allowHalfOpen: false }).allowHalfOpen, + halfOpenDisabledDuplex: new TLSSocket(halfOpenDisabledDuplex, { allowHalfOpen: true }).allowHalfOpen, + }).toEqual({ + halfOpenSocket: true, + defaultSocket: false, + defaultDuplex: true, + duplexWithoutRead: true, + halfOpenDisabledDuplex: false, + }); + } finally { + halfOpenSocket.destroy(); + defaultSocket.destroy(); + defaultDuplex.destroy(); + duplexWithoutRead.destroy(); + halfOpenDisabledDuplex.destroy(); + } + }); + + test("without a socket to wrap, the option is honored", () => { + expect({ + noArguments: new TLSSocket().allowHalfOpen, + option: new TLSSocket(undefined, { allowHalfOpen: true }).allowHalfOpen, + }).toEqual({ noArguments: false, option: true }); + }); + + test("tls.connect({ socket }) takes the given socket's allowHalfOpen, ignoring the option", () => { + const halfOpenSocket = new net.Socket({ allowHalfOpen: true }); + const defaultSocket = new net.Socket(); + const defaultDuplex = new PassThrough(); + const halfOpenDisabledDuplex = new Duplex({ + allowHalfOpen: false, + read() {}, + write(_chunk, _encoding, callback) { + callback(); + }, + }); + const tlsSockets: TLSSocket[] = []; + function connectOver(socket: Duplex, allowHalfOpen: boolean): boolean { + const tlsSocket = tls.connect({ socket, allowHalfOpen, rejectUnauthorized: false }); + tlsSocket.on("error", () => {}); + tlsSockets.push(tlsSocket); + return tlsSocket.allowHalfOpen; + } + try { + expect({ + halfOpenSocket: connectOver(halfOpenSocket, false), + defaultSocket: connectOver(defaultSocket, true), + defaultDuplex: connectOver(defaultDuplex, false), + halfOpenDisabledDuplex: connectOver(halfOpenDisabledDuplex, true), + }).toEqual({ + halfOpenSocket: true, + defaultSocket: false, + defaultDuplex: true, + halfOpenDisabledDuplex: false, + }); + } finally { + for (const tlsSocket of tlsSockets) tlsSocket.destroy(); + halfOpenSocket.destroy(); + defaultSocket.destroy(); + defaultDuplex.destroy(); + halfOpenDisabledDuplex.destroy(); + } + }); + + describe.concurrent("over a connection", () => { + test("a server-side wrap of a half-open socket stays writable after the peer ends", async () => { + const afterPeerEnd = Promise.withResolvers>(); + const wrapClosed = Promise.withResolvers(); + let wrapped: TLSSocket | undefined; + const rawServer = net.createServer({ allowHalfOpen: true }, raw => { + wrapped = new TLSSocket(raw, { isServer: true, ...COMMON_CERT }); + wrapped.on("error", afterPeerEnd.reject); + wrapped.once("close", () => wrapClosed.resolve()); + wrapped.resume(); + wrapped.once("end", () => { + // An allowHalfOpen: false Duplex ends its writable side from a + // nextTick queued by the 'end' emit, so look after that tick. + setImmediate(() => + afterPeerEnd.resolve({ + allowHalfOpen: wrapped!.allowHalfOpen, + writable: wrapped!.writable, + writableEnded: wrapped!.writableEnded, + destroyed: wrapped!.destroyed, + }), + ); + }); + }); + let client: TLSSocket | undefined; + try { + const port = await listen(rawServer); + client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => client!.end()); + client.on("error", afterPeerEnd.reject); + client.resume(); + const clientClosed = closed(client); + expect(await afterPeerEnd.promise).toEqual({ + allowHalfOpen: true, + writable: true, + writableEnded: false, + destroyed: false, + }); + wrapped!.end(); + await Promise.all([wrapClosed.promise, clientClosed]); + } finally { + client?.destroy(); + wrapped?.destroy(); + rawServer.close(); + } + }); + + test("a server-side wrap of a regular socket ends itself after the peer ends, even with allowHalfOpen: true", async () => { + const wrapClosed = Promise.withResolvers>(); + let wrapped: TLSSocket | undefined; + const rawServer = net.createServer(raw => { + wrapped = new TLSSocket(raw, { isServer: true, allowHalfOpen: true, ...COMMON_CERT }); + wrapped.on("error", wrapClosed.reject); + wrapped.resume(); + wrapped.once("close", () => + wrapClosed.resolve({ allowHalfOpen: wrapped!.allowHalfOpen, writableEnded: wrapped!.writableEnded }), + ); + }); + let client: TLSSocket | undefined; + try { + const port = await listen(rawServer); + client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }, () => client!.end()); + client.on("error", wrapClosed.reject); + client.resume(); + const clientClosed = closed(client); + expect(await wrapClosed.promise).toEqual({ allowHalfOpen: false, writableEnded: true }); + await clientClosed; + } finally { + client?.destroy(); + wrapped?.destroy(); + rawServer.close(); + } + }); + + test("a socket injected into a tls.Server keeps its own allowHalfOpen", async () => { + // The tls.Server's allowHalfOpen option only applies to connections it + // accepts itself; an injected socket keeps its flag in both directions. + async function injectInto(serverAllowHalfOpen: boolean, rawAllowHalfOpen: boolean): Promise { + const tlsServer = tls.createServer({ ...COMMON_CERT, allowHalfOpen: serverAllowHalfOpen }); + const secured = Promise.withResolvers(); + tlsServer.on("secureConnection", secured.resolve); + tlsServer.on("tlsClientError", secured.reject); + const rawServer = net.createServer({ allowHalfOpen: rawAllowHalfOpen }, raw => + tlsServer.emit("connection", raw), + ); + let client: TLSSocket | undefined; + let serverSide: TLSSocket | undefined; + try { + const port = await listen(rawServer); + client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + client.on("error", secured.reject); + client.resume(); + serverSide = await secured.promise; + serverSide.resume(); + const bothClosed = Promise.all([closed(serverSide), closed(client)]); + serverSide.end(); + client.end(); + await bothClosed; + return serverSide.allowHalfOpen; + } finally { + client?.destroy(); + serverSide?.destroy(); + rawServer.close(); + tlsServer.close(); + } + } + const [halfOpenSocketIntoRegularServer, regularSocketIntoHalfOpenServer] = await Promise.all([ + injectInto(false, true), + injectInto(true, false), + ]); + expect({ halfOpenSocketIntoRegularServer, regularSocketIntoHalfOpenServer }).toEqual({ + halfOpenSocketIntoRegularServer: true, + regularSocketIntoHalfOpenServer: false, + }); + }); + + test("tls.connect({ socket }) over a half-open socket stays writable after the server ends", async () => { + const serverSockets: TLSSocket[] = []; + const server = tls.createServer(COMMON_CERT, socket => { + serverSockets.push(socket); + socket.on("error", () => {}); + socket.resume(); + socket.end(); + }); + let raw: net.Socket | undefined; + let client: TLSSocket | undefined; + try { + const port = await listen(server); + const afterServerEnd = Promise.withResolvers>(); + raw = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true }); + raw.on("error", afterServerEnd.reject); + client = tls.connect({ socket: raw, allowHalfOpen: false, rejectUnauthorized: false }); + client.on("error", afterServerEnd.reject); + client.resume(); + const clientClosed = closed(client); + const allowHalfOpenAtConstruction = client.allowHalfOpen; + client.once("end", () => { + setImmediate(() => + afterServerEnd.resolve({ + allowHalfOpen: client!.allowHalfOpen, + writable: client!.writable, + writableEnded: client!.writableEnded, + destroyed: client!.destroyed, + }), + ); + }); + expect(allowHalfOpenAtConstruction).toBe(true); + expect(await afterServerEnd.promise).toEqual({ + allowHalfOpen: true, + writable: true, + writableEnded: false, + destroyed: false, + }); + client.end(); + await clientClosed; + } finally { + client?.destroy(); + raw?.destroy(); + for (const socket of serverSockets) socket.destroy(); + server.close(); + } + }); + }); + + describe.concurrent("the wrapped socket closing", () => { + // Nothing below ends or destroys the TLS socket itself, and each one is + // half-open, so only the wrapped socket's close can take it down. + type Outcome = { allowHalfOpen: boolean; destroyed: boolean }; + const destroyedHalfOpen: Outcome = { allowHalfOpen: true, destroyed: true }; + + function outcomeOnClose(socket: TLSSocket, outcome: PromiseWithResolvers): void { + socket.on("error", outcome.reject); + socket.once("close", () => outcome.resolve({ allowHalfOpen: socket.allowHalfOpen, destroyed: socket.destroyed })); + } + + test("tls.connect({ socket: duplex }): the duplex closing", async () => { + const serverSockets: TLSSocket[] = []; + const server = tls.createServer(COMMON_CERT, socket => { + serverSockets.push(socket); + socket.on("error", () => {}); + socket.resume(); + socket.end(); + }); + let raw: net.Socket | undefined; + let client: TLSSocket | undefined; + try { + const port = await listen(server); + const outcome = Promise.withResolvers(); + raw = net.connect({ port, host: "127.0.0.1" }); + raw.on("error", outcome.reject); + client = tls.connect({ socket: bridge(raw), rejectUnauthorized: false }); + outcomeOnClose(client, outcome); + client.resume(); + expect(await outcome.promise).toEqual(destroyedHalfOpen); + } finally { + client?.destroy(); + raw?.destroy(); + for (const socket of serverSockets) socket.destroy(); + server.close(); + } + }); + + test("tls.connect({ socket }): the socket being destroyed", async () => { + const serverSockets: TLSSocket[] = []; + const server = tls.createServer(COMMON_CERT, socket => { + serverSockets.push(socket); + socket.on("error", () => {}); + socket.resume(); + }); + let raw: net.Socket | undefined; + let client: TLSSocket | undefined; + try { + const port = await listen(server); + const outcome = Promise.withResolvers(); + const secured = Promise.withResolvers(); + raw = net.connect({ port, host: "127.0.0.1", allowHalfOpen: true }); + raw.on("error", outcome.reject); + client = tls.connect({ socket: raw, rejectUnauthorized: false }, secured.resolve); + outcomeOnClose(client, outcome); + client.on("error", secured.reject); + client.resume(); + await secured.promise; + raw.destroy(); + expect(await outcome.promise).toEqual(destroyedHalfOpen); + } finally { + client?.destroy(); + raw?.destroy(); + for (const socket of serverSockets) socket.destroy(); + server.close(); + } + }); + + test("new TLSSocket(socket, { isServer }): the socket being destroyed", async () => { + const outcome = Promise.withResolvers(); + const secured = Promise.withResolvers(); + let raw: net.Socket | undefined; + let wrapped: TLSSocket | undefined; + const rawServer = net.createServer({ allowHalfOpen: true }, socket => { + raw = socket; + wrapped = new TLSSocket(socket, { isServer: true, ...COMMON_CERT }); + outcomeOnClose(wrapped, outcome); + wrapped.on("error", secured.reject); + wrapped.once("secure", secured.resolve); + wrapped.resume(); + }); + let client: TLSSocket | undefined; + try { + const port = await listen(rawServer); + client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + client.on("error", () => {}); + client.resume(); + await secured.promise; + raw!.destroy(); + expect(await outcome.promise).toEqual(destroyedHalfOpen); + } finally { + client?.destroy(); + wrapped?.destroy(); + raw?.destroy(); + rawServer.close(); + } + }); + + test("new TLSSocket(duplex, { isServer }): the duplex closing", async () => { + const outcome = Promise.withResolvers(); + const secured = Promise.withResolvers(); + let wrapped: TLSSocket | undefined; + const rawServer = net.createServer(socket => { + wrapped = new TLSSocket(bridge(socket), { isServer: true, ...COMMON_CERT }); + outcomeOnClose(wrapped, outcome); + wrapped.on("error", secured.reject); + wrapped.once("secure", secured.resolve); + wrapped.resume(); + }); + let client: TLSSocket | undefined; + try { + const port = await listen(rawServer); + client = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: false }); + client.on("error", () => {}); + client.resume(); + await secured.promise; + client.destroy(); + expect(await outcome.promise).toEqual(destroyedHalfOpen); + } finally { + client?.destroy(); + wrapped?.destroy(); + rawServer.close(); + } + }); }); });