From fe41ba2e649face2c33e19a8392b3e16fd37b530 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 20:29:11 +0000 Subject: [PATCH 1/2] fix: harden egress proxy error handling Catch writeHead failures and rejected resolver promises on the HTTP and CONNECT paths so a bad upstream response or lookup fails that connection with 502 instead of leaving an unhandled exception. Co-authored-by: Jake Barnby --- src/utils/egress.ts | 245 ++++++++++++++++---------- tests/unit/egress.test.ts | 350 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 502 insertions(+), 93 deletions(-) create mode 100644 tests/unit/egress.test.ts diff --git a/src/utils/egress.ts b/src/utils/egress.ts index 486e527..d669c51 100644 --- a/src/utils/egress.ts +++ b/src/utils/egress.ts @@ -133,6 +133,41 @@ function parseAuthority( return { hostname: url.hostname, port }; } +function failResponse(res: http.ServerResponse, status = 502): void { + if (res.destroyed || res.writableEnded) { + return; + } + if (res.headersSent) { + res.destroy(); + return; + } + try { + // A rejected writeHead can leave an unusable status line on `res`. + res.statusCode = status; + res.statusMessage = "Bad Gateway"; + const socket = res.socket; + res.writeHead(status).end(() => { + socket?.end(); + }); + } catch { + res.destroy(); + } +} + +function failSocket( + socket: net.Socket, + statusLine = "HTTP/1.1 502 Bad Gateway", +): void { + if (socket.destroyed) { + return; + } + try { + socket.end(`${statusLine}\r\nContent-Length: 0\r\n\r\n`); + } catch { + socket.destroy(); + } +} + /** * A guard per capture pins each host to its first answer for the capture, so a * page cannot flip a host between public and private mid-capture, and records @@ -176,113 +211,137 @@ export async function startEgressGuard({ }; const server = http.createServer(async (req, res) => { - track(req.socket); - // Bun stalls on the second absolute-form request of a kept-alive - // connection, so make the browser open a new one for each request. - res.setHeader("Connection", "close"); - - let target: URL; try { - target = new URL(req.url ?? ""); - } catch { - res.writeHead(400).end(); - return; - } - if (target.protocol !== "http:") { - res.writeHead(400).end(); - return; - } + track(req.socket); + // Bun stalls on the second absolute-form request of a kept-alive + // connection, so make the browser open a new one for each request. + res.setHeader("Connection", "close"); + req.on("error", () => failResponse(res)); - const port = target.port === "" ? 80 : Number(target.port); - const resolution = await resolve(target.hostname); - if (resolution.status === "private") { - refuse(target.hostname, port); - res - .writeHead(403, { "Content-Type": "text/plain" }) - .end("Destination is not publicly routable."); - return; - } - if (resolution.status === "unresolved") { - res.writeHead(502).end(); - return; - } + let target: URL; + try { + target = new URL(req.url ?? ""); + } catch { + res.writeHead(400).end(); + return; + } + if (target.protocol !== "http:") { + res.writeHead(400).end(); + return; + } - const [address] = preferIpv4(resolution.addresses); - const upstream = http.request( - { - host: address.address, - family: address.family, - port, - method: req.method, - path: `${target.pathname}${target.search}`, - headers: { ...forwardHeaders(req.headers), host: target.host }, - }, - (upstreamRes) => { - res.writeHead( - upstreamRes.statusCode ?? 502, - upstreamRes.statusMessage, - forwardHeaders(upstreamRes.headers), - ); - upstreamRes.pipe(res); - }, - ); - upstream.setTimeout(IDLE_TIMEOUT_MS, () => upstream.destroy()); - res.once("close", () => upstream.destroy()); - upstream.on("error", () => { - if (res.headersSent) { - res.destroy(); - } else { + const port = target.port === "" ? 80 : Number(target.port); + const resolution = await resolve(target.hostname); + if (resolution.status === "private") { + refuse(target.hostname, port); + res + .writeHead(403, { "Content-Type": "text/plain" }) + .end("Destination is not publicly routable."); + return; + } + if (resolution.status === "unresolved") { res.writeHead(502).end(); + return; } - }); - req.pipe(upstream); + + const [address] = preferIpv4(resolution.addresses); + const upstream = http.request( + { + host: address.address, + family: address.family, + port, + method: req.method, + path: `${target.pathname}${target.search}`, + headers: { ...forwardHeaders(req.headers), host: target.host }, + }, + (upstreamRes) => { + const abort = () => { + failResponse(res); + upstreamRes.destroy(); + }; + upstreamRes.on("error", abort); + try { + res.writeHead( + upstreamRes.statusCode ?? 502, + upstreamRes.statusMessage, + forwardHeaders(upstreamRes.headers), + ); + upstreamRes.pipe(res); + } catch { + abort(); + } + }, + ); + upstream.setTimeout(IDLE_TIMEOUT_MS, () => upstream.destroy()); + res.once("close", () => upstream.destroy()); + upstream.on("error", () => failResponse(res)); + req.pipe(upstream); + } catch { + failResponse(res); + } }); // HTTPS, and WebSockets of either scheme, arrive as CONNECT tunnels. server.on("connect", async (req, socket: net.Socket, head: Buffer) => { - track(socket); + let upstream: net.Socket | undefined; + let established = false; + try { + track(socket); + req.on("error", () => socket.destroy()); - const target = parseAuthority(req.url ?? ""); - if (!target) { - socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n"); - return; - } + const target = parseAuthority(req.url ?? ""); + if (!target) { + failSocket(socket, "HTTP/1.1 400 Bad Request"); + return; + } - const resolution = await resolve(target.hostname); - if (resolution.status === "private") { - refuse(target.hostname, target.port); - socket.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"); - return; - } - if (resolution.status === "unresolved") { - socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"); - return; - } + const resolution = await resolve(target.hostname); + if (resolution.status === "private") { + refuse(target.hostname, target.port); + failSocket(socket, "HTTP/1.1 403 Forbidden"); + return; + } + if (resolution.status === "unresolved") { + failSocket(socket); + return; + } - let upstream: net.Socket; - try { - upstream = await connectToAny( - preferIpv4(resolution.addresses), - target.port, - ); - } catch { - socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"); - return; - } - if (socket.destroyed) { - upstream.destroy(); - return; - } - track(upstream); - upstream.once("close", () => socket.destroy()); - socket.once("close", () => upstream.destroy()); + try { + upstream = await connectToAny( + preferIpv4(resolution.addresses), + target.port, + ); + } catch { + failSocket(socket); + return; + } + if (!upstream) { + failSocket(socket); + return; + } + if (socket.destroyed) { + upstream.destroy(); + return; + } + track(upstream); + upstream.once("close", () => socket.destroy()); + socket.once("close", () => upstream?.destroy()); - socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); - if (head.length > 0) { - upstream.write(head); + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + established = true; + if (head.length > 0) { + upstream.write(head); + } + upstream.pipe(socket); + socket.pipe(upstream); + } catch { + upstream?.destroy(); + if (established) { + socket.destroy(); + } else { + failSocket(socket); + } } - upstream.pipe(socket); - socket.pipe(upstream); }); await new Promise((resolve, reject) => { diff --git a/tests/unit/egress.test.ts b/tests/unit/egress.test.ts new file mode 100644 index 0000000..78aba4a --- /dev/null +++ b/tests/unit/egress.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, test } from "bun:test"; +import net from "node:net"; +import { startEgressGuard } from "../../src/utils/egress.js"; +import type { HostResolution } from "../../src/utils/ssrf.js"; + +const loopbackPublic: HostResolution = { + status: "public", + addresses: [{ address: "127.0.0.1", family: 4 }], +}; + +async function withProcessFaults( + fn: () => Promise, +): Promise<{ value?: T; error?: unknown; faults: unknown[] }> { + const faults: unknown[] = []; + const onException = (error: unknown) => { + faults.push(error); + }; + const onRejection = (error: unknown) => { + faults.push(error); + }; + process.on("uncaughtException", onException); + process.on("unhandledRejection", onRejection); + try { + const value = await fn(); + // Late stream errors surface on the next ticks. + await Bun.sleep(50); + return { value, faults }; + } catch (error) { + await Bun.sleep(50); + return { error, faults }; + } finally { + process.off("uncaughtException", onException); + process.off("unhandledRejection", onRejection); + } +} + +async function serveRaw( + write: (socket: net.Socket) => void, +): Promise<{ port: number; close: () => void }> { + const server = net.createServer((socket) => { + socket.on("error", () => {}); + socket.once("data", () => write(socket)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + port: (server.address() as net.AddressInfo).port, + close: () => { + server.close(); + }, + }; +} + +function parseHttpResponse(raw: string): { + status: number; + message: string; + body: string; +} | null { + const split = raw.indexOf("\r\n\r\n"); + if (split === -1) { + return null; + } + const head = raw.slice(0, split); + const body = raw.slice(split + 4); + const length = /content-length:\s*(\d+)/i.exec(head); + if (length && body.length < Number(length[1])) { + return null; + } + const statusLine = head.split("\r\n")[0] ?? ""; + const match = /^HTTP\/\d(?:\.\d)?\s+(\d{3})\s*(.*)$/.exec(statusLine); + if (!match) { + return null; + } + return { + status: Number(match[1]), + message: match[2] ?? "", + body, + }; +} + +async function requestThrough( + guardUrl: string, + upstreamPort: number, +): Promise<{ status: number; message: string; body: string }> { + const proxy = new URL(guardUrl); + return await new Promise((resolve, reject) => { + const socket = net.connect( + { host: proxy.hostname, port: Number(proxy.port) }, + () => { + socket.write( + `GET http://upstream.test:${upstreamPort}/ HTTP/1.1\r\nHost: upstream.test:${upstreamPort}\r\nConnection: close\r\n\r\n`, + ); + }, + ); + const chunks: Buffer[] = []; + let settled = false; + const finish = (value: { + status: number; + message: string; + body: string; + }) => { + if (settled) return; + settled = true; + socket.end(); + resolve(value); + }; + socket.on("data", (chunk) => { + chunks.push(chunk); + const parsed = parseHttpResponse( + Buffer.concat(chunks).toString("latin1"), + ); + if (parsed) finish(parsed); + }); + socket.on("error", reject); + socket.setTimeout(3_000, () => { + socket.destroy(); + reject(new Error("client timeout")); + }); + }); +} + +async function connectThrough( + guardUrl: string, + authority: string, +): Promise<{ statusLine: string; rest: string }> { + const proxy = new URL(guardUrl); + return await new Promise((resolve, reject) => { + const socket = net.connect( + { host: proxy.hostname, port: Number(proxy.port) }, + () => { + socket.write( + `CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`, + ); + }, + ); + let buf = ""; + socket.on("data", (chunk) => { + buf += chunk.toString("latin1"); + const split = buf.indexOf("\r\n\r\n"); + if (split !== -1) { + socket.end(); + resolve({ + statusLine: buf.slice(0, buf.indexOf("\r\n")), + rest: buf.slice(split + 4), + }); + } + }); + socket.on("error", reject); + socket.on("end", () => { + if (buf.length === 0) { + reject(new Error("CONNECT closed without a response")); + return; + } + resolve({ + statusLine: buf.split("\r\n")[0] ?? "", + rest: "", + }); + }); + socket.setTimeout(3_000, () => { + socket.destroy(); + reject(new Error("CONNECT timeout")); + }); + }); +} + +describe("egress guard: HTTP forwarding errors", () => { + test("forwards a normal upstream response", async () => { + const upstream = await serveRaw((socket) => { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi"); + }); + const guard = await startEgressGuard({ + resolver: async () => loopbackPublic, + }); + try { + const { faults, value, error } = await withProcessFaults(() => + requestThrough(guard.server, upstream.port), + ); + expect(error).toBeUndefined(); + expect(faults).toEqual([]); + expect(value?.status).toBe(200); + expect(value?.body).toBe("hi"); + } finally { + guard.close(); + upstream.close(); + } + }); + + test("returns 502 when the upstream status message is rejected by the writer", async () => { + const upstream = await serveRaw((socket) => { + socket.end("HTTP/1.1 200 OK 🎉\r\nContent-Length: 2\r\n\r\nhi"); + }); + const guard = await startEgressGuard({ + resolver: async () => loopbackPublic, + }); + try { + const { faults, value, error } = await withProcessFaults(() => + requestThrough(guard.server, upstream.port), + ); + expect(error).toBeUndefined(); + expect(faults).toEqual([]); + expect(value?.status).toBe(502); + } finally { + guard.close(); + upstream.close(); + } + }); + + test("returns 502 when the upstream status code is outside 100-999", async () => { + const upstream = await serveRaw((socket) => { + socket.end("HTTP/1.1 099 Almost\r\nContent-Length: 0\r\n\r\n"); + }); + const guard = await startEgressGuard({ + resolver: async () => loopbackPublic, + }); + try { + const { faults, value, error } = await withProcessFaults(() => + requestThrough(guard.server, upstream.port), + ); + expect(error).toBeUndefined(); + expect(faults).toEqual([]); + expect(value?.status).toBe(502); + } finally { + guard.close(); + upstream.close(); + } + }); + + test("returns 502 when the host resolver rejects", async () => { + const upstream = await serveRaw((socket) => { + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + }); + const guard = await startEgressGuard({ + resolver: async () => { + throw new Error("lookup failed"); + }, + }); + try { + const { faults, value, error } = await withProcessFaults(() => + requestThrough(guard.server, upstream.port), + ); + expect(error).toBeUndefined(); + expect(faults).toEqual([]); + expect(value?.status).toBe(502); + } finally { + guard.close(); + upstream.close(); + } + }); + + test("keeps serving after an upstream response the writer rejects", async () => { + const replies = [ + "HTTP/1.1 200 OK 🎉\r\nContent-Length: 0\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok", + ]; + const upstream = await serveRaw((socket) => { + socket.end(replies.shift() ?? ""); + }); + const guard = await startEgressGuard({ + resolver: async () => loopbackPublic, + }); + try { + const first = await withProcessFaults(() => + requestThrough(guard.server, upstream.port), + ); + const second = await withProcessFaults(() => + requestThrough(guard.server, upstream.port), + ); + expect(first.error).toBeUndefined(); + expect(first.faults).toEqual([]); + expect(first.value?.status).toBe(502); + expect(second.error).toBeUndefined(); + expect(second.faults).toEqual([]); + expect(second.value?.status).toBe(200); + expect(second.value?.body).toBe("ok"); + } finally { + guard.close(); + upstream.close(); + } + }); +}); + +describe("egress guard: CONNECT errors", () => { + test("establishes a tunnel to a reachable host", async () => { + const target = net.createServer((socket) => { + socket.on("error", () => {}); + socket.write("pong"); + socket.end(); + }); + await new Promise((resolve) => + target.listen(0, "127.0.0.1", resolve), + ); + const port = (target.address() as net.AddressInfo).port; + const guard = await startEgressGuard({ + resolver: async () => loopbackPublic, + }); + try { + const { faults, value, error } = await withProcessFaults(() => + connectThrough(guard.server, `upstream.test:${port}`), + ); + expect(error).toBeUndefined(); + expect(faults).toEqual([]); + expect(value?.statusLine).toBe("HTTP/1.1 200 Connection Established"); + expect(value?.rest).toBe("pong"); + } finally { + guard.close(); + target.close(); + } + }); + + test("returns 502 when the host resolver rejects", async () => { + const guard = await startEgressGuard({ + resolver: async () => { + throw new Error("lookup failed"); + }, + }); + try { + const { faults, value, error } = await withProcessFaults(() => + connectThrough(guard.server, "upstream.test:443"), + ); + expect(error).toBeUndefined(); + expect(faults).toEqual([]); + expect(value?.statusLine).toBe("HTTP/1.1 502 Bad Gateway"); + } finally { + guard.close(); + } + }); + + test("returns 502 when the destination refuses the connection", async () => { + const closed = net.createServer(); + await new Promise((resolve) => + closed.listen(0, "127.0.0.1", resolve), + ); + const port = (closed.address() as net.AddressInfo).port; + await new Promise((resolve, reject) => + closed.close((error) => (error ? reject(error) : resolve())), + ); + const guard = await startEgressGuard({ + resolver: async () => loopbackPublic, + }); + try { + const { faults, value, error } = await withProcessFaults(() => + connectThrough(guard.server, `upstream.test:${port}`), + ); + expect(error).toBeUndefined(); + expect(faults).toEqual([]); + expect(value?.statusLine).toBe("HTTP/1.1 502 Bad Gateway"); + } finally { + guard.close(); + } + }); +}); From 267c7fb2c912a546dd6bd955d9df44bd369d19f4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 23:12:06 +0000 Subject: [PATCH 2/2] test: use a status message both Bun versions reject CI installs Bun latest, which accepts a UTF-8 reason phrase the Dockerfile version rejects. Drive the writer-reject cases with a control byte the client parser keeps and writeHead still refuses. Co-authored-by: Jake Barnby --- tests/unit/egress.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/unit/egress.test.ts b/tests/unit/egress.test.ts index 78aba4a..3091fbb 100644 --- a/tests/unit/egress.test.ts +++ b/tests/unit/egress.test.ts @@ -186,7 +186,14 @@ describe("egress guard: HTTP forwarding errors", () => { test("returns 502 when the upstream status message is rejected by the writer", async () => { const upstream = await serveRaw((socket) => { - socket.end("HTTP/1.1 200 OK 🎉\r\nContent-Length: 2\r\n\r\nhi"); + // A control byte is kept by the client parser and then rejected by + // writeHead on both the Dockerfile Bun and the CI "latest" Bun. + socket.end( + Buffer.from( + "HTTP/1.1 200 OK\x01\r\nContent-Length: 2\r\n\r\nhi", + "latin1", + ), + ); }); const guard = await startEgressGuard({ resolver: async () => loopbackPublic, @@ -248,7 +255,7 @@ describe("egress guard: HTTP forwarding errors", () => { test("keeps serving after an upstream response the writer rejects", async () => { const replies = [ - "HTTP/1.1 200 OK 🎉\r\nContent-Length: 0\r\n\r\n", + Buffer.from("HTTP/1.1 200 OK\x01\r\nContent-Length: 0\r\n\r\n", "latin1"), "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok", ]; const upstream = await serveRaw((socket) => {