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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 14:27:56 +01:00
co-authored by Claude Opus 5.5
parent c7b9e1f98e
commit 213f2247b4
3 changed files with 125 additions and 12 deletions
+26 -12
View File
@@ -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<Response>), 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<Response> {
// 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<string, (req: Request) => Promise<Response>>,
stop: () => inner.stop(true),
stop: () => { inner.stop(true); rmSync(dir, { recursive: true, force: true }); },
};
}
@@ -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<string, string> = {}): Promise<{ status: number; headers: Record<string, string>; body: string }> {
const u = new URL(url);
let raw = "";
await new Promise<void>((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<string, string | null>[] = [];
const DOC = '<!doctype html><html><head><script type="module" src="/app.js"></script></head><body><div id="root"></div></body></html>';
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<string, unknown> = {};
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));
+22
View File
@@ -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('<div id="root">');
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);