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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 14:38:04 +01:00
1 parent 213f2247b4
commit 0da35ec6f8
11 files changed
+204 -293

No files matched your search

@@ -11,6 +11,8 @@ declare global {
/** Negative control only: the old design, a same-origin blob worker in the page. */
stcRunPlain(code: string, tests: string, timeoutMs: number): Promise<unknown>;
stcRenderLesson(markdown: string, hintBody: string): Promise<string>;
/** Sets unsanitized HTML with innerHTML (bypasses the sanitizer) to test the CSP layer on its own. */
stcRawHtml(html: string): Promise<number>;
stcReady: boolean;
}
}
@@ -36,4 +38,15 @@ window.stcRenderLesson = async (markdown, hintBody) => {
return el.innerHTML;
};
window.stcRawHtml = async (html) => {
let violations = 0;
document.addEventListener("securitypolicyviolation", () => { violations++; });
const el = document.createElement("div");
el.innerHTML = html;
document.body.append(el);
for (const a of el.querySelectorAll("a, summary, img")) { try { a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) { /* ignore */ } }
await new Promise((r) => setTimeout(r, 600));
return violations;
};
window.stcReady = true;
@@ -0,0 +1,12 @@
// Run by csp.test.ts in a subprocess (bundling an HTML import inside a full bun test run is unreliable):
// serves apps/web/index.html the way main.ts does, as a plain Bun HTML import, and prints the document.
import indexHtml from "../../../../apps/web/index.html";
const development = process.argv[2] === "dev";
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, routes: { "/*": indexHtml }, development });
try {
const r = await fetch(`http://127.0.0.1:${server.port}/c/sample/m1-intro`);
console.log(JSON.stringify({ status: r.status, type: r.headers.get("content-type"), html: await r.text() }));
} finally {
server.stop(true);
}
@@ -1,77 +0,0 @@
// 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));