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]>
66 lines
3.3 KiB
TypeScript
66 lines
3.3 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { inlineScriptHashes, spaCsp, spaWithCsp } from "../src/server/spa";
|
|
import { RUNNER_FRAME_CSP, RUNNER_FRAME_PATH } from "../src/server/runnerFrame";
|
|
import { connect } from "@stc/db";
|
|
import { runMigrations } from "../src/server/migrate";
|
|
import { makeApp } from "./app";
|
|
|
|
const scriptSrc = (csp: string) => csp.split(";").map((d) => d.trim()).find((d) => d.startsWith("script-src"))!;
|
|
|
|
test("the SPA CSP allows scripts from 'self' only, never inline", () => {
|
|
const s = scriptSrc(spaCsp());
|
|
expect(s).toBe("script-src 'self'");
|
|
expect(spaCsp()).toContain("object-src 'none'");
|
|
expect(spaCsp()).toContain("frame-ancestors 'self'");
|
|
expect(inlineScriptHashes('<script src="/a.js"></script><script>x()</script>')).toEqual([
|
|
`sha256-${new Bun.CryptoHasher("sha256").update("x()").digest("base64")}`,
|
|
]);
|
|
});
|
|
|
|
// A stand-in for the bundled index.html (bundling inside bun test is unreliable); the real bundle is checked
|
|
// against the dev server (see wp-learner-report.md, fix round 1).
|
|
const DOC = '<!doctype html><html><head><script type="module" src="/_bun/client/app.js"></script><script>helper()</script></head><body><div id="root"></div></body></html>';
|
|
const upstream = (req: Request) => new URL(req.url).pathname.endsWith(".js")
|
|
? new Response("console.log(1)", { headers: { "Content-Type": "text/javascript" } })
|
|
: new Response(DOC, { headers: { "Content-Type": "text/html;charset=utf-8" } });
|
|
|
|
for (const development of [false, true]) {
|
|
test(`the SPA document is served with the CSP header (development: ${development})`, async () => {
|
|
const spa = spaWithCsp(upstream, { development });
|
|
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, routes: { "/api/x": () => new Response("api"), ...spa.routes } });
|
|
try {
|
|
const r = await fetch(`http://127.0.0.1:${server.port}/c/sample/m1-intro`);
|
|
expect(r.status).toBe(200);
|
|
const csp = r.headers.get("content-security-policy")!;
|
|
expect(await r.text()).toBe(DOC);
|
|
const s = scriptSrc(csp);
|
|
expect(s).not.toContain("unsafe-inline");
|
|
expect(s).not.toContain("unsafe-eval");
|
|
// Production never allows inline script; development allows only the exact injected helper, by hash.
|
|
expect(s).toBe(development ? `script-src 'self' '${inlineScriptHashes(DOC)[0]}'` : "script-src 'self'");
|
|
const asset = await fetch(`http://127.0.0.1:${server.port}/_bun/client/app.js`);
|
|
expect(asset.status).toBe(200);
|
|
expect(asset.headers.get("content-security-policy")).toBeNull();
|
|
expect(await (await fetch(`http://127.0.0.1:${server.port}/api/x`)).text()).toBe("api");
|
|
} finally {
|
|
server.stop(true);
|
|
spa.stop();
|
|
}
|
|
});
|
|
}
|
|
|
|
test("the runner frame is served sandboxed, with no connections and only its own script", async () => {
|
|
const db = await connect("sqlite://:memory:");
|
|
await runMigrations(db);
|
|
const { call } = await makeApp(db);
|
|
const r = await call("GET", RUNNER_FRAME_PATH);
|
|
expect(r.status).toBe(200);
|
|
const csp = r.headers.get("content-security-policy")!;
|
|
expect(csp).toBe(RUNNER_FRAME_CSP);
|
|
expect(csp).toContain("connect-src 'none'");
|
|
expect(csp).toContain("sandbox allow-scripts");
|
|
expect(csp).not.toContain("allow-same-origin");
|
|
expect(scriptSrc(csp)).toMatch(/^script-src 'sha256-[A-Za-z0-9+/=]+' blob:$/);
|
|
await db.close();
|
|
});
|