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:
co-authored by
Claude Opus 5.5
parent
c7b9e1f98e
commit
213f2247b4
@@ -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 }); },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user