Repository navigation
fix(node:https): wrap injected raw connections with TLS #42594
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { test } from "bun:test"; | ||
| import { tls } from "harness"; | ||
| import https from "node:https"; | ||
|
|
||
| test.each([-1, 0, 1])("node:https accepts handshakeTimeout %d", handshakeTimeout => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move this test into the existing HTTPS server suite.
As per coding guidelines: “Default: add your test to the existing test file for the code you're changing. Do not create a new file.”
|
||
| const server = https.createServer({ ...tls, handshakeTimeout }); | ||
| server.close(); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5428,6 +5428,142 @@ it("connectionListener hands off Upgrade and CONNECT like Node", async () => { | |
| } | ||
| }); | ||
|
|
||
| it.each([ | ||
| ["default", {}, "http/1.1"], | ||
| ["ALPNProtocols", { ALPNProtocols: ["http/1.1"] }, "http/1.1"], | ||
| ["ALPNCallback", { ALPNCallback: () => "http/1.1" }, "http/1.1"], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Assert that The default case and the callback case both expect 🤖 Prompt for AI Agents |
||
| ])("https wraps a raw socket injected through the connection event (%s)", async (_name, options, protocol) => { | ||
| const server = createHttpsServer({ ...tlsCert, ...options }, (req, res) => { | ||
| expect((req.socket as any).encrypted).toBe(true); | ||
| expect((req.socket as any).alpnProtocol).toBe(protocol); | ||
| res.writeHead(200, { Connection: "close" }); | ||
| res.end("injected-ok"); | ||
| }); | ||
| const rawClosed = Promise.withResolvers<void>(); | ||
| const front = createNetServer(socket => { | ||
| socket.once("close", () => rawClosed.resolve()); | ||
| server.emit("connection", socket); | ||
| }); | ||
|
|
||
| try { | ||
| await once(front.listen(0, "127.0.0.1"), "listening"); | ||
| const response = await new Promise<{ statusCode: number | undefined; body: string }>((resolve, reject) => { | ||
| const request = https.get( | ||
| { | ||
| host: "127.0.0.1", | ||
| port: (front.address() as AddressInfo).port, | ||
| rejectUnauthorized: false, | ||
| agent: false, | ||
| ALPNProtocols: ["http/1.1"], | ||
| }, | ||
| response => { | ||
| const chunks: Buffer[] = []; | ||
| response.on("data", chunk => chunks.push(chunk)); | ||
| response.on("end", () => | ||
| resolve({ statusCode: response.statusCode, body: Buffer.concat(chunks).toString("utf8") }), | ||
| ); | ||
| }, | ||
| ); | ||
| request.on("error", reject); | ||
| }); | ||
|
|
||
| expect(response).toEqual({ statusCode: 200, body: "injected-ok" }); | ||
| await rawClosed.promise; | ||
| } finally { | ||
| front.close(); | ||
| } | ||
| }); | ||
|
|
||
| it("https wraps a handed-off native CONNECT tunnel", async () => { | ||
| const server = createHttpsServer(tlsCert, (req, res) => { | ||
| res.writeHead(200, { Connection: "close" }); | ||
| res.end(req.socket.encrypted ? "tunnel-ok" : "plaintext"); | ||
| }); | ||
| const frontClosed = Promise.withResolvers<void>(); | ||
| const front = createServer(); | ||
| front.on("connect", (_req, socket) => { | ||
| socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); | ||
| server.emit("connection", socket); | ||
| front.close(err => (err ? frontClosed.reject(err) : frontClosed.resolve())); | ||
| }); | ||
| let client; | ||
| try { | ||
| await once(front.listen(0, "127.0.0.1"), "listening"); | ||
| const result = await new Promise<string>((resolve, reject) => { | ||
| const tunnel = http.request({ | ||
| host: "127.0.0.1", | ||
| port: (front.address() as AddressInfo).port, | ||
| method: "CONNECT", | ||
| path: "localhost:443", | ||
| }); | ||
| tunnel.on("error", reject); | ||
| tunnel.on("connect", (_response, socket) => { | ||
| client = tlsConnect({ socket, rejectUnauthorized: false }, () => { | ||
| client.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); | ||
| }); | ||
| const chunks: Buffer[] = []; | ||
| client.on("error", reject); | ||
| client.on("data", chunk => chunks.push(chunk)); | ||
| client.on("end", () => resolve(Buffer.concat(chunks).toString())); | ||
| }); | ||
| tunnel.end(); | ||
| }); | ||
| expect(result).toContain("HTTP/1.1 200 OK"); | ||
| expect(result).toContain("tunnel-ok"); | ||
| await frontClosed.promise; | ||
| } finally { | ||
| client?.destroy(); | ||
| front.closeAllConnections(); | ||
| if (front.listening) front.close(); | ||
| } | ||
| }); | ||
|
|
||
| async function withStalledInjectedHttpsConnection<T>(observe: (server: https.Server) => Promise<T>) { | ||
| const server = createHttpsServer({ ...tlsCert, handshakeTimeout: 50 }); | ||
| const rawClosed = Promise.withResolvers<void>(); | ||
| const front = createNetServer(socket => { | ||
| socket.once("close", () => rawClosed.resolve()); | ||
| server.emit("connection", socket); | ||
| }); | ||
| let client; | ||
|
|
||
| try { | ||
| const observed = observe(server); | ||
| await once(front.listen(0, "127.0.0.1"), "listening"); | ||
| client = connect((front.address() as AddressInfo).port, "127.0.0.1"); | ||
| client.on("error", () => {}); | ||
| const result = await observed; | ||
| await rawClosed.promise; | ||
| return result; | ||
| } finally { | ||
| client?.destroy(); | ||
| front.close(); | ||
| } | ||
| } | ||
|
|
||
| it("https routes an injected socket handshake timeout through clientError", async () => { | ||
| const result = await withStalledInjectedHttpsConnection(server => { | ||
| const clientError = Promise.withResolvers<{ code: string | undefined; destroyed: boolean }>(); | ||
| server.on("clientError", (err: Error & { code?: string }, socket) => { | ||
| clientError.resolve({ code: err.code, destroyed: socket.destroyed }); | ||
| socket.destroy(); | ||
| }); | ||
| return clientError.promise; | ||
| }); | ||
| expect(result).toEqual({ code: "ERR_TLS_HANDSHAKE_TIMEOUT", destroyed: false }); | ||
| }); | ||
|
|
||
| it("https destroys an injected socket after an unhandled handshake timeout", async () => { | ||
| const result = await withStalledInjectedHttpsConnection(server => { | ||
| const tlsClientError = Promise.withResolvers<{ code: string | undefined; destroyed: boolean }>(); | ||
| server.on("tlsClientError", (err: Error & { code?: string }, socket) => { | ||
| tlsClientError.resolve({ code: err.code, destroyed: socket.destroyed }); | ||
| }); | ||
| return tlsClientError.promise; | ||
| }); | ||
| expect(result).toEqual({ code: "ERR_TLS_HANDSHAKE_TIMEOUT", destroyed: true }); | ||
| }); | ||
|
|
||
| // A TLS client that is mid-handshake when an https server with a 'clientError' listener is closed still | ||
| // belongs to that server: once its handshake completes, a malformed request from it reaches 'clientError' | ||
| // (as in Node). The connection used to go uncounted until the handshake finished, so close() considered the | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 10314
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 20464
🏁 Script executed:
Repository: oven-sh/bun
Length of output: 30450
Pass the configured callback to the wrapped TLSSocket.
The HTTPS server stores the callback in
_ALPNCallback, but this line passesthis.ALPNCallback. The TLSSocket constructor usesoptions.ALPNCallbackto set its own_ALPNCallback. When that field is unset,serverHandlersFor(this)omits ALPN dispatch, so custom negotiation can be skipped for wrapped connections.🐛 Suggested fix
📝 Committable suggestion
🧰 Tools
🪛 GitHub Actions: Lint / 0_Lint JavaScript.txt
[error] 337-339: TypeScript type-check failed during
bun run build:types(bun x tsc --noEmit -p src/js/tsconfig.json): propertiesALPNProtocols,ALPNCallback, and_SNICallbackdo not exist on typeServer(TS2339).🪛 GitHub Actions: Lint / Lint JavaScript
[error] 337-339: TypeScript type-check failed during
bun run build:types(bun x tsc --noEmit -p src/js/tsconfig.json): propertiesALPNProtocols,ALPNCallback, and_SNICallbackdo not exist on typeServer.🤖 Prompt for AI Agents