From 213f2247b428e8534d08c3fb86e95d0d858bd678 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 14:27:56 +0100 Subject: [PATCH] fix(learner): SPA proxy forwards no credentials and never goes through HTTP_PROXY spaWithCsp proxies GET and HEAD only (405 otherwise, no body buffering) and forwards only accept, if-none-match, if-modified-since and range. The internal server listens on a Unix socket in a private 0700 temp dir. Bun 1.3.14 ignores proxy: false for this hop, and over a socket with a proxy set it sends an absolute-form target that breaks asset routing, so localhost, 127.0.0.1 and ::1 are appended to NO_PROXY/no_proxy (existing entries kept). accept-encoding is no longer forced; fetch decompresses and the encoding headers are dropped. Test: in a subprocess with HTTP(S)_PROXY and ALL_PROXY set to a dead proxy, the SPA and an asset load with the CSP, POST gets 405, and the upstream sees no Cookie, Authorization or X-Forwarded-For. Co-Authored-By: Claude Opus 5.5 --- packages/feature-learner/src/server/spa.ts | 38 ++++++--- .../test/e2e/spa-proxy-check.ts | 77 +++++++++++++++++++ packages/feature-learner/test/spa.test.ts | 22 ++++++ 3 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 packages/feature-learner/test/e2e/spa-proxy-check.ts diff --git a/packages/feature-learner/src/server/spa.ts b/packages/feature-learner/src/server/spa.ts index 41342d5..9b774ac 100644 --- a/packages/feature-learner/src/server/spa.ts +++ b/packages/feature-learner/src/server/spa.ts @@ -1,3 +1,6 @@ +import { chmodSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { HTMLBundle } from "bun"; /** @@ -36,30 +39,41 @@ export function inlineScriptHashes(html: string): string[] { /** * Serves the SPA (`index.html` bundle) with a CSP header. Bun.serve cannot add headers to an HTML import route, - * so the bundle is served by an internal server bound to 127.0.0.1 on a random port, and these routes proxy to - * it, adding the header to HTML responses. Phase 2 can replace this once core sets the CSP globally + * so the bundle is served by an internal server on a private Unix socket, and these routes proxy to it (GET and + * HEAD only, a few cache/range headers only, never cookies), adding the header to HTML responses. Phase 2 can replace this once core sets the CSP globally * (docs/contract-requests/learner.md ยง5). */ export function spaWithCsp(indexHtml: HTMLBundle | ((req: Request) => Response | Promise), opts: { development: boolean }) { + // Bun 1.3.14 sends this hop through HTTP_PROXY even with `proxy: false`, and over a Unix socket it then uses an + // absolute-form request target that breaks asset routing. Loopback never belongs behind a proxy, so the loopback + // names are appended to NO_PROXY (existing entries are kept). The socket lives in a private (0700) temp dir. + for (const k of ["NO_PROXY", "no_proxy"]) { + const have = (process.env[k] ?? "").split(",").map((x) => x.trim()).filter(Boolean); + process.env[k] = [...new Set([...have, "localhost", "127.0.0.1", "::1"])].join(","); + } + const dir = mkdtempSync(join(tmpdir(), "stc-spa-")); + chmodSync(dir, 0o700); + const socket = join(dir, "spa.sock"); const inner = Bun.serve({ - hostname: "127.0.0.1", - port: 0, + unix: socket, routes: { "/*": indexHtml }, // HMR needs a websocket on the public server; it is off, and the bundle still rebuilds per request in dev. development: opts.development ? { hmr: false, console: false } : false, fetch: () => new Response("Not found", { status: 404 }), }); - const base = `http://127.0.0.1:${inner.port}`; + + /** Only these request headers reach the internal server: never Cookie, Authorization or X-Forwarded-*. */ + const FORWARD = ["accept", "if-none-match", "if-modified-since", "range"]; async function handle(req: Request): Promise { + // The SPA and its assets are read-only; nothing else is proxied (and no request body is buffered). + if (req.method !== "GET" && req.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } }); const u = new URL(req.url); - const headers = new Headers(req.headers); - headers.delete("host"); - headers.set("accept-encoding", "identity"); - const r = await fetch(base + u.pathname + u.search, { - method: req.method, headers, body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(), redirect: "manual", - }); + const headers = new Headers(); + for (const h of FORWARD) { const v = req.headers.get(h); if (v !== null) headers.set(h, v); } + const r = await fetch(`http://localhost${u.pathname}${u.search}`, { method: req.method, headers, redirect: "manual", unix: socket, proxy: false }); const out = new Headers(r.headers); + // fetch already decompressed the body, so the upstream encoding and length no longer apply. out.delete("content-encoding"); out.delete("content-length"); out.delete("transfer-encoding"); @@ -75,6 +89,6 @@ export function spaWithCsp(indexHtml: HTMLBundle | ((req: Request) => Response | return { routes: { "/*": handle } as Record Promise>, - stop: () => inner.stop(true), + stop: () => { inner.stop(true); rmSync(dir, { recursive: true, force: true }); }, }; } diff --git a/packages/feature-learner/test/e2e/spa-proxy-check.ts b/packages/feature-learner/test/e2e/spa-proxy-check.ts new file mode 100644 index 0000000..8e91deb --- /dev/null +++ b/packages/feature-learner/test/e2e/spa-proxy-check.ts @@ -0,0 +1,77 @@ +// Run by spa.test.ts in a subprocess with HTTP_PROXY/ALL_PROXY pointing at an unreachable proxy. +import { spaWithCsp } from "../../src/server/spa"; + +/** + * The test client speaks raw HTTP/1.1 over Bun.connect: fetch and Bun's node:http both honour HTTP_PROXY, and + * only the code under test should meet the proxy. + */ +async function get(url: string, method = "GET", headers: Record = {}): Promise<{ status: number; headers: Record; body: string }> { + const u = new URL(url); + let raw = ""; + await new Promise((resolve, reject) => { + Bun.connect({ + hostname: u.hostname, port: Number(u.port), + socket: { + open(sock) { + const body = method === "POST" ? "x" : ""; + const lines = [`${method} ${u.pathname}${u.search} HTTP/1.1`, `Host: ${u.host}`, "Connection: close", ...Object.entries(headers).map(([k, v]) => `${k}: ${v}`)]; + if (body) lines.push(`Content-Length: ${body.length}`); + sock.write(`${lines.join("\r\n")}\r\n\r\n${body}`); + }, + data(sock, d) { + raw += new TextDecoder().decode(d); + // Bun keeps the connection open despite "Connection: close"; stop once the whole response is here. + const end = raw.indexOf("\r\n\r\n"); + if (end < 0) return; + const len = /\r\ncontent-length:\s*(\d+)/i.exec(raw.slice(0, end)); + const done = len ? raw.length - end - 4 >= Number(len[1]) : /\r\n0\r\n\r\n$/.test(raw); + if (done) { sock.end(); resolve(); } + }, + close() { resolve(); }, + error(_s, e) { reject(e); }, + }, + }).catch(reject); + }); + const [head, ...rest] = raw.split("\r\n\r\n"); + const [statusLine, ...hl] = head!.split("\r\n"); + const hs = Object.fromEntries(hl.map((l) => [l.slice(0, l.indexOf(":")).toLowerCase(), l.slice(l.indexOf(":") + 1).trim()])); + let body = rest.join("\r\n\r\n"); + if (hs["transfer-encoding"] === "chunked") { + let out = "", i = 0; + for (;;) { const nl = body.indexOf("\r\n", i); const n = parseInt(body.slice(i, nl), 16); if (!n) break; out += body.slice(nl + 2, nl + 2 + n); i = nl + 2 + n + 2; } + body = out; + } + return { status: Number(statusLine!.split(" ")[1]), headers: hs, body }; +} + +// Control, before spaWithCsp touches NO_PROXY: a plain loopback fetch in this process goes to the env proxy. +const plain = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("direct") }); +const controlViaEnvProxy = await fetch(`http://127.0.0.1:${plain.port}/`, { signal: AbortSignal.timeout(2000) }).then((r) => `status ${r.status}`, (e) => `error ${(e as Error).name}`); +plain.stop(true); + +const seen: Record[] = []; +const DOC = '
'; +const spa = spaWithCsp((req) => { + if (new URL(req.url).pathname === "/app.js") return new Response("console.log(1)", { headers: { "Content-Type": "text/javascript" } }); + seen.push({ cookie: req.headers.get("cookie"), authorization: req.headers.get("authorization"), xff: req.headers.get("x-forwarded-for"), accept: req.headers.get("accept") }); + return new Response(DOC, { headers: { "Content-Type": "text/html;charset=utf-8" } }); +}, { development: false }); +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, routes: spa.routes }); +const url = `http://127.0.0.1:${server.port}/c/sample`; +const out: Record = {}; +try { + out.controlViaEnvProxy = controlViaEnvProxy; + const r = await get(url, "GET", { cookie: "better-auth.session_token=secret", authorization: "Bearer secret", "x-forwarded-for": "1.2.3.4", accept: "text/html" }); + out.status = r.status; + out.csp = r.headers["content-security-policy"]; + out.body = r.body; + out.post = (await get(url, "POST")).status; + const asset = await get(`http://127.0.0.1:${server.port}/app.js`); + out.asset = `${asset.status} ${asset.headers["content-type"]} ${asset.body}`; + out.noProxy = process.env.NO_PROXY; + out.seen = seen; +} finally { + server.stop(true); + spa.stop(); +} +console.log(JSON.stringify(out)); diff --git a/packages/feature-learner/test/spa.test.ts b/packages/feature-learner/test/spa.test.ts index 7e78e56..2e9bd2a 100644 --- a/packages/feature-learner/test/spa.test.ts +++ b/packages/feature-learner/test/spa.test.ts @@ -63,3 +63,25 @@ test("the runner frame is served sandboxed, with no connections and only its own expect(scriptSrc(csp)).toMatch(/^script-src 'sha256-[A-Za-z0-9+/=]+' blob:$/); await db.close(); }); + +test("the SPA proxy ignores HTTP_PROXY/ALL_PROXY and forwards no credentials", () => { + const dead = "http://127.0.0.1:9"; // nothing listens on the discard port + const p = Bun.spawnSync([process.execPath, new URL("./e2e/spa-proxy-check.ts", import.meta.url).pathname], { + env: { ...process.env, HTTP_PROXY: dead, http_proxy: dead, HTTPS_PROXY: dead, https_proxy: dead, ALL_PROXY: dead, all_proxy: dead, NO_PROXY: "corp.example", no_proxy: "" }, + stdout: "pipe", stderr: "pipe", + }); + expect(p.exitCode).toBe(0); + const out = JSON.parse(p.stdout.toString().trim().split("\n").pop()!); + // The environment proxy really is in effect for plain fetches in that process... + expect(out.controlViaEnvProxy).not.toBe("status 200"); + // ...but the SPA still loads through spaWithCsp, with its CSP. + expect(out.status).toBe(200); + expect(out.body).toContain('
'); + expect(out.csp).toContain("script-src 'self'"); + expect(out.post).toBe(405); + // Assets come through too (with a proxy in the env, Bun's socket fetch would otherwise hit the HTML fallback). + expect(out.asset).toBe("200 text/javascript console.log(1)"); + expect(out.noProxy).toBe("corp.example,localhost,127.0.0.1,::1"); + // The upstream saw only the allowlisted headers: no Cookie, Authorization or X-Forwarded-*. + expect(out.seen).toEqual([{ cookie: null, authorization: null, xff: null, accept: "text/html" }]); +}, 30_000);