fix(learner): remove the SPA proxy; CSP as a meta tag in index.html (ruling R16)

The proxy, its internal server, Unix socket and NO_PROXY changes are gone; main.ts serves the HTML
import directly again. apps/web/index.html carries the SPA policy as <meta http-equiv=CSP> ahead of every
script (script-src 'self' plus the hash of Bun's dev-server helper, no unsafe-inline/eval; no
frame-ancestors, which meta cannot carry). Dev HMR connects under it. The runner frame keeps its header CSP.

Tests: the served index.html (prod and dev, in a subprocess) has the meta policy and every inline script
is hash-covered; the XSS browser test fails with the sanitizer disabled and passes with it, and the meta
CSP alone blocks unsanitized handlers (with a no-CSP control); no source assigns proxy env vars, and the
feature leaves them untouched at runtime. Contract request 5 asks core for frame-ancestors/X-Frame-Options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 14:38:04 +01:00
co-authored by Claude Opus 5.5
parent 213f2247b4
commit 0da35ec6f8
11 changed files with 204 additions and 293 deletions
-1
View File
@@ -1,7 +1,6 @@
// Server
export { migrationsDirs, runMigrations } from "./server/migrate";
export { learnerFeature } from "./server/feature";
export { spaWithCsp, spaCsp } from "./server/spa";
// Browser (also available without the server half as "@stc/feature-learner/web")
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
// Shared JSON shapes
@@ -1,94 +0,0 @@
import { chmodSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HTMLBundle } from "bun";
/**
* Content-Security-Policy for the SPA document. No inline script: `script-src 'self'` only (plus, in development,
* the hashes of the inline helper scripts Bun's dev server injects). Style attributes are allowed because React
* components set them; styles cannot run script. The exercise runner frame is a same-origin URL (`frame-src
* 'self'`) with its own, stricter policy, see `runnerFrame.ts`.
*/
export function spaCsp(inlineScriptHashes: string[] = []): string {
const hashes = inlineScriptHashes.map((h) => ` '${h}'`).join("");
return [
"default-src 'self'",
`script-src 'self'${hashes}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"media-src 'self' blob:",
"font-src 'self' data:",
"connect-src 'self'",
"worker-src 'self' blob:",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
].join("; ");
}
/** `sha256-…` CSP sources for every inline `<script>` (no `src`) in `html`. */
export function inlineScriptHashes(html: string): string[] {
const out: string[] = [];
for (const m of html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)) {
out.push(`sha256-${new Bun.CryptoHasher("sha256").update(m[1]!).digest("base64")}`);
}
return out;
}
/**
* 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 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({
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 }),
});
/** 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();
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");
if ((out.get("content-type") ?? "").includes("text/html")) {
const html = await r.text();
out.set("Content-Security-Policy", spaCsp(opts.development ? inlineScriptHashes(html) : []));
out.set("X-Content-Type-Options", "nosniff");
out.set("Referrer-Policy", "same-origin");
return new Response(html, { status: r.status, headers: out });
}
return new Response(r.body, { status: r.status, headers: out });
}
return {
routes: { "/*": handle } as Record<string, (req: Request) => Promise<Response>>,
stop: () => { inner.stop(true); rmSync(dir, { recursive: true, force: true }); },
};
}