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

@@ -6,7 +6,6 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { connect, type Db } from "@stc/db";
import { runMigrations } from "../src/server/migrate";
import { spaCsp } from "../src/server/spa";
import { makeApp, FIXTURES } from "./app";
import { Chrome, chromePath } from "./chrome";
@@ -18,6 +17,17 @@ let P = "", ORIGIN = "";
const run = chromePath ? describe : describe.skip;
/** Navigates to a harness page and waits until its bundle has loaded. */
async function openHarness(path: string) {
await chrome.goto(`${ORIGIN}${path}`, 200);
for (let i = 0; i < 100; i++) {
// The old document still has stcReady until the navigation commits, so check the location as well.
if (await chrome.eval<boolean>(`location.pathname === ${JSON.stringify(path)} && window.stcReady === true`).catch(() => false)) return;
await Bun.sleep(100);
}
throw new Error(`harness ${path} did not load`);
}
run("browser isolation (headless Chrome)", () => {
beforeAll(async () => {
// Bundled in a separate process: Bun.build inside a full bun test run is unreliable (EISDIR on node_modules).
@@ -42,15 +52,17 @@ run("browser isolation (headless Chrome)", () => {
db = await connect("sqlite://:memory:");
await runMigrations(db);
const { app: learner } = await makeApp(db, [FIXTURES]);
const page = `<!doctype html><html><head><meta charset="utf-8"><script type="module" src="/harness.js"></script></head><body><div id="root"></div></body></html>`;
// The SPA's own CSP meta tag, taken from apps/web/index.html (the policy the learner app runs under).
const meta = /<meta\s+http-equiv="Content-Security-Policy"[^>]*>/i.exec(readFileSync(join(import.meta.dir, "../../../apps/web/index.html"), "utf8"))![0];
const page = (csp: boolean) => `<!doctype html><html><head><meta charset="utf-8">${csp ? meta : ""}<script type="module" src="/harness.js"></script></head><body><div id="root"></div></body></html>`;
app = Bun.serve({
hostname: "localhost", port: 0,
fetch(req) {
const u = new URL(req.url);
// The harness page gets the same CSP as the SPA document.
if (u.pathname === "/harness.html") return new Response(page, { headers: { "Content-Type": "text/html", "Content-Security-Policy": spaCsp(), "Set-Cookie": "stc_probe=secret-session; Path=/; SameSite=Lax" } });
if (u.pathname === "/harness.html") return new Response(page(true), { headers: { "Content-Type": "text/html", "Set-Cookie": "stc_probe=secret-session; Path=/; SameSite=Lax" } });
// The same page without any CSP: the runner must isolate learner code on its own.
if (u.pathname === "/harness-nocsp.html") return new Response(page, { headers: { "Content-Type": "text/html" } });
if (u.pathname === "/harness-nocsp.html") return new Response(page(false), { headers: { "Content-Type": "text/html" } });
// Same-origin probe: a request here from learner code would carry the session cookie.
if (u.pathname.startsWith("/cookie-probe")) {
hits.push({ path: `app:${u.pathname}`, cookie: req.headers.get("cookie") });
@@ -77,7 +89,7 @@ run("browser isolation (headless Chrome)", () => {
// The SPA CSP (connect-src 'self') blocks fetches from the page itself, so the control is a top-level
// navigation. It proves that a leak would be recorded, and would carry the cookie.
await chrome.goto(`${P}/control`, 500);
await chrome.goto(`${ORIGIN}/harness.html`, 1500);
await openHarness("/harness.html");
expect(await chrome.eval<boolean>("window.stcReady === true")).toBe(true);
const c = hits.find((h) => h.path === "/control");
expect(c).toBeDefined();
@@ -138,7 +150,7 @@ run("browser isolation (headless Chrome)", () => {
let controlEnd = 0;
test("negative control: without the sandbox, a same-origin worker on a page without CSP leaks", async () => {
await chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
await openHarness("/harness-nocsp.html");
const leaked = await leaksFrom("stcRunPlain");
// Dynamic import gets through the stubs, to other origins and, with the session cookie, to the app itself.
expect(leaked.map((h) => h.path)).toContain("/code-import.js");
@@ -147,39 +159,62 @@ run("browser isolation (headless Chrome)", () => {
}, 30_000);
test("C2: learner and test code reach nothing from the sandboxed runner, even on a page without CSP", async () => {
await chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
await openHarness("/harness-nocsp.html");
expect(await leaksFrom("stcRun")).toEqual([]);
}, 30_000);
test("C2: learner and test code reach nothing from the sandboxed runner on the SPA (with its CSP)", async () => {
await chrome.goto(`${ORIGIN}/harness.html`, 1500);
await openHarness("/harness.html");
expect(await leaksFrom("stcRun")).toEqual([]);
// Only the control navigation (and the favicon request Chrome makes for it) ever carried the cookie.
// (The negative control's own leaks are excluded; they ran the old design on purpose.)
expect(hits.slice(controlEnd).filter((h) => (h.cookie ?? "").includes("stc_probe"))).toEqual([]);
}, 30_000);
test("C1: the reviewer's exploit lesson does not run script in LessonView (page without CSP)", async () => {
// No CSP here, so the sanitizer alone has to stop the script.
await chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
const beacon = `fetch('${P}/xss')`;
const md = [
"<details>\n<summary>Think first</summary>\n<img src=x onerror=\"window.__xss=1;" + beacon + "\"\n\nNext paragraph\n</details>",
"<div>\n<img src=x onerror=window.__xss=2\n\nAnother\n</div>",
"<svg onload=\"window.__xss=3\"></svg>",
"<details><summary onclick=\"window.__xss=4\">Hint 1 — nudge</summary>\n\nx\n\n</details>",
"[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) <a href=\"jav&#x61;script:window.__xss=7\">c</a>",
"<iframe src=\"javascript:parent.__xss=8\"></iframe><script>window.__xss=9</script>",
].join("\n\n");
const exploitMd = () => [
"<details>\n<summary>Think first</summary>\n<img src=x onerror=\"window.__xss=1;fetch('" + P + "/xss')\"\n\nNext paragraph\n</details>",
"<div>\n<img src=x onerror=window.__xss=2\n\nAnother\n</div>",
"<svg onload=\"window.__xss=3\"></svg>",
"<details><summary onclick=\"window.__xss=4\">Hint 1 — nudge</summary>\n\nx\n\n</details>",
"[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) <a href=\"jav&#x61;script:window.__xss=7\">c</a>",
"<iframe src=\"javascript:parent.__xss=8\"></iframe><script>window.__xss=9</script>",
].join("\n\n");
async function renderExploit(pagePath: string) {
await openHarness(pagePath);
const before = hits.length;
const html = await chrome.eval<string>(`stcRenderLesson(${JSON.stringify(md)}, ${JSON.stringify("<img src=x onerror=window.__xss=10")})`);
const html = await chrome.eval<string>(`stcRenderLesson(${JSON.stringify(exploitMd())}, ${JSON.stringify("<img src=x onerror=window.__xss=10")})`);
// Click every link and summary, as a learner would.
await chrome.eval(`(() => { for (const a of document.querySelectorAll("#root a, #root summary")) { try { a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) {} } })()`);
await Bun.sleep(800);
expect(await chrome.eval("window.__xss ?? null")).toBeNull();
return { html, xss: await chrome.eval("window.__xss ?? null"), leaked: hits.slice(before).filter((h) => h.path === "/xss") };
}
test("C1, first layer: the exploit lesson runs no script on a page without CSP (sanitizer alone)", async () => {
const r = await renderExploit("/harness-nocsp.html");
expect(r.xss).toBeNull();
expect(chrome.dialogs).toEqual([]);
expect(hits.slice(before).filter((h) => h.path === "/xss")).toEqual([]);
expect(html).toContain("Next paragraph");
expect(html).not.toMatch(/<[a-z][^>]*\son[a-z]+=/i);
expect(r.leaked).toEqual([]);
expect(r.html).toContain("Next paragraph");
expect(r.html).not.toMatch(/<[a-z][^>]*\son[a-z]+=/i);
}, 30_000);
test("C1, both layers: the exploit lesson runs no script under the SPA's meta CSP", async () => {
const r = await renderExploit("/harness.html");
expect(r.xss).toBeNull();
expect(r.leaked).toEqual([]);
expect(r.html).toContain("Next paragraph");
}, 30_000);
const RAW = `<img src=x onerror="window.__xss='raw'"><svg><animate onbegin="window.__xss='svg'" attributeName="x" dur="1s"></animate></svg><a href="javascript:void(window.__xss='href')">a</a>`;
test("C1, second layer: the meta CSP alone blocks unsanitized handlers (control: they run without it)", async () => {
await openHarness("/harness-nocsp.html");
await chrome.eval(`stcRawHtml(${JSON.stringify(RAW)})`);
expect(await chrome.eval("window.__xss ?? null")).not.toBeNull(); // the raw payload does run without a CSP
await openHarness("/harness.html");
const violations = await chrome.eval<number>(`stcRawHtml(${JSON.stringify(RAW)})`);
expect(await chrome.eval("window.__xss ?? null")).toBeNull();
expect(violations).toBeGreaterThan(0);
}, 30_000);
});