diff --git a/src/http/lib.rs b/src/http/lib.rs index bd6175e8a003..96ad8d56ad20 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -2712,6 +2712,45 @@ impl<'a> HTTPClient<'a> { buffer: &mut bun_io::StreamBuffer, data: &[u8], ) -> Result { + // Through a proxy tunnel the stream body goes via the inner TLS, + // not the outer socket. + if let Some(proxy_ptr) = self.proxy_tunnel.as_ref().map(|p| p.as_ptr()) { + if socket.is_closed() || socket.is_shutdown() { + return Err(err!(ConnectionClosed)); + } + let proxy = proxy_tunnel::raw_as_mut(proxy_ptr); + // Any Err is backpressure: WantRead/WantWrite retry on the next + // on_writable, and a fatal SSL error already ran on_close (and + // may have freed *self), so bail via Ok(true) without touching + // self — same as the other ProxyTunnel::write callers. + let pending = buffer.slice().len(); + if pending > 0 { + let Ok(n) = ProxyTunnel::write(proxy, buffer.slice()) else { + let _ = buffer.write(data); + return Ok(true); + }; + self.state.request_sent_len += n; + buffer.cursor += n; + if n < pending { + let _ = buffer.write(data); + return Ok(true); + } + buffer.reset(); + } + if !data.is_empty() { + let Ok(n) = ProxyTunnel::write(proxy, data) else { + let _ = buffer.write(data); + return Ok(true); + }; + self.state.request_sent_len += n; + if n < data.len() { + let _ = buffer.write(&data[n..]); + return Ok(true); + } + } + return Ok(false); + } + let to_send_len = buffer.slice().len(); if to_send_len > 0 { let amount = write_to_socket::(socket, buffer.slice())?; @@ -2848,6 +2887,17 @@ impl<'a> HTTPClient<'a> { if let Some(proxy) = self.proxy_tunnel_mut() { proxy.on_writable::(socket); + // ProxyTunnel::on_writable → SSLWrapper::flush → handle_traffic + // may process a TLS alert or close_notify that was buffered + // alongside the handshake flight, firing on_close → + // close_and_fail, which terminates the outer socket and frees + // the AsyncHTTP that embeds `*self` via the result callback + // (same hazard as documented in `start_proxy_handshake`). The + // socket handle outlives the client; use it as the liveness + // guard before touching `self` again. + if socket.is_closed() { + return; + } } // Parked until the JS `checkServerIdentity` callback approves the peer @@ -3071,7 +3121,15 @@ impl<'a> HTTPClient<'a> { ); } - let has_sent_body = self.request_body().is_empty(); + // Match send_initial_request_payload: a Stream/Sendfile + // body has an empty `request_body()` buffer at this + // point, which does not mean the body is sent. + let has_sent_body = + if matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) { + self.request_body().is_empty() + } else { + false + }; if has_sent_headers && has_sent_body { self.state.request_stage = RequestStage::Done; @@ -3085,7 +3143,17 @@ impl<'a> HTTPClient<'a> { let ctx = self.get_ssl_ctx::(); self.progress_update::(ctx, socket); } - debug_assert!(!self.request_body().is_empty()); + debug_assert!( + // leftover bytes OR stream/sendfile (whose body + // buffer is empty here; the body flows via + // flush_stream in the ProxyBody arm) + (matches!(self.state.original_request_body, HTTPRequestBody::Bytes(_)) + && !self.request_body().is_empty()) + || matches!( + self.state.original_request_body, + HTTPRequestBody::Sendfile(_) | HTTPRequestBody::Stream(_) + ) + ); // we sent everything, but there's some body leftover if amount == to_send.len() { diff --git a/test/js/bun/http/proxy-stress-adversarial.test.ts b/test/js/bun/http/proxy-stress-adversarial.test.ts new file mode 100644 index 000000000000..ddbc9ac8a2dc --- /dev/null +++ b/test/js/bun/http/proxy-stress-adversarial.test.ts @@ -0,0 +1,589 @@ +/** + * Adversarial combinations: each test pairs several "hostile" conditions at + * once (split CONNECT × compression × streaming body × keepalive, etc.), + * plus targeted exercises of the code paths the matrix/lifecycle files + * can't reach individually (checkServerIdentity re-entry, Host header + * shape, origin-facing header content, response status matrix). + * + * Also covers the WebSocket proxy tunnel (`WebSocketProxyTunnel`), which is + * a separate SSLWrapper consumer from the fetch `ProxyTunnel` and had zero + * stress coverage for the wss-through-https-proxy (double-TLS) case. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { isASAN } from "harness"; +import { once } from "node:events"; +import net from "node:net"; +import tls from "node:tls"; +import { + cartesian, + clearProxyEnv, + createAdversarialOrigin, + createAdversarialProxy, + errcode, + laxTls, + makeBody, + restoreProxyEnv, + tlsCert, +} from "./proxy-stress-helpers"; + +let savedEnv: Record; +beforeAll(() => { + savedEnv = clearProxyEnv(); +}); +afterAll(() => { + restoreProxyEnv(savedEnv); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Stacked adversarial conditions: split CONNECT envelope × chunked +// compressed body × streaming upload × redirect — all through the same +// tunnel type. +// ───────────────────────────────────────────────────────────────────────────── + +describe("stacked adversarial", () => { + for (const { proxyTls, splitConnect, encoding, keepalive } of cartesian({ + proxyTls: [false, true] as const, + splitConnect: [1, 3, 10] as const, + encoding: ["identity", "gzip", "br"] as const, + keepalive: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy split=${splitConnect} chunked/${encoding} keepalive=${keepalive}`, + async () => { + const payload = makeBody(8192, "A"); + await using origin = await createAdversarialOrigin({ + tls: true, + body: payload, + framing: "chunked", + encoding, + }); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + splitConnectReply: splitConnect, + }); + const res = await fetch(origin.url, { + method: "POST", + body: Buffer.alloc(1024, "q"), + proxy: proxy.url, + keepalive, + tls: laxTls, + }); + expect(await res.text()).toBe(payload); + expect(res.status).toBe(200); + expect(origin.requests[0].body.length).toBe(1024); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Origin response status matrix through every proxy combination. The body +// must be delivered regardless of status; redirect-class statuses with no +// Location must not hang. +// ───────────────────────────────────────────────────────────────────────────── + +describe("origin status through tunnel", () => { + const STATUSES = [200, 201, 204, 206, 301, 304, 400, 401, 404, 418, 500, 503] as const; + for (const { proxyTls, originTls, status } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + status: STATUSES, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin status=${status}`, + async () => { + // 204/304 carry no body per RFC; everything else does. + const hasBody = status !== 204 && status !== 304; + await using origin = await createAdversarialOrigin({ + tls: originTls, + status, + body: hasBody ? `status-${status}` : "", + framing: "content-length", + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + redirect: "manual", // surface 3xx as-is + }); + expect(res.status).toBe(status); + expect(await res.text()).toBe(hasBody ? `status-${status}` : ""); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Request header shape seen by the origin through each proxy path. The +// Host header must name the origin (not the proxy), and user headers must +// pass through unmodified in both CONNECT-tunnel and absolute-form paths. +// ───────────────────────────────────────────────────────────────────────────── + +describe("origin-facing headers", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin Host + user headers`, + async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(`${origin.url}/path?q=1`, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + headers: { + "X-User-Header": "user-value", + "Accept": "application/json", + }, + }); + expect(res.status).toBe(200); + const h = origin.requests[0].headers; + // Host names the origin, not the proxy. + expect(h["host"]).toBe(`localhost:${origin.port}`); + expect(h["x-user-header"]).toBe("user-value"); + expect(h["accept"]).toBe("application/json"); + expect(origin.requests[0].path).toBe("/path?q=1"); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// checkServerIdentity callback re-entry: the callback runs on the JS +// thread with the tunnel parked; it can allocate, throw, or issue other +// fetches. The tunnel must resume correctly after approval, and clean up +// correctly after rejection, through both proxy types. +// ───────────────────────────────────────────────────────────────────────────── + +describe("checkServerIdentity through tunnel", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy: callback approves → request completes`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "approved" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + let called = 0; + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: { + ca: tlsCert.cert, + rejectUnauthorized: true, + checkServerIdentity: (host: string, cert: any) => { + called++; + expect(host).toBe("localhost"); + expect(cert).toBeDefined(); + // Allocate a bit while the tunnel is parked. + Buffer.alloc(1024); + return undefined; // approve + }, + }, + }); + expect(await res.text()).toBe("approved"); + expect(called).toBe(1); + }); + + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy: callback rejects → fetch rejects, origin untouched`, + async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "never" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + let caught: any; + try { + await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: { + ca: tlsCert.cert, + rejectUnauthorized: true, + checkServerIdentity: () => new Error("nope"), + }, + signal: AbortSignal.timeout(10_000), + }); + } catch (e) { + caught = e; + } + expect(caught?.message).toBe("nope"); + expect(origin.requests.length).toBe(0); + }, + ); + + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy: callback approves ${isASAN ? 50 : 20}× under GC pressure`, + async () => { + await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("gc") }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const N = isASAN ? 50 : 20; + for (let i = 0; i < N; i++) { + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: true, + tls: { + ca: tlsCert.cert, + rejectUnauthorized: true, + checkServerIdentity: () => { + Bun.gc(false); + return undefined; + }, + }, + }); + expect(await res.text()).toBe("gc"); + } + }, + 60_000, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Query string + path edge cases through proxy. +// ───────────────────────────────────────────────────────────────────────────── + +describe("path and query through proxy", () => { + const PATHS = [ + "/", + "/a/b/c", + "/with%20space", + "/?a=1&b=2", + "/p?q=%E4%B8%AD%E6%96%87", // URL-encoded UTF-8 + "/" + Buffer.alloc(500, "p").toString("latin1"), + ] as const; + for (const { proxyTls, originTls, path } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + path: PATHS, + })) { + const short = path.length > 20 ? path.slice(0, 17) + "..." : path; + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin path="${short}"`, + async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url + path, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + expect(origin.requests[0].path).toBe(path); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// verbose:true through every proxy combination. The verbose output goes to +// stderr; here we only check that the request still succeeds (verbose +// touches internal state that could regress). +// ───────────────────────────────────────────────────────────────────────────── + +describe("verbose through proxy", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin verbose:true`, + async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "v" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + verbose: true, + }); + expect(await res.text()).toBe("v"); + expect(res.status).toBe(200); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// AbortSignal.timeout through every proxy combination, against an origin +// that never replies. Must reject with TimeoutError, not hang. +// ───────────────────────────────────────────────────────────────────────────── + +describe("AbortSignal.timeout through proxy", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, origin never replies → TimeoutError`, + async () => { + // Origin accepts the connection but never writes a response. + const handler = (sock: net.Socket) => { + sock.on("error", () => {}); + // swallow data forever + }; + const server = originTls + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handler) + : net.createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const originPort = (server.address() as net.AddressInfo).port; + try { + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + let code: string; + const t0 = Date.now(); + try { + const res = await fetch(`${originTls ? "https" : "http"}://127.0.0.1:${originPort}/`, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(500), + }); + await res.arrayBuffer().catch(() => {}); + code = `resolved:${res.status}`; + } catch (e) { + code = errcode(e); + } + const elapsed = Date.now() - t0; + expect(code).toBe("TimeoutError"); + // The 500ms timeout is honored (with generous slack for debug+ASAN + // builds), not the client's multi-second default idle timeout. + expect(elapsed).toBeLessThan(10_000); + } finally { + server.close(); + } + }, + 20_000, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// WebSocket through proxy: full matrix of ws/wss target × http/https proxy, +// plus lifecycle edges (proxy closes mid-handshake, origin closes mid-frame, +// large frames, abort during upgrade). +// +// Uses Bun's native WebSocket client (global `WebSocket`) with the `proxy` +// constructor option, which drives `WebSocketProxyTunnel` for wss targets. +// ───────────────────────────────────────────────────────────────────────────── + +describe("WebSocket through proxy", () => { + function makeEchoServer(withTls: boolean) { + return Bun.serve({ + port: 0, + tls: withTls ? tlsCert : undefined, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("expected upgrade", { status: 400 }); + }, + websocket: { + open(ws) { + ws.send("hello"); + }, + message(ws, msg) { + ws.send(msg); + }, + }, + }); + } + + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + const scheme = originTls ? "wss" : "ws"; + const route = `${proxyTls ? "https" : "http"}-proxy → ${scheme}-origin`; + + test.concurrent(`${route}: echo round-trip`, async () => { + await using origin = makeEchoServer(originTls); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const received: string[] = []; + const { promise: done, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`${scheme}://localhost:${origin.port}/`, { + proxy: proxy.url, + tls: originTls || proxyTls ? laxTls : undefined, + } as any); + ws.onmessage = ev => { + received.push(String(ev.data)); + if (received.length === 1) ws.send("echo-me"); + if (received.length === 2) { + ws.close(); + resolve(); + } + }; + ws.onerror = ev => reject(new Error("ws error: " + (ev as any).message)); + ws.onclose = ev => { + if (received.length < 2) reject(new Error(`closed early: ${ev.code} ${ev.reason}`)); + }; + await done; + expect(received).toEqual(["hello", "echo-me"]); + expect(proxy.connections.length).toBe(1); + expect(proxy.connections[0].method).toBe("CONNECT"); + }); + + test.concurrent(`${route}: large binary frame round-trip`, async () => { + await using origin = makeEchoServer(originTls); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const payload = new Uint8Array(256 * 1024).fill(0xab); + + const { promise: done, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`${scheme}://localhost:${origin.port}/`, { + proxy: proxy.url, + tls: originTls || proxyTls ? laxTls : undefined, + } as any); + ws.binaryType = "arraybuffer"; + let gotHello = false; + let settled = false; + ws.onmessage = ev => { + if (!gotHello) { + gotHello = true; + ws.send(payload); + return; + } + settled = true; + resolve(new Uint8Array(ev.data as ArrayBuffer)); + ws.close(); + }; + ws.onerror = ev => reject(new Error("ws error: " + (ev as any).message)); + ws.onclose = ev => { + if (!settled) reject(new Error(`closed early: ${ev.code} ${ev.reason}`)); + }; + const got = await done; + expect(got.length).toBe(payload.length); + expect(got[0]).toBe(0xab); + expect(got[got.length - 1]).toBe(0xab); + }); + + test.concurrent(`${route}: proxy RSTs at CONNECT → error event`, async () => { + await using origin = makeEchoServer(originTls); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + killClientAt: "request-received", + }); + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`${scheme}://localhost:${origin.port}/`, { + proxy: proxy.url, + tls: originTls || proxyTls ? laxTls : undefined, + } as any); + ws.onopen = () => reject(new Error("open fired")); + ws.onerror = () => resolve("error"); + ws.onclose = () => resolve("close"); + const outcome = await promise; + expect(["error", "close"]).toContain(outcome); + }); + + test.concurrent(`${route}: CONNECT → 407 without auth → error/close event`, async () => { + await using origin = makeEchoServer(originTls); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + auth: { user: "u", pass: "p" }, + }); + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`${scheme}://localhost:${origin.port}/`, { + proxy: proxy.url, // no credentials + tls: originTls || proxyTls ? laxTls : undefined, + } as any); + ws.onopen = () => reject(new Error("open fired")); + ws.onerror = () => resolve("error"); + ws.onclose = () => resolve("close"); + const outcome = await promise; + expect(["error", "close"]).toContain(outcome); + }); + + test.concurrent(`${route}: rapid close after open`, async () => { + await using origin = makeEchoServer(originTls); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`${scheme}://localhost:${origin.port}/`, { + proxy: proxy.url, + tls: originTls || proxyTls ? laxTls : undefined, + } as any); + ws.onopen = () => ws.close(1000, "done"); + ws.onerror = ev => reject(new Error("ws error: " + (ev as any).message)); + ws.onclose = ev => resolve(ev.code); + const code = await promise; + expect(code).toBe(1000); + }); + } + + // wss through https proxy: the double-TLS case that had zero coverage. + // Churn it under GC pressure to look for tunnel lifecycle issues. + test("wss via https-proxy: open/close churn under GC", async () => { + await using origin = makeEchoServer(true); + await using proxy = await createAdversarialProxy({ tls: true }); + const N = isASAN ? 40 : 20; + for (let i = 0; i < N; i++) { + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`wss://localhost:${origin.port}/`, { + proxy: proxy.url, + tls: laxTls, + } as any); + ws.onmessage = () => { + ws.close(); + }; + ws.onclose = () => resolve(); + ws.onerror = ev => reject(new Error("ws error: " + (ev as any).message)); + await promise; + if (i % 5 === 0) Bun.gc(true); + } + }, 60_000); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Interleaved proxy and direct fetches to the same origin. The client's +// connection pool keys tunneled and non-tunneled connections separately; a +// direct request must never end up on a tunnel and vice versa. +// ───────────────────────────────────────────────────────────────────────────── + +describe("interleaved proxy/direct", () => { + for (const proxyTls of [false, true] as const) { + test(`${proxyTls ? "https" : "http"}-proxy + direct to same https origin, alternating`, async () => { + await using origin = Bun.serve({ + port: 0, + tls: tlsCert, + fetch: req => new Response(new URL(req.url).searchParams.get("via") ?? "?"), + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const totalBytesUp = () => proxy.connections.reduce((s, c) => s + c.bytesUp, 0); + for (let i = 0; i < 6; i++) { + const viaProxy = i % 2 === 0; + const before = totalBytesUp(); + const res = await fetch(`${origin.url}?via=${viaProxy ? "proxy" : "direct"}`, { + ...(viaProxy ? { proxy: proxy.url } : {}), + keepalive: true, + tls: laxTls, + }); + expect(await res.text()).toBe(viaProxy ? "proxy" : "direct"); + // A direct fetch mis-routed onto a pooled tunnel would show up as + // new client→upstream bytes on the proxy even though no `proxy` + // option was passed. + if (!viaProxy) expect(totalBytesUp()).toBe(before); + } + // 3 proxied requests → at least 1 CONNECT, at most 3. + expect(proxy.connectCount()).toBeGreaterThanOrEqual(1); + expect(proxy.connectCount()).toBeLessThanOrEqual(3); + // http-proxy: 3 sequential keepalive proxied requests reuse one + // tunnel deterministically. A proxied request that silently + // bypassed the proxy would leave this at 0; a direct request that + // opened its own CONNECT would push it above 1. + if (!proxyTls) expect(proxy.connectCount()).toBe(1); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CONNECT target line shape: host:port format for default vs non-default +// ports. +// ───────────────────────────────────────────────────────────────────────────── + +describe("CONNECT target format", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy CONNECT target is host:port`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + // CONNECT line targets the origin's host:port, not a URL. + expect(proxy.connections[0].target).toBe(`localhost:${origin.port}`); + // And a matching Host header. + expect(proxy.connections[0].headers["host"]).toBe(`localhost:${origin.port}`); + }); + } +}); diff --git a/test/js/bun/http/proxy-stress-concurrent.test.ts b/test/js/bun/http/proxy-stress-concurrent.test.ts new file mode 100644 index 000000000000..4bdda387aaa9 --- /dev/null +++ b/test/js/bun/http/proxy-stress-concurrent.test.ts @@ -0,0 +1,457 @@ +/** + * Concurrency, connection-pool, and memory stress for the proxy tunnel. + * + * The tunnel pool (HTTPContext::PooledSocket) keys on (proxy addr, target + * host:port, proxy_auth_hash, established_with_reject_unauthorized). These + * tests churn that pool: many parallel requests to one target, many targets + * through one proxy, interleaved aborts, and a subprocess leak probe that + * watches RSS over thousands of iterations. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN, isCI } from "harness"; +import { once } from "node:events"; +import net from "node:net"; +import { join } from "node:path"; +import { + cartesian, + clearProxyEnv, + createAdversarialProxy, + laxTls, + makeBody, + proxyFreeEnv, + restoreProxyEnv, + tlsCert, +} from "./proxy-stress-helpers"; + +let savedEnv: Record; +beforeAll(() => { + savedEnv = clearProxyEnv(); +}); +afterAll(() => { + restoreProxyEnv(savedEnv); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Parallel requests to one origin through one proxy. +// ───────────────────────────────────────────────────────────────────────────── + +describe("parallel requests, single origin", () => { + for (const { proxyTls, originTls, keepalive } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + keepalive: [false, true] as const, + })) { + const N = 32; + test.concurrent( + `${N}× parallel ${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin keepalive=${keepalive}`, + async () => { + // Use Bun.serve here so keepalive reuse actually works on the + // origin side (the raw adversarial origin closes after each + // response, which defeats the pool). + await using origin = Bun.serve({ + port: 0, + tls: originTls ? tlsCert : undefined, + fetch: req => new Response(new URL(req.url).searchParams.get("i") ?? "?"), + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const results = await Promise.all( + Array.from({ length: N }, (_, i) => + fetch(`${origin.url}?i=${i}`, { + proxy: proxy.url, + keepalive, + tls: laxTls, + }).then(async r => ({ status: r.status, body: await r.text() })), + ), + ); + + // Every request got its own response body back — no cross-talk. + for (let i = 0; i < N; i++) { + expect(results[i]).toEqual({ status: 200, body: String(i) }); + } + + // All traffic went through the proxy. + expect(proxy.connections.length).toBeGreaterThanOrEqual(1); + if (originTls) { + expect(proxy.connections.every(c => c.method === "CONNECT")).toBe(true); + } + }, + 30_000, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Sequential keep-alive reuse of the CONNECT tunnel: N requests to the same +// https origin, with keepalive on, should result in exactly one CONNECT. +// The double-TLS path (https proxy → https origin) is what's missing from +// proxy.test.ts's reuse coverage. +// ───────────────────────────────────────────────────────────────────────────── + +describe("tunnel reuse", () => { + for (const proxyTls of [false, true] as const) { + test(`${proxyTls ? "https" : "http"}-proxy → https-origin, 5 sequential requests reuse one CONNECT`, async () => { + await using origin = Bun.serve({ + port: 0, + tls: tlsCert, + fetch: () => new Response("reused"), + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + for (let i = 0; i < 5; i++) { + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: true, tls: laxTls }); + expect(await res.text()).toBe("reused"); + expect(res.status).toBe(200); + } + // For an HTTP proxy the outer TCP connection is reused verbatim; a + // single CONNECT serves all five requests. For an HTTPS proxy the + // outer TLS socket is itself subject to the SSL context's pool + // rules; require only that pooling happened at all (fewer CONNECTs + // than requests). + if (proxyTls) { + expect(proxy.connectCount()).toBeLessThanOrEqual(5); + } else { + expect(proxy.connectCount()).toBe(1); + } + }); + + test(`${proxyTls ? "https" : "http"}-proxy → https-origin, different auth hashes use separate tunnels`, async () => { + await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("ok") }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const creds = ["a:1", "b:2", "a:1"]; // third should reuse first (http-proxy) + for (const c of creds) { + const res = await fetch(origin.url, { + proxy: `${proxyTls ? "https" : "http"}://${c}@127.0.0.1:${proxy.port}`, + keepalive: true, + tls: laxTls, + }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + } + // Different auth hashes must never share a tunnel. That is the + // invariant; whether the third (repeat) cred reuses the first + // depends on outer-socket pooling for https proxies (see above). + const c = proxy.connectCount(); + expect(c).toBeGreaterThanOrEqual(2); + expect(c).toBeLessThanOrEqual(3); + // The first two CONNECTs carried different Proxy-Authorization. + const auths = proxy.connections.map(r => r.headers["proxy-authorization"]); + expect(auths[0]).not.toBe(auths[1]); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Many origins through one proxy. This churns the pool's target-key map. +// ───────────────────────────────────────────────────────────────────────────── + +describe("many origins, one proxy", () => { + for (const proxyTls of [false, true] as const) { + test(`${proxyTls ? "https" : "http"}-proxy → 12 https origins, interleaved`, async () => { + const N_ORIGINS = 12; + const origins: Array<{ url: string; stop: () => void }> = []; + for (let i = 0; i < N_ORIGINS; i++) { + const body = `origin-${i}`; + const s = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response(body) }); + origins.push({ url: String(s.url), stop: () => s.stop(true) }); + } + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + try { + // Two rounds so each origin is reused once. + for (let round = 0; round < 2; round++) { + for (let i = 0; i < N_ORIGINS; i++) { + const res = await fetch(origins[i].url, { proxy: proxy.url, keepalive: true, tls: laxTls }); + expect(await res.text()).toBe(`origin-${i}`); + expect(res.status).toBe(200); + } + } + // Every request went through the proxy as a CONNECT; none + // bypassed. Tunnel reuse across rounds is an optimization — + // assert it for the HTTP proxy where it's deterministic. + const cc = proxy.connectCount(); + expect(cc).toBeGreaterThanOrEqual(N_ORIGINS); + expect(cc).toBeLessThanOrEqual(2 * N_ORIGINS); + if (!proxyTls) { + expect(cc).toBe(N_ORIGINS); + } + } finally { + for (const o of origins) o.stop(); + } + }, 45_000); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// reject_unauthorized stickiness: a tunnel established with +// rejectUnauthorized=false must not be reused by a later strict request. +// ───────────────────────────────────────────────────────────────────────────── + +describe("reject_unauthorized pool gate", () => { + for (const proxyTls of [false, true] as const) { + test(`${proxyTls ? "https" : "http"}-proxy → https-origin: lax then strict opens a fresh CONNECT`, async () => { + await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("g") }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + // 1: lax + let res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: true, + tls: { rejectUnauthorized: false }, + }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + expect(proxy.connectCount()).toBe(1); + + // 2: strict with matching CA — must not reuse the lax tunnel. + res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: true, + tls: { ca: tlsCert.cert, rejectUnauthorized: true }, + }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + expect(proxy.connectCount()).toBe(2); + + // 3: strict again — reuses the strict tunnel (http-proxy). + res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: true, + tls: { ca: tlsCert.cert, rejectUnauthorized: true }, + }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + // Invariant: the strict request never reused the lax tunnel + // (connectCount grew from 1 to 2 at step 2). For an HTTP proxy, + // step 3 deterministically reuses the strict tunnel; HTTPS-proxy + // outer-socket pooling can force a third CONNECT (see above). + const cc = proxy.connectCount(); + if (proxyTls) { + expect(cc).toBeGreaterThanOrEqual(2); + expect(cc).toBeLessThanOrEqual(3); + } else { + expect(cc).toBe(2); + } + }, 30_000); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Large bidirectional body through the tunnel: upload and echo 4MB in one +// request, N times concurrently. Exercises the ProxyBody upload path and +// the streaming download path together under load. +// ───────────────────────────────────────────────────────────────────────────── + +describe("large bidirectional", () => { + const SIZE = 4 * 1024 * 1024; + for (const proxyTls of [false, true] as const) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, 4× concurrent ${SIZE}B echo`, + async () => { + await using origin = Bun.serve({ + port: 0, + tls: tlsCert, + fetch: async req => new Response(await req.arrayBuffer()), + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const payload = makeBody(SIZE, "L"); + const results = await Promise.all( + Array.from({ length: 4 }, () => + fetch(origin.url, { + method: "POST", + body: payload, + proxy: proxy.url, + keepalive: false, + tls: laxTls, + }).then(async r => { + const t = await r.text(); + return { status: r.status, len: t.length, ok: t === payload }; + }), + ), + ); + for (const r of results) { + expect(r).toEqual({ status: 200, len: SIZE, ok: true }); + } + }, + 60_000, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Pooled tunnel receives unsolicited data while idle: the proxy pushes a +// byte into the client after the first request fully completes and the +// tunnel has been parked. The client should evict the tunnel rather than +// letting the stale byte reach the next request. +// ───────────────────────────────────────────────────────────────────────────── + +test("idle pooled tunnel receiving data is evicted", async () => { + await using origin = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("ok") }); + + // Custom proxy that exposes each live client socket so the test can + // inject a stray byte AFTER the first request has been fully consumed + // (i.e. once the tunnel is definitely parked in the pool). + const liveClients = new Set(); + let connects = 0; + const server = net.createServer(client => { + connects++; + liveClients.add(client); + client.on("close", () => liveClients.delete(client)); + client.on("error", () => {}); + let head = Buffer.alloc(0); + let upstream: net.Socket | undefined; + client.on("data", chunk => { + if (upstream) { + upstream.write(chunk); + return; + } + head = Buffer.concat([head, chunk]); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) return; + const leftover = head.subarray(end + 4); + upstream = net.connect(origin.port, "127.0.0.1", () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (leftover.length) upstream!.write(leftover); + upstream!.pipe(client, { end: false }); + }); + upstream.on("error", () => client.destroy()); + client.on("close", () => upstream?.destroy()); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const proxyPort = (server.address() as net.AddressInfo).port; + + try { + // First request: pools the tunnel. + let res = await fetch(origin.url, { + proxy: `http://127.0.0.1:${proxyPort}`, + keepalive: true, + tls: laxTls, + }); + expect(await res.text()).toBe("ok"); + expect(connects).toBe(1); + + // Tunnel is now parked. Push a stray byte into every live client + // socket from the proxy side. The client's idle-data handler evicts + // the pooled entry, which RSTs the proxy connection. + const parked = [...liveClients]; + expect(parked.length).toBe(1); + const closed = Promise.all( + parked.map( + c => + new Promise(resolve => { + if (c.destroyed) return resolve(); + c.once("close", () => resolve()); + }), + ), + ); + for (const c of parked) c.write(Buffer.from([0x17, 0x03, 0x03, 0x00, 0x01, 0x00])); + await closed; + + // Second request: must open a fresh CONNECT and succeed. + res = await fetch(origin.url, { + proxy: `http://127.0.0.1:${proxyPort}`, + keepalive: true, + tls: laxTls, + }); + expect(await res.text()).toBe("ok"); + expect(connects).toBe(2); + } finally { + for (const c of liveClients) c.destroy(); + server.close(); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Subprocess-based memory / leak / UAF probe. +// +// Run a child that issues thousands of fetch/abort cycles through a proxy +// to an https origin across every stage of the tunnel, tracking RSS. The +// child exits non-zero on any crash (ASAN UAF, debug assert, segfault) and +// reports RSS growth ratio so the test can fail on leaks. +// ───────────────────────────────────────────────────────────────────────────── + +describe("memory probe (subprocess)", () => { + const MODES = [ + "complete", // let every request finish + "abort-immediate", // abort on next microtask + "abort-after-connect", // abort once the proxy sees the CONNECT + "concurrent-32", // 32 in flight at once, all complete + "concurrent-32-abort", // 32 in flight, abort half at random + "redirect", // origin redirects once per request + ] as const; + + for (const { proxyTls, mode } of cartesian({ + proxyTls: [false, true] as const, + mode: MODES, + })) { + // ASAN inflates RSS and slows everything down; use fewer iterations + // there but still enough to surface a UAF. + const iterations = isASAN ? 300 : isCI ? 1200 : 600; + + test(`${proxyTls ? "https" : "http"}-proxy → https-origin mode=${mode} ×${iterations}`, async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + join(import.meta.dir, "proxy-stress-memory-fixture.ts"), + proxyTls ? "https" : "http", + mode, + String(iterations), + ], + env: { + ...bunEnv, + ...proxyFreeEnv, + // UAFs on the HTTP thread must abort the process rather + // than race the main thread's clean exit. + ASAN_OPTIONS: ((bunEnv as any).ASAN_OPTIONS ?? "") + ":abort_on_error=1:halt_on_error=1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error("fixture stderr:\n" + stderr); + + // Surface the child's final stats line before asserting. + const lines = stdout.trim().split("\n"); + const lastLine = lines[lines.length - 1]; + let result: { completed: number; failed: number; rssStart: number; rssEnd: number; rssMax: number }; + try { + result = JSON.parse(lastLine); + } catch { + console.error("fixture stdout:\n" + stdout); + throw new Error("fixture did not emit a JSON summary line"); + } + + expect(exitCode).toBe(0); + expect(result.completed + result.failed).toBe(iterations); + // Non-abort modes must complete every request; abort modes must + // have actually aborted something. + if (mode.includes("abort")) { + expect(result.failed).toBeGreaterThan(0); + } else { + expect(result.failed).toBe(0); + expect(result.completed).toBe(iterations); + } + + // RSS leak check: after a warm-up, RSS should plateau. Allow a + // generous 3× growth factor (ASAN, fragmentation, per-target pool + // entries) — a real leak of one tunnel/request shows as 10×+ with + // these iteration counts. Skip the threshold under ASAN because + // LeakSanitizer's shadow memory makes RSS non-representative; a + // UAF there shows up as a crash, not a slow leak. + if (!isASAN) { + const growth = result.rssEnd / Math.max(1, result.rssStart); + // Carry `mode` + the rounded ratio in the failing diff. + expect({ mode, growth: Number(growth.toFixed(2)), withinBound: growth < 3.0 }).toEqual({ + mode, + growth: expect.any(Number), + withinBound: true, + }); + } + }, 120_000); + } +}); diff --git a/test/js/bun/http/proxy-stress-errors.test.ts b/test/js/bun/http/proxy-stress-errors.test.ts new file mode 100644 index 000000000000..51b811e54b89 --- /dev/null +++ b/test/js/bun/http/proxy-stress-errors.test.ts @@ -0,0 +1,375 @@ +/** + * Proxy error-path coverage. + * + * These tests assert what the client surfaces when something in the proxy + * pipeline fails in an expected way: a non-200 CONNECT, an unreachable + * proxy or upstream, wrong/missing proxy auth, inner-TLS verification + * failure, unsupported protocol/feature combinations. + * + * Unlike the lifecycle file (which asserts "no hang/crash"), here we assert + * the *shape* of the surfaced response/error because it's part of the + * observable API. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { once } from "node:events"; +import net from "node:net"; +import tls from "node:tls"; +import { + cartesian, + clearProxyEnv, + createAdversarialOrigin, + createAdversarialProxy, + deadPort, + errcode, + laxTls, + restoreProxyEnv, + tlsCert, +} from "./proxy-stress-helpers"; + +let savedEnv: Record; +beforeAll(() => { + savedEnv = clearProxyEnv(); +}); +afterAll(() => { + restoreProxyEnv(savedEnv); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CONNECT failure status codes. +// ───────────────────────────────────────────────────────────────────────────── + +describe("CONNECT failure status", () => { + const STATUSES = [400, 403, 407, 500, 502, 503, 504] as const; + for (const { proxyTls, status } of cartesian({ + proxyTls: [false, true] as const, + status: STATUSES, + })) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy CONNECT → ${status} is surfaced as-is`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "unreachable" }); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + connectStatus: status, + connectStatusBody: `proxy-said-${status}`, + }); + + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(15_000), + }); + // The client surfaces the proxy's reply; it does NOT tunnel through. + expect(res.status).toBe(status); + expect(await res.text()).toBe(`proxy-said-${status}`); + // The origin must never have been reached. + expect(origin.requests.length).toBe(0); + }); + } + + // A 3xx CONNECT reply is surfaced, not followed (already covered for 307 + // in proxy.test.ts; here we add 301/302 and assert the Location is not + // interpreted). + for (const status of [301, 302] as const) { + test.concurrent(`CONNECT → ${status} with Location is not followed`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "unreachable" }); + await using bait = await createAdversarialOrigin({ tls: false, body: "bait" }); + await using proxy = await createAdversarialProxy({ + connectStatus: status, + connectReplyHeaders: { Location: bait.url }, + }); + + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(status); + expect(res.headers.get("location")).toBe(bait.url); + expect(bait.requests.length).toBe(0); + expect(origin.requests.length).toBe(0); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Proxy unreachable. +// ───────────────────────────────────────────────────────────────────────────── + +describe("proxy unreachable", () => { + for (const originTls of [false, true] as const) { + test.concurrent(`proxy port refused, ${originTls ? "https" : "http"} origin`, async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "unreachable" }); + const port = await deadPort(); + let code: string; + try { + const res = await fetch(origin.url, { + proxy: `http://127.0.0.1:${port}`, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(15_000), + }); + await res.arrayBuffer().catch(() => {}); + code = `resolved:${res.status}`; + } catch (e) { + code = errcode(e); + } + expect(code).toMatch(/ECONNREFUSED|ConnectionRefused/); + expect(origin.requests.length).toBe(0); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Upstream unreachable via proxy: the proxy dials a refused port and the +// client sees the proxy's 502. +// ───────────────────────────────────────────────────────────────────────────── + +describe("upstream unreachable via proxy", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy, CONNECT upstream refused → 502`, async () => { + const port = await deadPort(); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + // Point at a refused port directly — the client will CONNECT to it, + // the proxy will fail to dial, and return 502. + const res = await fetch(`https://127.0.0.1:${port}/`, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(15_000), + }); + expect(res.status).toBe(502); + }); + + test.concurrent(`${proxyTls ? "https" : "http"}-proxy, absolute-form upstream refused → 502`, async () => { + const port = await deadPort(); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(`http://127.0.0.1:${port}/`, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(15_000), + }); + expect(res.status).toBe(502); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Proxy authentication. +// ───────────────────────────────────────────────────────────────────────────── + +describe("proxy authentication", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + const route = `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin`; + + test.concurrent(`${route}: missing auth → 407`, async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "secret" }); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + auth: { user: "alice", pass: "s3cret" }, + }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(407); + expect(res.headers.get("proxy-authenticate")).toContain("Basic"); + expect(origin.requests.length).toBe(0); + }); + + test.concurrent(`${route}: wrong auth → 403`, async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "secret" }); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + auth: { user: "alice", pass: "s3cret" }, + }); + const res = await fetch(origin.url, { + proxy: `${proxyTls ? "https" : "http"}://alice:wrong@127.0.0.1:${proxy.port}`, + keepalive: false, + tls: laxTls, + }); + expect(res.status).toBe(403); + expect(origin.requests.length).toBe(0); + }); + + test.concurrent(`${route}: correct auth → 200`, async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "secret" }); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + auth: { user: "alice", pass: "s3cret" }, + }); + const res = await fetch(origin.url, { + proxy: `${proxyTls ? "https" : "http"}://alice:s3cret@127.0.0.1:${proxy.port}`, + keepalive: false, + tls: laxTls, + }); + expect(await res.text()).toBe("secret"); + expect(res.status).toBe(200); + expect(proxy.connections[0].headers["proxy-authorization"]).toBe( + "Basic " + Buffer.from("alice:s3cret").toString("base64"), + ); + }); + + test.concurrent(`${route}: auth via proxy.headers`, async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "secret" }); + await using proxy = await createAdversarialProxy({ + tls: proxyTls, + auth: { user: "alice", pass: "s3cret" }, + }); + const basic = "Basic " + Buffer.from("alice:s3cret").toString("base64"); + const res = await fetch(origin.url, { + proxy: { url: proxy.url, headers: { "Proxy-Authorization": basic } }, + keepalive: false, + tls: laxTls, + }); + expect(await res.text()).toBe("secret"); + expect(res.status).toBe(200); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Inner-TLS verification through the tunnel. +// ───────────────────────────────────────────────────────────────────────────── + +describe("inner TLS verification", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, rejectUnauthorized=true with matching CA`, + async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "verified" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: { ca: tlsCert.cert, rejectUnauthorized: true }, + }); + expect(await res.text()).toBe("verified"); + expect(res.status).toBe(200); + }, + ); + + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, rejectUnauthorized=true without CA fails`, + async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "verified" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + let code: string; + try { + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: { rejectUnauthorized: true }, + signal: AbortSignal.timeout(15_000), + }); + await res.arrayBuffer().catch(() => {}); + code = `resolved:${res.status}`; + } catch (e) { + code = errcode(e); + } + expect(code).not.toBe("resolved:200"); + expect(code).not.toBe("TimeoutError"); + expect(code).toMatch(/CERT|TLS|SSL|SELF_SIGNED|UNABLE_TO_VERIFY|DEPTH_ZERO/); + // Inner handshake failed, so the origin saw no decrypted request. + expect(origin.requests.length).toBe(0); + // For an HTTP proxy the outer leg has no TLS; the CONNECT must + // have been sent and the failure is the inner handshake. For an + // HTTPS proxy the same rejectUnauthorized:true + no CA would + // also reject the outer self-signed proxy cert before CONNECT; + // either way the fetch must not succeed, but only the http-proxy + // case proves the inner-TLS path specifically. + if (!proxyTls) { + expect(proxy.connectCount()).toBe(1); + } + }, + ); + + test.concurrent(`${proxyTls ? "https" : "http"}-proxy → https-origin, checkServerIdentity rejects`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "verified" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + let code: string; + try { + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: { + ca: tlsCert.cert, + rejectUnauthorized: true, + checkServerIdentity: () => new Error("pinned"), + }, + signal: AbortSignal.timeout(15_000), + }); + await res.arrayBuffer().catch(() => {}); + code = `resolved:${res.status}`; + } catch (e) { + const any = e as any; + code = any?.message ?? errcode(e); + } + expect(code).toBe("pinned"); + expect(origin.requests.length).toBe(0); + // The tunnel was established before checkServerIdentity ran (it + // runs on the inner handshake, not the outer). The proxy saw the + // CONNECT; the origin saw no request. + expect(proxy.connectCount()).toBe(1); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Protocol rejection: unsupported proxy schemes. +// ───────────────────────────────────────────────────────────────────────────── + +describe("unsupported proxy scheme", () => { + for (const scheme of ["ftp", "socks4", "socks5", "socks5h", "ws"] as const) { + test.concurrent(`${scheme}:// proxy is rejected with UnsupportedProxyProtocol`, async () => { + await using origin = await createAdversarialOrigin({ tls: false, body: "ok" }); + await expect(fetch(origin.url, { proxy: `${scheme}://127.0.0.1:1`, keepalive: false })).rejects.toMatchObject({ + code: "UnsupportedProxyProtocol", + }); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP/2 is not offered through a proxy. The client must negotiate +// http/1.1 in the inner-TLS ALPN even against an h2-capable origin. +// ───────────────────────────────────────────────────────────────────────────── + +describe("HTTP/2 not offered through proxy", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy → h2-capable https origin negotiates http/1.1`, async () => { + // A raw TLS server that advertises both h2 and http/1.1 and echoes + // back the protocol it actually negotiated with the client. A + // Bun.serve origin can't expose the ALPN result to its handler, so + // observe it at the socket level instead. + const server = tls.createServer({ ...tlsCert, ALPNProtocols: ["h2", "http/1.1"] }, sock => { + sock.on("error", () => {}); + sock.once("data", () => { + const negotiated = sock.alpnProtocol || "none"; + const body = `alpn=${negotiated}`; + sock.write( + `HTTP/1.1 200 OK\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`, + ); + sock.end(); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const originPort = (server.address() as net.AddressInfo).port; + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + try { + const res = await fetch(`https://localhost:${originPort}/`, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + }); + // If the client offered h2 in the inner ALPN, the origin would have + // selected it (h2 is first in ALPNProtocols) and this assertion + // would read "alpn=h2". + expect(await res.text()).toBe("alpn=http/1.1"); + expect(res.status).toBe(200); + expect(proxy.connections[0].method).toBe("CONNECT"); + } finally { + server.close(); + } + }); + } +}); diff --git a/test/js/bun/http/proxy-stress-headers.test.ts b/test/js/bun/http/proxy-stress-headers.test.ts new file mode 100644 index 000000000000..3b3f7e7c2a4a --- /dev/null +++ b/test/js/bun/http/proxy-stress-headers.test.ts @@ -0,0 +1,308 @@ +/** + * Response- and request-header shape through the proxy tunnel: many + * headers, duplicate Set-Cookie, every standard Content-Type, and the + * proxy's own Host / Proxy-Connection on the CONNECT envelope. + * + * Also: Content-Range (206), long URLs, and the decompress:false path + * that surfaces the raw encoded body. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { once } from "node:events"; +import net from "node:net"; +import tls from "node:tls"; +import zlib from "node:zlib"; +import { + cartesian, + clearProxyEnv, + createAdversarialOrigin, + createAdversarialProxy, + laxTls, + restoreProxyEnv, + tlsCert, +} from "./proxy-stress-helpers"; + +let savedEnv: Record; +beforeAll(() => { + savedEnv = clearProxyEnv(); +}); +afterAll(() => { + restoreProxyEnv(savedEnv); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Many response headers through each proxy combination. +// ───────────────────────────────────────────────────────────────────────────── + +describe("many response headers", () => { + for (const { proxyTls, originTls, count } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + count: [5, 50, 200] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, ${count} response headers`, + async () => { + const headers: Record = {}; + for (let i = 0; i < count; i++) headers[`X-Resp-${i}`] = `val-${i}`; + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok", headers }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + for (let i = 0; i < count; i++) { + expect(res.headers.get(`x-resp-${i}`)).toBe(`val-${i}`); + } + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Multiple Set-Cookie headers: preserved as separate entries. +// ───────────────────────────────────────────────────────────────────────────── + +describe("duplicate Set-Cookie through tunnel", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, 3 Set-Cookie headers`, + async () => { + // The adversarial origin writes headers from a plain object, so + // duplicate names need a raw writer. Build one inline for this test. + const handler = (sock: net.Socket) => { + sock.on("error", () => {}); + sock.once("data", () => { + sock.write( + "HTTP/1.1 200 OK\r\n" + + "Set-Cookie: a=1\r\n" + + "Set-Cookie: b=2\r\n" + + "Set-Cookie: c=3\r\n" + + "Content-Length: 2\r\n" + + "Connection: close\r\n\r\nok", + ); + sock.end(); + }); + }; + const server = originTls + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handler) + : net.createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const originUrl = `${originTls ? "https" : "http"}://localhost:${(server.address() as net.AddressInfo).port}`; + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + try { + const res = await fetch(originUrl, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + expect(res.headers.getSetCookie()).toEqual(["a=1", "b=2", "c=3"]); + } finally { + server.close(); + } + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Content-Type matrix: the header value survives the tunnel unchanged. +// ───────────────────────────────────────────────────────────────────────────── + +describe("Content-Type through tunnel", () => { + const CONTENT_TYPES = [ + "text/plain", + "text/html; charset=utf-8", + "application/json", + "application/octet-stream", + "image/png", + 'multipart/form-data; boundary="abc123"', + "application/x-www-form-urlencoded", + ] as const; + for (const { proxyTls, originTls, ct } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + ct: CONTENT_TYPES, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin Content-Type="${ct}"`, + async () => { + await using origin = await createAdversarialOrigin({ + tls: originTls, + body: "ct", + headers: { "Content-Type": ct }, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe(ct); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 206 Partial Content with Content-Range through each proxy combination. +// ───────────────────────────────────────────────────────────────────────────── + +describe("206 Content-Range through tunnel", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin 206 with Content-Range`, + async () => { + await using origin = await createAdversarialOrigin({ + tls: originTls, + status: 206, + body: "partial", + headers: { "Content-Range": "bytes 0-6/100", "Accept-Ranges": "bytes" }, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + headers: { Range: "bytes=0-6" }, + }); + expect(res.status).toBe(206); + expect(res.headers.get("content-range")).toBe("bytes 0-6/100"); + expect(await res.text()).toBe("partial"); + expect(origin.requests[0].headers["range"]).toBe("bytes=0-6"); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// decompress:false through the tunnel: the client surfaces the raw +// encoded body and the Content-Encoding header. +// ───────────────────────────────────────────────────────────────────────────── + +describe("decompress:false through tunnel", () => { + for (const { proxyTls, originTls, encoding } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + encoding: ["gzip", "br"] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin ${encoding} decompress:false`, + async () => { + const payload = Buffer.alloc(2048, "Z").toString("latin1"); + await using origin = await createAdversarialOrigin({ + tls: originTls, + body: payload, + encoding, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + decompress: false, + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-encoding")).toBe(encoding); + const raw = Buffer.from(await res.arrayBuffer()); + // The raw bytes are the compressed form, not the plaintext. + expect(raw.toString("latin1")).not.toBe(payload); + const decoded = encoding === "gzip" ? zlib.gunzipSync(raw) : zlib.brotliDecompressSync(raw); + expect(decoded.toString("latin1")).toBe(payload); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CONNECT envelope shape: Host and Proxy-Connection are present on the +// proxy request (write_proxy_connect in src/http/lib.rs). +// ───────────────────────────────────────────────────────────────────────────── + +describe("CONNECT envelope shape", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy CONNECT carries Host and Proxy-Connection`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + const h = proxy.connections[0].headers; + expect(h["host"]).toBe(`localhost:${origin.port}`); + expect(h["proxy-connection"]).toBeDefined(); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Long request URL through both absolute-form and CONNECT tunnel. +// ───────────────────────────────────────────────────────────────────────────── + +describe("long URL through proxy", () => { + for (const { proxyTls, originTls, len } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + len: [1024, 4096, 8000] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, ${len}B path`, + async () => { + const path = "/" + Buffer.alloc(len - 1, "p").toString("latin1"); + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url + path, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + expect(origin.requests[0].path).toBe(path); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Switch proxy mid-session: two requests to the same origin through two +// different proxies. Tunnels must not be cross-keyed. +// ───────────────────────────────────────────────────────────────────────────── + +describe("switching proxy", () => { + for (const originTls of [false, true] as const) { + test.concurrent(`two different proxies, same ${originTls ? "https" : "http"}-origin`, async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); + await using proxyA = await createAdversarialProxy({}); + await using proxyB = await createAdversarialProxy({}); + let res = await fetch(origin.url, { proxy: proxyA.url, keepalive: true, tls: laxTls }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + res = await fetch(origin.url, { proxy: proxyB.url, keepalive: true, tls: laxTls }); + expect(res.status).toBe(200); + await res.arrayBuffer(); + expect(proxyA.connections.length).toBe(1); + expect(proxyB.connections.length).toBe(1); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// decompress:true (default): the body is decoded and Content-Encoding is +// preserved on the exposed response (issue #5668). +// ───────────────────────────────────────────────────────────────────────────── + +describe("Content-Encoding header after decompress", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin gzip, body decoded`, + async () => { + const payload = Buffer.alloc(2048, "D").toString("latin1"); + await using origin = await createAdversarialOrigin({ tls: originTls, body: payload, encoding: "gzip" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + // Bun keeps Content-Encoding after transparent decompression + // (fetch.test.ts, issue #5668); the tunnel must match direct fetch. + expect(res.headers.get("content-encoding")).toBe("gzip"); + expect(await res.text()).toBe(payload); + }, + ); + } +}); diff --git a/test/js/bun/http/proxy-stress-helpers.ts b/test/js/bun/http/proxy-stress-helpers.ts new file mode 100644 index 000000000000..24dd0e044378 --- /dev/null +++ b/test/js/bun/http/proxy-stress-helpers.ts @@ -0,0 +1,739 @@ +/** + * Adversarial proxy + origin infrastructure for stress-testing the HTTP + * client's proxy code paths. + * + * The proxy here understands both absolute-form requests (HTTP target) and + * CONNECT tunneling (HTTPS target), over a plain-TCP or TLS outer socket. + * Every lifecycle stage exposes a hook so a test can close/delay/mangle at + * that exact point; the default behavior with no hooks is a transparent + * well-behaved proxy. + * + * Origins are thin wrappers around `Bun.serve` / raw `tls`/`net` servers that + * can shape the response (content-length / chunked / close-delimited, + * optional compression) and track what the client actually sent. + */ + +import net from "node:net"; +import tls from "node:tls"; +import zlib from "node:zlib"; +import { once } from "node:events"; +import { tls as tlsCert } from "harness"; + +// ───────────────────────────────────────────────────────────────────────────── +// Environment hygiene: ambient HTTP_PROXY / NO_PROXY on CI hosts will reroute +// or bypass localhost fetches and silently turn every assertion here into a +// false positive. Importers of this module get the cleared env (and a +// restorer) for free. +// ───────────────────────────────────────────────────────────────────────────── + +export const PROXY_ENV_KEYS = [ + "NO_PROXY", + "no_proxy", + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", +] as const; + +export function clearProxyEnv(): Record { + const saved: Record = {}; + for (const key of PROXY_ENV_KEYS) { + saved[key] = process.env[key]; + // Assign "" rather than delete: the native env loader only observes + // assignments. An empty value disables the proxy/bypass. + process.env[key] = ""; + } + return saved; +} + +export function restoreProxyEnv(saved: Record) { + for (const key of PROXY_ENV_KEYS) { + process.env[key] = saved[key] ?? ""; + } +} + +/** Env override for subprocess fixtures: wipes every proxy-relevant key. */ +export const proxyFreeEnv = { + NO_PROXY: undefined, + no_proxy: undefined, + HTTP_PROXY: undefined, + http_proxy: undefined, + HTTPS_PROXY: undefined, + https_proxy: undefined, +} as const; + +// ───────────────────────────────────────────────────────────────────────────── +// Adversarial proxy +// ───────────────────────────────────────────────────────────────────────────── + +export type ProxyStage = + /** CONNECT or absolute-form request head fully received from client. */ + | "request-received" + /** Upstream TCP connect() succeeded (before the 200 CONNECT reply). */ + | "upstream-connected" + /** 200 reply to CONNECT (or forwarded head for absolute-form) written. */ + | "connect-replied" + /** First client→upstream byte after the tunnel was established (for + * CONNECT to an HTTPS origin this is the inner-TLS ClientHello). */ + | "first-client-byte" + /** First upstream→client byte after the tunnel was established (for + * CONNECT to an HTTPS origin this is the inner-TLS ServerHello flight). */ + | "first-upstream-byte"; + +export interface ProxyConnectionRecord { + /** Raw request head bytes (CONNECT line or absolute-form request line). */ + head: string; + method: string; + /** For CONNECT, `host:port`; for absolute-form, the absolute URL. */ + target: string; + /** Lower-cased header name → value of the proxy request. */ + headers: Record; + /** Number of client→upstream bytes relayed after the tunnel was up. */ + bytesUp: number; + /** Number of upstream→client bytes relayed after the tunnel was up. */ + bytesDown: number; +} + +export interface AdversarialProxyOptions { + /** Outer socket is TLS (i.e., an `https://` proxy). */ + tls?: boolean; + /** + * If set, the proxy responds to every CONNECT with this status instead of + * dialing upstream. The body is empty unless `connectStatusBody` is set. + * Absolute-form requests (non-CONNECT) still forward normally. + */ + connectStatus?: number; + /** Optional body to send with a non-200 CONNECT reply. */ + connectStatusBody?: string; + /** Optional extra headers on the CONNECT reply (success or failure). */ + connectReplyHeaders?: Record; + /** + * Split the 200 CONNECT envelope into N writes with a `setImmediate` tick + * between each. Exercises the client's partial-CONNECT-response parser. + */ + splitConnectReply?: number; + /** + * After CONNECT succeeds, relay upstream→client bytes one-byte-at-a-time. + * Forces the inner-TLS handshake and response to arrive across hundreds of + * distinct `on_data` callbacks. + */ + trickleDownstream?: boolean; + /** + * Stage at which the proxy kills the client socket instead of proceeding. + * The kill is an RST (`resetAndDestroy`) so the client observes an error + * rather than a clean FIN. + */ + killClientAt?: ProxyStage; + /** + * Stage at which the proxy kills the upstream socket (clean destroy). + * The client sees whatever the upstream close translates to after being + * relayed. + */ + killUpstreamAt?: ProxyStage; + /** + * If set, the proxy requires `Proxy-Authorization: Basic ` + * and replies 407 if missing / 403 if wrong. + */ + auth?: { user: string; pass: string }; +} + +export interface AdversarialProxy { + server: net.Server | tls.Server; + url: string; + port: number; + /** One entry per accepted client connection, in accept order. */ + connections: ProxyConnectionRecord[]; + /** Number of CONNECT requests seen. */ + connectCount(): number; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +const STATUS_TEXT: Record = { + 200: "Connection Established", + 301: "Moved Permanently", + 302: "Found", + 307: "Temporary Redirect", + 400: "Bad Request", + 403: "Forbidden", + 407: "Proxy Authentication Required", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + 504: "Gateway Timeout", +}; + +function rstClient(client: net.Socket) { + try { + // For TLS sockets, reset the underlying TCP so the client's next write + // fails instead of being buffered. + const raw: net.Socket = (client as any)._parent ?? (client as any).socket ?? client; + if (typeof raw.resetAndDestroy === "function") raw.resetAndDestroy(); + else client.destroy(); + } catch { + client.destroy(); + } +} + +async function writeSplit(socket: net.Socket, data: string, parts: number) { + const buf = Buffer.from(data); + if (parts <= 1 || buf.length <= 1) { + socket.write(buf); + return; + } + const chunk = Math.max(1, Math.floor(buf.length / parts)); + let off = 0; + while (off < buf.length) { + const end = Math.min(off + chunk, buf.length); + socket.write(buf.subarray(off, end)); + off = end; + if (off < buf.length) await new Promise(r => setImmediate(r)); + } +} + +function parseHead(head: string): { method: string; target: string; headers: Record } { + const lines = head.split("\r\n"); + const [method = "", target = ""] = lines[0].split(" "); + const headers: Record = {}; + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const colon = line.indexOf(":"); + if (colon > 0) { + headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim(); + } + } + return { method, target, headers }; +} + +export async function createAdversarialProxy(opts: AdversarialProxyOptions = {}): Promise { + const connections: ProxyConnectionRecord[] = []; + + const handleClient = (client: net.Socket) => { + client.on("error", () => {}); + let head = Buffer.alloc(0); + let upstream: net.Socket | undefined; + let tunneled = false; + let sawFirstClientByte = false; + let sawFirstUpstreamByte = false; + let record: ProxyConnectionRecord | undefined; + + const stageHit = (stage: ProxyStage): boolean => { + if (opts.killClientAt === stage) { + rstClient(client); + upstream?.destroy(); + return true; + } + if (opts.killUpstreamAt === stage) { + if (upstream) { + upstream.destroy(); + } else { + // No upstream yet: nothing to relay the failure through, so the + // client would otherwise hang. Behave like a real proxy and 502. + client.write("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + client.end(); + } + return true; + } + return false; + }; + + // Trickle queue: when `trickleDownstream` is on, upstream→client bytes + // are queued here and drained one-byte-per-tick. The upstream may + // close while bytes are still queued; `upstreamEnded` records that so + // the drain loop can FIN the client only after the last byte. + let trickleQueue = Buffer.alloc(0); + let trickleActive = false; + let upstreamEnded = false; + const pumpTrickle = () => { + if (trickleActive) return; + trickleActive = true; + const step = () => { + if (client.destroyed) { + trickleActive = false; + return; + } + if (trickleQueue.length === 0) { + trickleActive = false; + if (upstreamEnded) client.end(); + return; + } + client.write(trickleQueue.subarray(0, 1)); + trickleQueue = trickleQueue.subarray(1); + setImmediate(step); + }; + step(); + }; + + const relayDown = (chunk: Buffer) => { + if (record) record.bytesDown += chunk.length; + if (!sawFirstUpstreamByte) { + sawFirstUpstreamByte = true; + if (stageHit("first-upstream-byte")) return; + } + if (opts.trickleDownstream) { + trickleQueue = Buffer.concat([trickleQueue, chunk]); + pumpTrickle(); + } else { + client.write(chunk); + } + }; + + const onData = (chunk: Buffer) => { + if (tunneled) { + if (record) record.bytesUp += chunk.length; + if (!sawFirstClientByte) { + sawFirstClientByte = true; + if (stageHit("first-client-byte")) return; + } + upstream?.write(chunk); + return; + } + // Head already parsed and upstream dial in flight; buffer until the + // connect callback flips `tunneled`. The absolute-form branch re-reads + // `head` inside the connect callback, so appended bytes are forwarded. + // CONNECT clients wait for the 200 reply before sending more, so this + // window is unreachable for them. + if (record) { + head = Buffer.concat([head, chunk]); + return; + } + + head = Buffer.concat([head, chunk]); + const headerEnd = head.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + + const headStr = head.subarray(0, headerEnd).toString("latin1"); + const leftover = head.subarray(headerEnd + 4); + const parsed = parseHead(headStr); + record = { + head: headStr, + method: parsed.method, + target: parsed.target, + headers: parsed.headers, + bytesUp: 0, + bytesDown: 0, + }; + connections.push(record); + + if (stageHit("request-received")) return; + + // Proxy-Authorization check. + if (opts.auth) { + const got = parsed.headers["proxy-authorization"]; + const want = "Basic " + Buffer.from(`${opts.auth.user}:${opts.auth.pass}`).toString("base64"); + if (!got) { + client.write( + 'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="test"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n', + ); + client.end(); + return; + } + if (got !== want) { + client.write("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + client.end(); + return; + } + } + + const isConnect = parsed.method === "CONNECT"; + + // Forced CONNECT status (before dialing upstream). + if (isConnect && opts.connectStatus && opts.connectStatus !== 200) { + const status = opts.connectStatus; + const body = opts.connectStatusBody ?? ""; + let reply = `HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? "Error"}\r\n`; + for (const [k, v] of Object.entries(opts.connectReplyHeaders ?? {})) reply += `${k}: ${v}\r\n`; + reply += `Content-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`; + client.write(reply); + client.end(); + return; + } + + // Resolve upstream address. + let host: string; + let port: number; + if (isConnect) { + const colon = parsed.target.lastIndexOf(":"); + host = parsed.target.slice(0, colon); + port = Number(parsed.target.slice(colon + 1)); + } else { + const url = new URL(parsed.target); + host = url.hostname; + port = Number(url.port || "80"); + } + // IPv6 authority-form / URL.hostname keep the brackets; net.connect + // and getaddrinfo want the bare literal. + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); + + upstream = net.connect(port, host); + let clientEnded = false; + const endClient = () => { + if (clientEnded || client.destroyed) return; + clientEnded = true; + client.end(); + }; + upstream.on("error", () => { + if (!tunneled && !client.destroyed && !clientEnded) { + client.write("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + } + endClient(); + }); + upstream.on("close", () => { + if (opts.trickleDownstream) { + // Let the trickle drain finish before closing the client. + upstreamEnded = true; + pumpTrickle(); + } else { + endClient(); + } + }); + client.on("close", () => upstream?.destroy()); + + upstream.once("connect", async () => { + if (stageHit("upstream-connected")) return; + if (isConnect) { + let reply = `HTTP/1.1 200 ${STATUS_TEXT[200]}\r\n`; + for (const [k, v] of Object.entries(opts.connectReplyHeaders ?? {})) reply += `${k}: ${v}\r\n`; + reply += "\r\n"; + await writeSplit(client, reply, opts.splitConnectReply ?? 1); + tunneled = true; + if (stageHit("connect-replied")) return; + if (leftover.length) { + if (record) record.bytesUp += leftover.length; + sawFirstClientByte = true; + if (opts.killClientAt === "first-client-byte" || opts.killUpstreamAt === "first-client-byte") { + if (stageHit("first-client-byte")) return; + } + upstream!.write(leftover); + } + upstream!.on("data", relayDown); + } else { + // Absolute-form: rewrite request line to origin-form, strip + // hop-by-hop headers (Proxy-Authorization / Proxy-Connection; + // RFC 9110 §7.6.1), and relay the rest. + const url = new URL(parsed.target); + const originForm = `${parsed.method} ${url.pathname}${url.search || ""} HTTP/1.1\r\n`; + const firstCrlf = head.indexOf("\r\n"); + const rest = head.subarray(firstCrlf + 2); + // `rest` is "Header: v\r\nHeader: v\r\n\r\n". Split at the + // blank line, filter proxy-* headers, reassemble. + const hdrEnd = rest.indexOf("\r\n\r\n"); + const rawHeaders = rest + .subarray(0, hdrEnd) + .toString("latin1") + .split("\r\n") + .filter(l => { + const name = l.slice(0, l.indexOf(":")).toLowerCase(); + return name !== "proxy-authorization" && name !== "proxy-connection"; + }) + .join("\r\n"); + const bodyStart = rest.subarray(hdrEnd + 4); + upstream!.write(originForm); + upstream!.write(rawHeaders + "\r\n\r\n"); + if (bodyStart.length > 0) upstream!.write(bodyStart); + tunneled = true; + if (stageHit("connect-replied")) return; + upstream!.on("data", relayDown); + } + }); + }; + + client.on("data", onData); + }; + + const server = opts.tls + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handleClient) + : net.createServer(handleClient); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as net.AddressInfo).port; + const url = `${opts.tls ? "https" : "http"}://127.0.0.1:${port}`; + + const close = async () => { + server.close(); + // Do not wait for 'close' — outstanding sockets on a killed tunnel may + // linger, and tests destroy their clients independently. + }; + + return { + server, + url, + port, + connections, + connectCount: () => connections.filter(c => c.method === "CONNECT").length, + close, + [Symbol.asyncDispose]: close, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Adversarial origin +// ───────────────────────────────────────────────────────────────────────────── + +export type BodyFraming = "content-length" | "chunked" | "close-delimited"; +export type BodyEncoding = "identity" | "gzip" | "deflate" | "br" | "zstd"; + +export interface OriginRequestRecord { + method: string; + path: string; + headers: Record; + body: Buffer; +} + +export interface AdversarialOriginOptions { + /** Origin is HTTPS. */ + tls?: boolean; + /** Response status (default 200). */ + status?: number; + /** Response body payload (before compression). Default "ok". */ + body?: Buffer | string; + /** How the body length is framed on the wire. Default "content-length". */ + framing?: BodyFraming; + /** Content-Encoding of the response. Default "identity". */ + encoding?: BodyEncoding; + /** Extra response headers. */ + headers?: Record; + /** If set, origin RSTs the socket after writing exactly this many response + * bytes (head + body). 0 = RST immediately after receiving the request + * head, before writing anything. */ + killAfterBytes?: number; + /** If set, respond with a redirect to this absolute URL instead of `body`. */ + redirectTo?: string; + /** Echo the request body as the response body. Overrides `body`. */ + echo?: boolean; +} + +export interface AdversarialOrigin { + server: net.Server | tls.Server; + url: string; + port: number; + /** One entry per complete request received, in arrival order. */ + requests: OriginRequestRecord[]; + close(): Promise; + [Symbol.asyncDispose](): Promise; +} + +function encodeBody(raw: Buffer, encoding: BodyEncoding): Buffer { + switch (encoding) { + case "gzip": + return zlib.gzipSync(raw); + case "deflate": + return zlib.deflateSync(raw); + case "br": + return zlib.brotliCompressSync(raw); + case "zstd": + return zlib.zstdCompressSync(raw); + case "identity": + default: + return raw; + } +} + +function buildResponse(opts: AdversarialOriginOptions, reqBody: Buffer): Buffer { + if (opts.redirectTo) { + const head = + `HTTP/1.1 302 Found\r\n` + `Location: ${opts.redirectTo}\r\n` + `Content-Length: 0\r\nConnection: close\r\n\r\n`; + return Buffer.from(head); + } + const status = opts.status ?? 200; + const rawBody = opts.echo ? reqBody : Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body ?? "ok"); + const encoding = opts.encoding ?? "identity"; + const framing = opts.framing ?? "content-length"; + const encoded = encodeBody(rawBody, encoding); + + let head = `HTTP/1.1 ${status} ${status === 200 ? "OK" : STATUS_TEXT[status] ?? "Status"}\r\n`; + for (const [k, v] of Object.entries(opts.headers ?? {})) head += `${k}: ${v}\r\n`; + if (encoding !== "identity") head += `Content-Encoding: ${encoding}\r\n`; + + if (framing === "content-length") { + head += `Content-Length: ${encoded.length}\r\nConnection: close\r\n\r\n`; + return Buffer.concat([Buffer.from(head), encoded]); + } + if (framing === "chunked") { + head += `Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n`; + // Split the body into at least two chunks to exercise the chunked parser + // path in ProxyTunnel::on_data (stage BodyChunk). + const mid = Math.max(1, Math.floor(encoded.length / 2)); + const c1 = encoded.subarray(0, mid); + const c2 = encoded.subarray(mid); + const chunks: Buffer[] = [Buffer.from(head)]; + for (const c of [c1, c2]) { + if (c.length === 0) continue; + chunks.push(Buffer.from(c.length.toString(16) + "\r\n")); + chunks.push(c); + chunks.push(Buffer.from("\r\n")); + } + chunks.push(Buffer.from("0\r\n\r\n")); + return Buffer.concat(chunks); + } + // close-delimited + head += `Connection: close\r\n\r\n`; + return Buffer.concat([Buffer.from(head), encoded]); +} + +export async function createAdversarialOrigin(opts: AdversarialOriginOptions = {}): Promise { + const requests: OriginRequestRecord[] = []; + + const handleClient = (sock: net.Socket) => { + sock.on("error", () => {}); + let buf = Buffer.alloc(0); + let headParsed = false; + let method = ""; + let path = ""; + let headers: Record = {}; + let bodyNeed = 0; + let chunked = false; + let body = Buffer.alloc(0); + + const finish = () => { + requests.push({ method, path, headers, body }); + const resp = buildResponse(opts, body); + if (typeof opts.killAfterBytes === "number") { + const n = opts.killAfterBytes; + if (n === 0) { + rstClient(sock); + return; + } + sock.write(resp.subarray(0, Math.min(n, resp.length)), () => rstClient(sock)); + return; + } + sock.write(resp, () => sock.end()); + }; + + // Minimal chunked-request decoder (only needs to handle what fetch sends). + const decodeChunked = (src: Buffer): { done: boolean; rest: Buffer } => { + let off = 0; + while (true) { + const lineEnd = src.indexOf("\r\n", off); + if (lineEnd === -1) return { done: false, rest: src.subarray(off) }; + const sizeHex = src.subarray(off, lineEnd).toString("latin1"); + const size = parseInt(sizeHex, 16); + if (Number.isNaN(size)) return { done: true, rest: Buffer.alloc(0) }; + const dataStart = lineEnd + 2; + if (size === 0) { + // trailer section: require terminating CRLF CRLF (or CRLF after 0) + const trailerEnd = src.indexOf("\r\n", dataStart); + if (trailerEnd === -1) return { done: false, rest: src.subarray(off) }; + return { done: true, rest: Buffer.alloc(0) }; + } + if (src.length < dataStart + size + 2) return { done: false, rest: src.subarray(off) }; + body = Buffer.concat([body, src.subarray(dataStart, dataStart + size)]); + off = dataStart + size + 2; + } + }; + + sock.on("data", chunk => { + buf = Buffer.concat([buf, chunk]); + if (!headParsed) { + const end = buf.indexOf("\r\n\r\n"); + if (end === -1) return; + const headStr = buf.subarray(0, end).toString("latin1"); + const parsed = parseHead(headStr); + method = parsed.method; + path = parsed.target; + headers = parsed.headers; + headParsed = true; + chunked = (headers["transfer-encoding"] ?? "").toLowerCase().includes("chunked"); + bodyNeed = Number(headers["content-length"] ?? "0"); + buf = buf.subarray(end + 4); + if (typeof opts.killAfterBytes === "number" && opts.killAfterBytes === 0) { + requests.push({ method, path, headers, body }); + rstClient(sock); + return; + } + } + if (chunked) { + const { done, rest } = decodeChunked(buf); + buf = rest; + if (done) finish(); + return; + } + if (bodyNeed > 0) { + if (buf.length < bodyNeed) return; + body = buf.subarray(0, bodyNeed); + } + finish(); + }); + }; + + const server = opts.tls + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handleClient) + : net.createServer(handleClient); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as net.AddressInfo).port; + const url = `${opts.tls ? "https" : "http"}://localhost:${port}`; + + const close = async () => { + server.close(); + }; + + return { server, url, port, requests, close, [Symbol.asyncDispose]: close }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Higher-level conveniences shared by the stress test files. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The TLS options every fetch through a self-signed proxy/origin needs. + * `rejectUnauthorized: false` because the test cert is self-signed; tests + * that exercise the `rejectUnauthorized: true` path build their own. + */ +export const laxTls = { ca: tlsCert.cert, rejectUnauthorized: false } as const; + +/** + * A body generator that's cheap in debug builds (Buffer.alloc, not + * String.repeat — the latter is O(n) * extremely slow JSC debug). + */ +export function makeBody(bytes: number, fill = "A"): string { + return Buffer.alloc(bytes, fill).toString("latin1"); +} + +/** + * Spawn-friendly summary of a thrown fetch error: pulls `.code` or `.name`. + */ +export function errcode(e: unknown): string { + const any = e as any; + return typeof any?.code === "string" ? any.code : typeof any?.name === "string" ? any.name : String(e); +} + +/** + * A port that's definitely closed. We bind and immediately release a port, + * then return it; nothing reuses it in the microseconds before the caller + * dials. Good enough for "connect refused" assertions without racing other + * tests on a fixed well-known-closed port. + */ +export async function deadPort(): Promise { + const s = net.createServer(); + s.listen(0, "127.0.0.1"); + await once(s, "listening"); + const port = (s.address() as net.AddressInfo).port; + s.close(); + await once(s, "close"); + return port; +} + +/** + * Produce every combination of the input dimensions as a flat list. + * `cartesian({a: [1,2], b: ["x","y"]})` → + * [{a:1,b:"x"},{a:1,b:"y"},{a:2,b:"x"},{a:2,b:"y"}] + */ +export function cartesian>( + dims: T, +): Array<{ [K in keyof T]: T[K][number] }> { + const keys = Object.keys(dims) as (keyof T)[]; + let out: Array> = [{}]; + for (const k of keys) { + const next: Array> = []; + for (const base of out) { + for (const v of dims[k]) { + next.push({ ...base, [k]: v }); + } + } + out = next; + } + return out as Array<{ [K in keyof T]: T[K][number] }>; +} + +export { tlsCert }; diff --git a/test/js/bun/http/proxy-stress-lifecycle.test.ts b/test/js/bun/http/proxy-stress-lifecycle.test.ts new file mode 100644 index 000000000000..97ee34c5456a --- /dev/null +++ b/test/js/bun/http/proxy-stress-lifecycle.test.ts @@ -0,0 +1,554 @@ +/** + * Lifecycle-edge stress: close, abort, and kill the proxy tunnel at every + * observable stage and assert the fetch fails cleanly (specific error, no + * hang, no crash). These are the transitions where ref/deref and + * SSLWrapper/ProxyTunnel callback ordering matter. + * + * Stages covered, for every {http, https} proxy × https origin combination: + * - proxy RSTs client after receiving the CONNECT head + * - proxy RSTs client after upstream TCP connects (before CONNECT reply) + * - proxy RSTs client after sending the CONNECT 200 reply + * - proxy RSTs client after first inner-TLS ClientHello byte + * - proxy RSTs client after first inner-TLS ServerHello byte + * - proxy drops the upstream leg at each of the above + * - origin RSTs after 0/partial response bytes + * - client aborts before connect / mid-CONNECT / mid-handshake / mid-body + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isASAN } from "harness"; +import { + ProxyStage, + cartesian, + clearProxyEnv, + createAdversarialOrigin, + createAdversarialProxy, + errcode, + laxTls, + proxyFreeEnv, + restoreProxyEnv, + tlsCert, +} from "./proxy-stress-helpers"; + +let savedEnv: Record; +beforeAll(() => { + savedEnv = clearProxyEnv(); +}); +afterAll(() => { + restoreProxyEnv(savedEnv); +}); + +// Every fetch in this file races a 15s AbortSignal. If that fires, the +// request hung instead of failing — a TimeoutError / AbortError is always a +// test failure. +const HANG_GUARD_MS = 15_000; + +/** A fetch that must reject with a non-timeout error. */ +async function expectConnectionFailure(p: Promise): Promise { + let code: string; + try { + const res = await p; + // Consume the body so a late failure has somewhere to surface. + await res.arrayBuffer().catch(() => {}); + code = `resolved:${res.status}`; + } catch (e) { + code = errcode(e); + } + // The request must not have been left to hang. + expect(code).not.toBe("TimeoutError"); + expect(code).not.toBe("AbortError"); + // And must not have succeeded. + expect(code).not.toStartWith("resolved:"); + return code; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Proxy kills the client socket at each stage (RST). Run against both +// keepalive states, and against both an HTTPS origin (CONNECT tunnel) and +// an HTTP origin (absolute-form) since those are two entirely separate +// code paths on the client. +// ───────────────────────────────────────────────────────────────────────────── + +const CLIENT_KILL_STAGES: ProxyStage[] = [ + "request-received", + "upstream-connected", + "connect-replied", + "first-client-byte", + "first-upstream-byte", +]; + +describe("proxy RSTs client", () => { + for (const { proxyTls, originTls, stage, keepalive } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + stage: CLIENT_KILL_STAGES, + keepalive: [false, true] as const, + })) { + // For an http origin (absolute-form GET), the client sends nothing + // after the request head, so `first-client-byte` never fires — skip + // that combination. + if (!originTls && stage === "first-client-byte") continue; + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, RST at '${stage}' keepalive=${keepalive}`, + async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "never" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls, killClientAt: stage }); + + const code = await expectConnectionFailure( + fetch(origin.url, { + proxy: proxy.url, + keepalive, + tls: laxTls, + signal: AbortSignal.timeout(HANG_GUARD_MS), + }), + ); + expect(code).toMatch(/ECONNRESET|ConnectionClosed|ECONNREFUSED|ConnectionRefused|SocketError|EPIPE/); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Same RST matrix but with a request body in flight (so the failure lands +// in the ProxyBody / upload path rather than ProxyHeaders). +// ───────────────────────────────────────────────────────────────────────────── + +describe("proxy RSTs client during upload", () => { + for (const { proxyTls, stage, bodyKind } of cartesian({ + proxyTls: [false, true] as const, + stage: CLIENT_KILL_STAGES, + bodyKind: ["string", "stream"] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, ${bodyKind} upload, RST at '${stage}'`, + async () => { + await using origin = await createAdversarialOrigin({ tls: true, echo: true }); + await using proxy = await createAdversarialProxy({ tls: proxyTls, killClientAt: stage }); + const body = + bodyKind === "string" + ? Buffer.alloc(16 * 1024, "u").toString("latin1") + : new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(8192).fill(0x55)); + c.enqueue(new Uint8Array(8192).fill(0x55)); + c.close(); + }, + }); + const code = await expectConnectionFailure( + fetch(origin.url, { + method: "POST", + body, + ...(bodyKind === "stream" ? { duplex: "half" as const } : {}), + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(HANG_GUARD_MS), + }), + ); + expect(code).toMatch(/ECONNRESET|ConnectionClosed|ECONNREFUSED|ConnectionRefused|SocketError|EPIPE/); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Proxy kills the upstream socket at each stage. The client sees this as the +// proxy's socket going quiet (relayed close) after CONNECT succeeded, or as +// a 502 before. +// ───────────────────────────────────────────────────────────────────────────── + +describe("proxy kills upstream", () => { + for (const { proxyTls, stage } of cartesian({ + proxyTls: [false, true] as const, + stage: CLIENT_KILL_STAGES, + })) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy → https-origin, upstream dropped at '${stage}'`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "never" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls, killUpstreamAt: stage }); + + let outcome: string; + try { + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(HANG_GUARD_MS), + }); + await res.arrayBuffer().catch(() => {}); + outcome = `resolved:${res.status}`; + } catch (e) { + outcome = errcode(e); + } + // At "upstream-connected", the upstream's close happens before the + // tunnel is up; the proxy relays the 502 envelope it writes on + // upstream error, which the client surfaces as a 502 response. + // After the tunnel is up the close is relayed and the inner TLS + // fails. Either is acceptable; a hang is not. + expect(outcome).not.toBe("TimeoutError"); + expect(outcome).not.toBe("AbortError"); + expect(outcome).toMatch( + /^resolved:502$|ECONNRESET|ConnectionClosed|ECONNREFUSED|ConnectionRefused|SocketError|EPIPE|ERR_TLS/, + ); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Origin kills the connection at various byte offsets through the tunnel. +// ───────────────────────────────────────────────────────────────────────────── + +describe("origin RSTs through tunnel", () => { + for (const { proxyTls, killAfter } of cartesian({ + proxyTls: [false, true] as const, + // 0 = before any response byte; 10 = mid-status-line; 60 = mid-headers; + // 200 = mid-body (with a 512-byte body). + killAfter: [0, 10, 60, 200] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, origin RST after ${killAfter} response bytes`, + async () => { + await using origin = await createAdversarialOrigin({ + tls: true, + body: Buffer.alloc(512, "x"), + framing: "content-length", + killAfterBytes: killAfter, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + let outcome: string; + try { + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(HANG_GUARD_MS), + }); + // With a truncated content-length body, .text() should reject. + await res.text(); + outcome = `resolved:${res.status}`; + } catch (e) { + outcome = errcode(e); + } + expect(outcome).not.toBe("TimeoutError"); + expect(outcome).not.toBe("AbortError"); + // A truncated body surfaces as ConnectionClosed / ECONNRESET. A + // response that never got to the status line is ConnectionRefused + // / ConnectionClosed. + expect(outcome).not.toBe("resolved:200"); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Close-delimited body through the tunnel: the origin's close IS the EOF. +// The tunnel's on_close callback routes this through the +// `received_last_chunk` path rather than `close_and_fail`. Parameterize by +// whether the origin closes immediately (same packet as body) or after a +// tick (separate packet). +// ───────────────────────────────────────────────────────────────────────────── + +describe("close-delimited body through tunnel", () => { + for (const { proxyTls, encoding } of cartesian({ + proxyTls: [false, true] as const, + encoding: ["identity", "gzip"] as const, + })) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy → https-origin, close-delimited ${encoding}`, async () => { + const payload = Buffer.alloc(4096, "C").toString("latin1"); + await using origin = await createAdversarialOrigin({ + tls: true, + body: payload, + framing: "close-delimited", + encoding, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe(payload); + expect(res.status).toBe(200); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// AbortSignal at every stage. A transparent proxy exposes its connection +// record; the test polls bytesUp/bytesDown to detect each stage and +// aborts when it's reached. The fetch must reject with AbortError and +// the tunnel teardown must not leave the request hung or the process +// crashed. +// ───────────────────────────────────────────────────────────────────────────── + +describe("abort at each proxy stage", () => { + const ABORT_STAGES: ProxyStage[] = [ + "request-received", + "upstream-connected", + "connect-replied", + "first-client-byte", + "first-upstream-byte", + ]; + + for (const { proxyTls, stage } of cartesian({ + proxyTls: [false, true] as const, + stage: ABORT_STAGES, + })) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy → https-origin, abort at '${stage}'`, async () => { + const ac = new AbortController(); + await using origin = await createAdversarialOrigin({ tls: true, body: "never" }); + // Run a transparent proxy and poll its connection record from the + // test side: the stage is inferred from record presence / + // bytesUp / bytesDown. The first three stages have no externally + // observable distinction from the record alone, so they collapse + // to "abort as soon as the proxy sees the request". + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const predicates: Record boolean> = { + "request-received": c => !!c, + "upstream-connected": c => !!c, + "connect-replied": c => !!c, + "first-client-byte": c => !!c && c.bytesUp > 0, + "first-upstream-byte": c => !!c && c.bytesDown > 0, + }; + const want = predicates[stage]; + + const poller = (async () => { + while (!want(proxy.connections[0])) { + await new Promise(r => setImmediate(r)); + } + ac.abort(); + })(); + + let code: string; + try { + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: ac.signal, + }); + await res.arrayBuffer().catch(() => {}); + code = `resolved:${res.status}`; + } catch (e) { + code = errcode(e); + } + // `await poller` resolving is itself proof the stage was reached: + // the IIFE's only exit sets `ac.abort()` and returns. + await poller; + + // Either the abort won (AbortError), or the request had already + // finished failing because the proxy/origin closed first. Both are + // fine; a resolved 200 or a hang is not. + if (code !== "AbortError") { + // The origin never replies with a complete response in this test + // (the poller aborts too early for that), but on very fast machines + // the abort may lose the race against a clean close. Accept any + // connection-flavored failure. + expect(code).not.toBe("resolved:200"); + expect(code).not.toBe("TimeoutError"); + } + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Abort immediately after fetch() returns the Response (body not yet read). +// This exercises the tunnel teardown path where the response is "done" from +// the headers' perspective but the body stream is still live. +// ───────────────────────────────────────────────────────────────────────────── + +describe("abort after headers, before body", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy → https-origin, abort with body pending`, async () => { + await using origin = await createAdversarialOrigin({ + tls: true, + body: Buffer.alloc(256 * 1024, "B"), + framing: "content-length", + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const ac = new AbortController(); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: ac.signal, + }); + expect(res.status).toBe(200); + ac.abort(); + let code: string; + try { + await res.arrayBuffer(); + code = "resolved"; + } catch (e) { + code = errcode(e); + } + // Either the body was already fully buffered (small body over + // loopback), in which case the abort is a no-op and arrayBuffer() + // resolves, or it wasn't and arrayBuffer() rejects with AbortError. + expect(["resolved", "AbortError"]).toContain(code); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Use-after-free: TLS alert buffered with the inner handshake flight. +// +// If the origin rejects the TLS stream immediately after its ServerHello +// flight (e.g. because the proxy corrupted the client's bytes), the alert +// record lands in the same receive buffer as the handshake. The client's +// on_handshake → on_writable → ProxyTunnel::on_writable → SSLWrapper::flush +// → handle_reading chain then processes the alert, fires on_close → +// close_and_fail, frees the client, and returns into on_writable which +// reads the freed `self`. +// +// Repro: a proxy that duplicates every client→origin byte. The origin's +// TLS stack sees a second ClientHello interleaved with the real traffic +// and aborts with an alert. Only deterministic under ASAN. +// ───────────────────────────────────────────────────────────────────────────── + +test.skipIf(!isASAN)( + "TLS alert in same buffer as inner handshake does not use-after-free the client", + async () => { + const fixture = ` + const net = require("node:net"); + const { once } = require("node:events"); + const tlsCert = ${JSON.stringify({ cert: tlsCert.cert, key: tlsCert.key })}; + + const origin = Bun.serve({ + port: 0, + tls: tlsCert, + fetch: () => new Response("never"), + }); + + // CONNECT proxy that duplicates every client->upstream byte, corrupting + // the inner TLS stream so the origin sends an alert right after (or + // during) its ServerHello flight. + const proxy = net.createServer(client => { + client.on("error", () => {}); + let head = Buffer.alloc(0); + let upstream; + client.on("close", () => upstream?.destroy()); + client.on("data", chunk => { + if (upstream) { + upstream.write(chunk); + upstream.write(chunk); // corrupt: double-write + return; + } + head = Buffer.concat([head, chunk]); + const end = head.indexOf("\\r\\n\\r\\n"); + if (end === -1) return; + const leftover = head.subarray(end + 4); + upstream = net.connect(origin.port, "127.0.0.1", () => { + client.write("HTTP/1.1 200 OK\\r\\n\\r\\n"); + if (leftover.length) { + upstream.write(leftover); + upstream.write(leftover); + } + upstream.pipe(client); + }); + upstream.on("error", () => client.destroy()); + upstream.on("close", () => client.destroy()); + }); + }); + proxy.listen(0, "127.0.0.1"); + await once(proxy, "listening"); + const proxyPort = proxy.address().port; + + // Multiple iterations: ASAN's report+abort on the HTTP thread is + // slower than the result callback that resolves the fetch on the + // main thread. Looping ensures a UAF on iteration N aborts the + // process before iteration N+1 completes; a single fetch + + // immediate process.exit(0) would race and win on fast machines. + for (let i = 0; i < 20; i++) { + let outcome = ""; + try { + const res = await fetch("https://localhost:" + origin.port + "/", { + proxy: "http://127.0.0.1:" + proxyPort, + keepalive: false, + tls: { rejectUnauthorized: false }, + signal: AbortSignal.timeout(10000), + }); + await res.arrayBuffer().catch(() => {}); + outcome = "resolved:" + res.status; + } catch (e) { + outcome = "rejected:" + (e?.code ?? e?.name ?? String(e)); + } + console.log(outcome); + } + origin.stop(true); + proxy.close(); + process.exit(0); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { + ...bunEnv, + ...proxyFreeEnv, + // The UAF happens on the HTTP thread; without abort_on_error the + // main thread's process.exit(0) can win the race against ASAN's + // reporter and the test would pass on an unfixed build. + ASAN_OPTIONS: ((bunEnv as any).ASAN_OPTIONS ?? "") + ":abort_on_error=1:halt_on_error=1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error("stderr:\n" + stderr); + // The corrupted stream makes the inner TLS fail; every fetch must + // reject cleanly, not crash the process. On an unfixed ASAN build + // the subprocess aborts mid-loop. + const lines = stdout.trim().split("\n"); + expect(lines.length).toBe(20); + for (const line of lines) { + expect(line).toMatch(/^rejected:/); + } + expect(stdout).not.toContain("TimeoutError"); + expect(exitCode).toBe(0); + }, + 30_000, +); + +// ───────────────────────────────────────────────────────────────────────────── +// Repeated abort churn in-process. This is a lighter-weight version of the +// subprocess-based memory fixture: many fetch+abort cycles at random +// post-CONNECT points, asserting no crash and bounded heap growth. Under +// ASAN this is where UAFs in the tunnel close path surface. +// ───────────────────────────────────────────────────────────────────────────── + +describe("abort churn", () => { + // Heavier iteration count under ASAN (where UAFs are catchable); lighter + // elsewhere to keep the debug build test time reasonable. + const ITERATIONS = isASAN ? 200 : 60; + + for (const proxyTls of [false, true] as const) { + test(`${proxyTls ? "https" : "http"}-proxy → https-origin, ${ITERATIONS}× fetch+immediate-abort`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: Buffer.alloc(4096, "x") }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + let aborted = 0; + let other = 0; + for (let i = 0; i < ITERATIONS; i++) { + const ac = new AbortController(); + const p = fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: ac.signal, + }); + // Abort on the next microtask — lands somewhere in the + // connect/CONNECT/handshake window. + queueMicrotask(() => ac.abort()); + try { + const res = await p; + await res.arrayBuffer().catch(() => {}); + other++; + } catch (e) { + if (errcode(e) === "AbortError") aborted++; + else other++; + } + } + // At least some of the aborts must have actually raced the request. + expect(aborted + other).toBe(ITERATIONS); + expect(aborted).toBeGreaterThan(0); + }, 60_000); + } +}); diff --git a/test/js/bun/http/proxy-stress-matrix.test.ts b/test/js/bun/http/proxy-stress-matrix.test.ts new file mode 100644 index 000000000000..7c04412637cb --- /dev/null +++ b/test/js/bun/http/proxy-stress-matrix.test.ts @@ -0,0 +1,416 @@ +/** + * Protocol × framing × encoding × body-shape matrix through an HTTP proxy. + * + * Every cell here is a request that must round-trip a known payload through + * a proxy; the assertion is on the decoded body, not just the status. This + * exercises the full ProxyTunnel decode path (SSLWrapper decrypt → on_data → + * handle_response_body / handle_response_body_chunked_encoding → + * InternalState::decompress_bytes) across every supported combination. + * + * See proxy-stress-helpers.ts for the proxy/origin infrastructure. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + BodyEncoding, + BodyFraming, + cartesian, + clearProxyEnv, + createAdversarialOrigin, + createAdversarialProxy, + laxTls, + makeBody, + restoreProxyEnv, +} from "./proxy-stress-helpers"; + +let savedEnv: Record; +beforeAll(() => { + savedEnv = clearProxyEnv(); +}); +afterAll(() => { + restoreProxyEnv(savedEnv); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Response-side matrix: every way the origin can frame/encode a body, through +// every proxy/origin TLS combination. +// ───────────────────────────────────────────────────────────────────────────── + +// Two body sizes: one that fits a single TLS record, one that spans several. +const BODY_SIZES = [128, 64 * 1024] as const; + +const RESPONSE_MATRIX = cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + framing: ["content-length", "chunked", "close-delimited"] as const satisfies readonly BodyFraming[], + encoding: ["identity", "gzip", "deflate", "br", "zstd"] as const satisfies readonly BodyEncoding[], + bodySize: BODY_SIZES, + keepalive: [false, true] as const, +}); + +describe("response matrix", () => { + for (const { proxyTls, originTls, framing, encoding, bodySize, keepalive } of RESPONSE_MATRIX) { + const label = + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin ` + + `${framing}/${encoding} ${bodySize}B keepalive=${keepalive}`; + + test.concurrent(label, async () => { + const payload = makeBody(bodySize, "R"); + await using origin = await createAdversarialOrigin({ + tls: originTls, + body: payload, + framing, + encoding, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive, + tls: laxTls, + }); + const text = await res.text(); + expect({ status: res.status, len: text.length, head: text.slice(0, 8), tail: text.slice(-8) }).toEqual({ + status: 200, + len: payload.length, + head: payload.slice(0, 8), + tail: payload.slice(-8), + }); + + // The request actually went through the proxy. + expect(proxy.connections.length).toBe(1); + if (originTls) { + expect(proxy.connections[0].method).toBe("CONNECT"); + } else { + expect(proxy.connections[0].method).toBe("GET"); + expect(proxy.connections[0].target).toStartWith("http://"); + } + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Request-side matrix: every way the client can send a body through a proxy. +// ───────────────────────────────────────────────────────────────────────────── + +type UploadShape = "string" | "Uint8Array" | "Blob" | "FormData" | "ReadableStream" | "async-iterator"; + +const UPLOAD_MATRIX = cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + shape: [ + "string", + "Uint8Array", + "Blob", + "FormData", + "ReadableStream", + "async-iterator", + ] as const satisfies readonly UploadShape[], + bodySize: BODY_SIZES, +}); + +function makeUploadBody( + shape: UploadShape, + payload: string, +): { body: BodyInit; duplex?: "half"; verify: (echoed: Buffer) => void } { + switch (shape) { + case "string": + return { body: payload, verify: b => expect(b.toString("latin1")).toBe(payload) }; + case "Uint8Array": + return { + body: new TextEncoder().encode(payload), + verify: b => expect(b.toString("latin1")).toBe(payload), + }; + case "Blob": + return { + body: new Blob([payload]), + verify: b => expect(b.toString("latin1")).toBe(payload), + }; + case "FormData": { + const fd = new FormData(); + fd.set("field", payload); + return { + body: fd, + // multipart encoding wraps the payload in boundaries; just assert the + // payload bytes are present and the total is larger. + verify: b => { + expect(b.length).toBeGreaterThan(payload.length); + expect(b.includes(payload)).toBe(true); + }, + }; + } + case "ReadableStream": { + const chunks = [payload.slice(0, payload.length / 2), payload.slice(payload.length / 2)]; + const stream = new ReadableStream({ + start(ctrl) { + for (const c of chunks) ctrl.enqueue(new TextEncoder().encode(c)); + ctrl.close(); + }, + }); + return { + body: stream, + duplex: "half", + verify: b => expect(b.toString("latin1")).toBe(payload), + }; + } + case "async-iterator": { + const chunks = [payload.slice(0, payload.length / 2), payload.slice(payload.length / 2)]; + async function* gen() { + for (const c of chunks) yield new TextEncoder().encode(c); + } + return { + body: gen() as unknown as BodyInit, + duplex: "half", + verify: b => expect(b.toString("latin1")).toBe(payload), + }; + } + } +} + +describe("upload matrix", () => { + for (const { proxyTls, originTls, shape, bodySize } of UPLOAD_MATRIX) { + const label = `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin POST ${shape} ${bodySize}B`; + test.concurrent(label, async () => { + const payload = makeBody(bodySize, "U"); + await using origin = await createAdversarialOrigin({ tls: originTls, echo: true }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const { body, duplex, verify } = makeUploadBody(shape, payload); + const res = await fetch(origin.url, { + method: "POST", + body, + ...(duplex ? { duplex } : {}), + proxy: proxy.url, + keepalive: false, + tls: laxTls, + }); + expect(res.status).toBe(200); + // Origin echoed the exact uploaded bytes; verify shape-appropriately. + verify(origin.requests[0].body); + + // And the client decoded the echoed response correctly. + const echoed = Buffer.from(await res.arrayBuffer()); + verify(echoed); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Streaming-response consumption through a tunnel. +// ───────────────────────────────────────────────────────────────────────────── + +const STREAM_MATRIX = cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + framing: ["content-length", "chunked"] as const satisfies readonly BodyFraming[], +}); + +describe("streamed response via reader", () => { + for (const { proxyTls, originTls, framing } of STREAM_MATRIX) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin ${framing} via getReader()`, + async () => { + const payload = makeBody(128 * 1024, "S"); + await using origin = await createAdversarialOrigin({ tls: originTls, body: payload, framing }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + const reader = res.body!.getReader(); + let got = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + got += value!.length; + } + expect(got).toBe(payload.length); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Trickled downstream: the proxy delivers the inner TLS handshake + response +// one byte per tick. This puts the SSLWrapper state machine through hundreds +// of on_data calls per request. +// ───────────────────────────────────────────────────────────────────────────── + +describe("trickled tunnel bytes", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → https-origin, 1 byte/tick downstream`, + async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "trickled", framing: "content-length" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls, trickleDownstream: true }); + + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + }); + expect(await res.text()).toBe("trickled"); + expect(res.status).toBe(200); + }, + // The trickle is bounded (handshake ~4KB + tiny body), but one byte + // per event-loop tick on a debug+ASAN build can take a few seconds. + 30_000, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Split CONNECT envelope: the `HTTP/1.1 200 ...\r\n\r\n` arrives across N +// separate reads. The client must assemble it before starting inner TLS. +// ───────────────────────────────────────────────────────────────────────────── + +describe("split CONNECT reply", () => { + for (const proxyTls of [false, true] as const) { + for (const parts of [2, 5, 20] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy, CONNECT reply split into ${parts} writes`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "split-ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls, splitConnectReply: parts }); + + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe("split-ok"); + expect(res.status).toBe(200); + }); + } + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CONNECT reply with headers the client must ignore (RFC 9110 §9.3.6): +// Content-Length / Transfer-Encoding on a 2xx CONNECT response. +// ───────────────────────────────────────────────────────────────────────────── + +describe("CONNECT reply with ignored headers", () => { + for (const extra of [ + { "Content-Length": "9999" }, + { "Transfer-Encoding": "chunked" }, + { "Content-Length": "0", "Transfer-Encoding": "chunked" }, + ]) { + test.concurrent(`CONNECT 200 with ${Object.keys(extra).join("+")} is ignored`, async () => { + await using origin = await createAdversarialOrigin({ tls: true, body: "ignored-ok" }); + await using proxy = await createAdversarialProxy({ connectReplyHeaders: extra }); + + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe("ignored-ok"); + expect(res.status).toBe(200); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Hop-by-hop header stripping: Proxy-Authorization and Proxy-Connection are +// sent to the proxy but must not reach the origin. +// ───────────────────────────────────────────────────────────────────────────── + +describe("hop-by-hop headers", () => { + for (const originTls of [false, true] as const) { + test.concurrent( + `Proxy-Authorization and Proxy-Connection do not reach ${originTls ? "https" : "http"} origin`, + async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); + await using proxy = await createAdversarialProxy({ auth: { user: "u", pass: "p" } }); + + const res = await fetch(origin.url, { + proxy: `http://u:p@127.0.0.1:${proxy.port}`, + keepalive: false, + tls: laxTls, + headers: { "X-Reaches-Origin": "yes" }, + }); + expect(await res.text()).toBe("ok"); + expect(res.status).toBe(200); + + // Proxy saw the auth header. + expect(proxy.connections[0].headers["proxy-authorization"]).toStartWith("Basic "); + + // Origin did not. + const originHeaders = origin.requests[0].headers; + expect(originHeaders["proxy-authorization"]).toBeUndefined(); + expect(originHeaders["proxy-connection"]).toBeUndefined(); + // But the user header passed through. + expect(originHeaders["x-reaches-origin"]).toBe("yes"); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// HEAD / DELETE / PUT / PATCH / OPTIONS through every proxy combination. +// ───────────────────────────────────────────────────────────────────────────── + +describe("method matrix", () => { + const METHODS = ["HEAD", "DELETE", "PUT", "PATCH", "OPTIONS"] as const; + for (const { proxyTls, originTls } of cartesian({ proxyTls: [false, true], originTls: [false, true] } as const)) { + for (const method of METHODS) { + test.concurrent( + `${method} via ${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin`, + async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "m" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const res = await fetch(origin.url, { + method, + body: method === "PUT" || method === "PATCH" ? "body" : undefined, + proxy: proxy.url, + keepalive: false, + tls: laxTls, + }); + expect(res.status).toBe(200); + // HEAD has no body. + if (method !== "HEAD") { + expect(await res.text()).toBe("m"); + } + expect(origin.requests[0].method).toBe(method); + }, + ); + } + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Redirect through proxy: same-origin path redirect, and cross-scheme +// http→https / https→http. Each hop must go through the proxy; the tunnel is +// torn down and re-CONNECTed on every https hop. +// ───────────────────────────────────────────────────────────────────────────── + +describe("redirect through proxy", () => { + for (const proxyTls of [false, true] as const) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy, http→https cross-scheme redirect`, async () => { + await using finalOrigin = await createAdversarialOrigin({ tls: true, body: "final" }); + await using firstOrigin = await createAdversarialOrigin({ tls: false, redirectTo: finalOrigin.url }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const res = await fetch(firstOrigin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe("final"); + expect(res.status).toBe(200); + // First hop: absolute-form GET. Second hop: CONNECT. Both via proxy. + expect(proxy.connections.map(c => c.method)).toEqual(["GET", "CONNECT"]); + }); + + test.concurrent(`${proxyTls ? "https" : "http"}-proxy, https→http cross-scheme redirect`, async () => { + await using finalOrigin = await createAdversarialOrigin({ tls: false, body: "final" }); + await using firstOrigin = await createAdversarialOrigin({ tls: true, redirectTo: finalOrigin.url }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const res = await fetch(firstOrigin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe("final"); + expect(res.status).toBe(200); + expect(proxy.connections.map(c => c.method)).toEqual(["CONNECT", "GET"]); + }); + + test.concurrent(`${proxyTls ? "https" : "http"}-proxy, https→https cross-host redirect re-CONNECTs`, async () => { + await using finalOrigin = await createAdversarialOrigin({ tls: true, body: "final" }); + await using firstOrigin = await createAdversarialOrigin({ tls: true, redirectTo: finalOrigin.url }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + + const res = await fetch(firstOrigin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe("final"); + expect(res.status).toBe(200); + // Two distinct https origins → two CONNECTs. + expect(proxy.connectCount()).toBe(2); + expect(proxy.connections[0].target).not.toBe(proxy.connections[1].target); + }); + } +}); diff --git a/test/js/bun/http/proxy-stress-memory-fixture.ts b/test/js/bun/http/proxy-stress-memory-fixture.ts new file mode 100644 index 000000000000..52a0818b64c5 --- /dev/null +++ b/test/js/bun/http/proxy-stress-memory-fixture.ts @@ -0,0 +1,188 @@ +/** + * Subprocess fixture for proxy-stress-concurrent.test.ts: issue many + * requests through a local CONNECT proxy to a local HTTPS origin, under + * one of several modes (complete, abort-immediate, abort-after-connect, + * concurrent-32, concurrent-32-abort, redirect), tracking RSS across the + * run. Emits a single JSON summary line on stdout and exits 0 on clean + * completion. + * + * Usage: bun proxy-stress-memory-fixture.ts + */ + +import net from "node:net"; +import tls from "node:tls"; +import { once } from "node:events"; +import { tls as tlsCert } from "harness"; + +const [proxyScheme, mode, iterStr] = process.argv.slice(2); +const iterations = Number(iterStr ?? "600"); +const isHttpsProxy = proxyScheme === "https"; + +type ConnectRecord = { count: number; resolveNext?: () => void }; +const connectRecord: ConnectRecord = { count: 0 }; + +function notifyConnect() { + connectRecord.count++; + if (connectRecord.resolveNext) { + const r = connectRecord.resolveNext; + connectRecord.resolveNext = undefined; + r(); + } +} + +function waitForNextConnect(): Promise { + return new Promise(resolve => { + connectRecord.resolveNext = resolve; + }); +} + +// HTTPS origin. Optionally redirects once (for mode=redirect). +const origin = Bun.serve({ + port: 0, + tls: tlsCert, + fetch(req) { + const url = new URL(req.url); + if (mode === "redirect" && url.pathname === "/start") { + return Response.redirect(`https://localhost:${origin.port}/final`, 302); + } + return new Response("ok-" + url.pathname); + }, +}); + +// A CONNECT proxy (HTTP or HTTPS outer socket). It intentionally tracks +// connects so the fixture can synchronize aborts to the CONNECT boundary. +function handleClient(client: net.Socket) { + client.on("error", () => {}); + let head = Buffer.alloc(0); + let upstream: net.Socket | undefined; + client.on("close", () => upstream?.destroy()); + client.on("data", chunk => { + if (upstream) { + upstream.write(chunk); + return; + } + head = Buffer.concat([head, chunk]); + const end = head.indexOf("\r\n\r\n"); + if (end === -1) return; + notifyConnect(); + const leftover = head.subarray(end + 4); + const firstLine = head.subarray(0, head.indexOf("\r\n")).toString("latin1"); + const [, hostPort] = firstLine.split(" "); + const colon = hostPort!.lastIndexOf(":"); + const host = hostPort!.slice(0, colon); + const port = Number(hostPort!.slice(colon + 1)); + upstream = net.connect(port, host, () => { + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (leftover.length) upstream!.write(leftover); + // client → upstream relay is the outer on("data") handler above; + // only pipe the upstream → client direction here. + upstream!.pipe(client); + }); + upstream.on("error", () => client.destroy()); + upstream.on("close", () => client.destroy()); + }); +} + +const proxy = isHttpsProxy + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handleClient) + : net.createServer(handleClient); +proxy.listen(0, "127.0.0.1"); +await once(proxy, "listening"); +const proxyPort = (proxy.address() as net.AddressInfo).port; +const proxyUrl = `${isHttpsProxy ? "https" : "http"}://127.0.0.1:${proxyPort}`; + +const laxTls = { ca: tlsCert.cert, rejectUnauthorized: false } as const; +const originUrl = (p: string) => `https://localhost:${origin.port}${p}`; + +let completed = 0; +let failed = 0; +let rssStart = 0; +let rssMax = 0; + +const rss = () => process.memoryUsage.rss(); + +async function one(i: number): Promise { + const path = mode === "redirect" ? "/start" : `/${i}`; + const ac = new AbortController(); + + if (mode === "abort-immediate") { + queueMicrotask(() => ac.abort()); + } else if (mode === "abort-after-connect") { + waitForNextConnect().then(() => ac.abort()); + } + + try { + const res = await fetch(originUrl(path), { + proxy: proxyUrl, + keepalive: false, + tls: laxTls, + signal: mode.startsWith("abort") ? ac.signal : undefined, + }); + await res.arrayBuffer(); + completed++; + } catch { + failed++; + } +} + +async function run() { + // Warm-up: first 20 iterations establish baseline RSS (JIT, TLS session + // cache, first-time allocations). rssStart is sampled after. + const WARMUP = Math.min(20, Math.floor(iterations / 4)); + + if (mode === "concurrent-32" || mode === "concurrent-32-abort") { + let i = 0; + while (i < iterations) { + const batch = Math.min(32, iterations - i); + const tasks: Promise[] = []; + for (let j = 0; j < batch; j++) { + const idx = i + j; + if (mode === "concurrent-32-abort" && idx % 2 === 1) { + const ac = new AbortController(); + const p = fetch(originUrl(`/${idx}`), { + proxy: proxyUrl, + keepalive: false, + tls: laxTls, + signal: ac.signal, + }) + .then(async r => { + await r.arrayBuffer(); + completed++; + }) + .catch(() => { + failed++; + }); + queueMicrotask(() => ac.abort()); + tasks.push(p); + } else { + tasks.push(one(idx)); + } + } + await Promise.all(tasks); + i += batch; + if (i >= WARMUP && rssStart === 0) { + Bun.gc(true); + rssStart = rss(); + } + rssMax = Math.max(rssMax, rss()); + } + } else { + for (let i = 0; i < iterations; i++) { + await one(i); + if (i === WARMUP) { + Bun.gc(true); + rssStart = rss(); + } + rssMax = Math.max(rssMax, rss()); + } + } + + Bun.gc(true); + const rssEnd = rss(); + console.log(JSON.stringify({ completed, failed, rssStart, rssEnd, rssMax })); +} + +await run(); +origin.stop(true); +proxy.close(); +process.exit(0); diff --git a/test/js/bun/http/proxy-stress-protocol.test.ts b/test/js/bun/http/proxy-stress-protocol.test.ts new file mode 100644 index 000000000000..137666661517 --- /dev/null +++ b/test/js/bun/http/proxy-stress-protocol.test.ts @@ -0,0 +1,501 @@ +/** + * Protocol-level edge cases through the proxy: early server replies mid- + * upload, multi-hop redirect chains, large header values, 1xx + * informational responses, origin HTTP version quirks, and IPv6 literals + * as origin hosts. These exercise code paths in the tunnel parser / + * state machine that the plain matrix doesn't reach. + */ + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { isIPv6 } from "harness"; +import { once } from "node:events"; +import net from "node:net"; +import tls from "node:tls"; +import { + cartesian, + clearProxyEnv, + createAdversarialOrigin, + createAdversarialProxy, + laxTls, + makeBody, + restoreProxyEnv, + tlsCert, +} from "./proxy-stress-helpers"; + +let savedEnv: Record; +beforeAll(() => { + savedEnv = clearProxyEnv(); +}); +afterAll(() => { + restoreProxyEnv(savedEnv); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Early server reply during upload: the origin responds before the +// request body is fully written. The client must stop uploading, surface +// the response, and NOT pool a tunnel that still has unflushed write_buffer +// bytes (the `tunnel_poolable` gate). +// ───────────────────────────────────────────────────────────────────────────── + +describe("early reply during upload", () => { + async function makeEarlyReplyOrigin(status: number, after: number, withTls: boolean) { + const handler = (sock: net.Socket) => { + sock.on("error", () => {}); + let got = 0; + let replied = false; + sock.on("data", chunk => { + got += chunk.length; + if (!replied && got >= after) { + replied = true; + const reply = `HTTP/1.1 ${status} Nope\r\nContent-Length: 5\r\nConnection: close\r\n\r\nearly`; + sock.write(reply, () => sock.end()); + } + }); + }; + const server = withTls + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handler) + : net.createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as net.AddressInfo).port; + return { + url: `${withTls ? "https" : "http"}://localhost:${port}`, + port, + close: () => server.close(), + [Symbol.asyncDispose]: async () => server.close(), + }; + } + + for (const { proxyTls, originTls, status } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + status: [413, 400, 500] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, ${status} after 1KB of a 1MB upload`, + async () => { + await using origin = await makeEarlyReplyOrigin(status, 1024, originTls); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + method: "POST", + body: Buffer.alloc(1024 * 1024, "u"), + proxy: proxy.url, + keepalive: true, + tls: laxTls, + signal: AbortSignal.timeout(15_000), + }); + expect(await res.text()).toBe("early"); + expect(res.status).toBe(status); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Multi-hop redirect chains through proxy: up to 5 hops mixing http and +// https origins. Every hop goes through the proxy; the tunnel is torn +// down and re-CONNECTed on every scheme change. +// ───────────────────────────────────────────────────────────────────────────── + +describe("multi-hop redirect through proxy", () => { + for (const { proxyTls, chain } of cartesian({ + proxyTls: [false, true] as const, + chain: [ + [false, false, false], + [true, true, true], + [false, true, false, true], + [true, false, true, false, true], + ] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy, ${chain.length}-hop [${chain.map(t => (t ? "s" : "h")).join("→")}]`, + async () => { + const origins: Array<{ close: () => void; url: string }> = []; + // Build chain back-to-front. + let next: string | undefined; + const finalBody = `final-${chain.length}`; + for (let i = chain.length - 1; i >= 0; i--) { + const o = await createAdversarialOrigin({ + tls: chain[i], + ...(next ? { redirectTo: next } : { body: finalBody }), + }); + origins.push({ close: () => o.close(), url: o.url }); + next = o.url; + } + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + try { + const res = await fetch(next!, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe(finalBody); + expect(res.status).toBe(200); + // Every hop went through the proxy. + expect(proxy.connections.length).toBe(chain.length); + // Each hop's method matches its scheme. + for (let i = 0; i < chain.length; i++) { + expect(proxy.connections[i].method).toBe(chain[i] ? "CONNECT" : "GET"); + } + } finally { + for (const o of origins) o.close(); + } + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Large response header values through the tunnel. +// ───────────────────────────────────────────────────────────────────────────── + +describe("large response headers through tunnel", () => { + for (const { proxyTls, originTls, size } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + size: [512, 4096, 16 * 1024] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, ${size}B header value`, + async () => { + const bigValue = Buffer.alloc(size, "H").toString("latin1"); + await using origin = await createAdversarialOrigin({ + tls: originTls, + body: "big", + headers: { "X-Big": bigValue }, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe("big"); + expect(res.headers.get("x-big")).toBe(bigValue); + expect(res.status).toBe(200); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Large request header values through the tunnel. +// ───────────────────────────────────────────────────────────────────────────── + +describe("large request headers through tunnel", () => { + for (const { proxyTls, originTls, size } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + size: [512, 4096] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, request header ${size}B`, + async () => { + const bigValue = Buffer.alloc(size, "Q").toString("latin1"); + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + headers: { "X-Big-Req": bigValue }, + }); + expect(res.status).toBe(200); + expect(origin.requests[0].headers["x-big-req"]).toBe(bigValue); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 1xx informational responses through the tunnel: the client must consume +// them and keep waiting for the final response. +// ───────────────────────────────────────────────────────────────────────────── + +describe("1xx through tunnel", () => { + async function make1xxOrigin(withTls: boolean, informational: string) { + const handler = (sock: net.Socket) => { + sock.on("error", () => {}); + sock.once("data", () => { + sock.write(`HTTP/1.1 ${informational}\r\n\r\n`); + sock.write("HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndone"); + sock.end(); + }); + }; + const server = withTls + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handler) + : net.createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + url: `${withTls ? "https" : "http"}://localhost:${(server.address() as net.AddressInfo).port}`, + [Symbol.asyncDispose]: async () => server.close(), + }; + } + + for (const { proxyTls, originTls, info } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + info: ["100 Continue", "102 Processing", "103 Early Hints"] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, ${info} then 200`, + async () => { + await using origin = await make1xxOrigin(originTls, info); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + method: "POST", + body: "x", + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(15_000), + }); + expect(await res.text()).toBe("done"); + expect(res.status).toBe(200); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// IPv6 literal origin through proxy. Loopback `::1` must be accepted as a +// CONNECT target and in the absolute-form URL. +// ───────────────────────────────────────────────────────────────────────────── + +describe.skipIf(!isIPv6())("IPv6 literal origin through proxy", () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent(`${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}://[::1]`, async () => { + await using origin = Bun.serve({ + port: 0, + hostname: "::1", + tls: originTls ? tlsCert : undefined, + fetch: () => new Response("v6"), + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const scheme = originTls ? "https" : "http"; + const res = await fetch(`${scheme}://[::1]:${origin.port}/`, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + signal: AbortSignal.timeout(10_000), + }); + expect(await res.text()).toBe("v6"); + expect(res.status).toBe(200); + // The CONNECT target (or absolute-form URL) the client sent + // contained the bracketed IPv6 literal. + expect(proxy.connections.length).toBe(1); + expect(proxy.connections[0].target).toContain("[::1]"); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Many proxy.headers entries: the CONNECT/absolute-form request carries +// every one of them. +// ───────────────────────────────────────────────────────────────────────────── + +describe("many proxy.headers", () => { + for (const { proxyTls, originTls, count } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + count: [1, 10, 50] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin, ${count} proxy headers`, + async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "ok" }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const headers: Record = {}; + for (let i = 0; i < count; i++) headers[`X-Proxy-${i}`] = `v${i}`; + const res = await fetch(origin.url, { + proxy: { url: proxy.url, headers }, + keepalive: false, + tls: laxTls, + }); + expect(res.status).toBe(200); + const seen = proxy.connections[0].headers; + for (let i = 0; i < count; i++) { + expect(seen[`x-proxy-${i}`]).toBe(`v${i}`); + } + // For a CONNECT tunnel, proxy.headers go only in the CONNECT + // envelope; the tunneled inner request does not carry them. + // Absolute-form proxies forward the whole request head, so the + // origin sees them there — that's the proxy's forwarding, not + // the client's. + if (originTls) { + expect(origin.requests[0].headers[`x-proxy-0`]).toBeUndefined(); + } + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// `proxy: ""` is accepted and, with no ambient HTTP(S)_PROXY, goes direct. +// Note: FetchTasklet.rs documents `proxy: ""` as "explicitly no proxy", +// but the option parser (fetch.rs) treats an empty string the same as +// absent, so ambient env proxies are NOT overridden. That discrepancy is +// out of scope here; this test only covers the no-ambient case (env is +// cleared by the file-level beforeAll). +// ───────────────────────────────────────────────────────────────────────────── + +describe('proxy: "" with no ambient env', () => { + for (const originTls of [false, true] as const) { + test.concurrent(`${originTls ? "https" : "http"}-origin, proxy:"" goes direct`, async () => { + await using origin = await createAdversarialOrigin({ tls: originTls, body: "direct" }); + const res = await fetch(origin.url, { + proxy: "", + keepalive: false, + tls: laxTls, + }); + expect(await res.text()).toBe("direct"); + expect(res.status).toBe(200); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Origin responds with HTTP/1.0 (no keep-alive semantics). Through a +// tunnel, the client must still parse it correctly. +// ───────────────────────────────────────────────────────────────────────────── + +describe("HTTP/1.0 origin through tunnel", () => { + async function makeHttp10Origin(withTls: boolean) { + const handler = (sock: net.Socket) => { + sock.on("error", () => {}); + sock.once("data", () => { + sock.write("HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\nold-school"); + sock.end(); + }); + }; + const server = withTls + ? tls.createServer({ ...tlsCert, rejectUnauthorized: false }, handler) + : net.createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + url: `${withTls ? "https" : "http"}://localhost:${(server.address() as net.AddressInfo).port}`, + [Symbol.asyncDispose]: async () => server.close(), + }; + } + + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin HTTP/1.0`, + async () => { + await using origin = await makeHttp10Origin(originTls); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(await res.text()).toBe("old-school"); + expect(res.status).toBe(200); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// redirect: "manual" through proxy: the 3xx is surfaced as-is; the proxy +// sees exactly one hop. +// ───────────────────────────────────────────────────────────────────────────── + +describe('redirect: "manual" through proxy', () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin 302 manual`, + async () => { + await using target = await createAdversarialOrigin({ tls: originTls, body: "should-not-reach" }); + await using origin = await createAdversarialOrigin({ tls: originTls, redirectTo: target.url }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + redirect: "manual", + }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(target.url); + expect(proxy.connections.length).toBe(1); + expect(target.requests.length).toBe(0); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// redirect: "error" through proxy: the 3xx becomes a rejection. +// ───────────────────────────────────────────────────────────────────────────── + +describe('redirect: "error" through proxy', () => { + for (const { proxyTls, originTls } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin 302 error`, + async () => { + await using origin = await createAdversarialOrigin({ + tls: originTls, + redirectTo: "http://never-reached.invalid/", + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + await expect( + fetch(origin.url, { + proxy: proxy.url, + keepalive: false, + tls: laxTls, + redirect: "error", + }), + ).rejects.toThrow(); + expect(proxy.connections.length).toBe(1); + }, + ); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Response body consumed as .blob() / .json() / .bytes() through tunnel. +// ───────────────────────────────────────────────────────────────────────────── + +describe("response body consumers through tunnel", () => { + for (const { proxyTls, originTls, consumer } of cartesian({ + proxyTls: [false, true] as const, + originTls: [false, true] as const, + consumer: ["text", "arrayBuffer", "bytes", "blob", "json"] as const, + })) { + test.concurrent( + `${proxyTls ? "https" : "http"}-proxy → ${originTls ? "https" : "http"}-origin .${consumer}()`, + async () => { + const payload = consumer === "json" ? '{"k":"v","n":42}' : makeBody(4096, "C"); + await using origin = await createAdversarialOrigin({ + tls: originTls, + body: payload, + headers: consumer === "json" ? { "Content-Type": "application/json" } : {}, + }); + await using proxy = await createAdversarialProxy({ tls: proxyTls }); + const res = await fetch(origin.url, { proxy: proxy.url, keepalive: false, tls: laxTls }); + expect(res.status).toBe(200); + switch (consumer) { + case "text": + expect(await res.text()).toBe(payload); + break; + case "arrayBuffer": + expect(new TextDecoder().decode(await res.arrayBuffer())).toBe(payload); + break; + case "bytes": + expect(new TextDecoder().decode(await res.bytes())).toBe(payload); + break; + case "blob": { + const b = await res.blob(); + expect(await b.text()).toBe(payload); + break; + } + case "json": + expect(await res.json()).toEqual({ k: "v", n: 42 }); + break; + } + }, + ); + } +});