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:
co-authored by
Claude Opus 5.5
parent
061ae7a2d9
commit
c7b9e1f98e
@@ -8,10 +8,14 @@
|
||||
"@better-auth/sso": "^1.7.14",
|
||||
"better-auth": "^1.7.14",
|
||||
"marked": "^18.1.2",
|
||||
"pdfjs-dist": "^6.4.299"
|
||||
"pdfjs-dist": "^6.4.299",
|
||||
"sanitize-html": "^2"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./web": "./web/src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "^2.16.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Server
|
||||
export { migrationsDirs, runMigrations } from "./server/migrate";
|
||||
export { learnerFeature } from "./server/feature";
|
||||
export { spaWithCsp, spaCsp } from "./server/spa";
|
||||
// Browser (also available without the server half as "@stc/feature-learner/web")
|
||||
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
|
||||
// Shared JSON shapes
|
||||
|
||||
@@ -28,10 +28,14 @@ export function canEdit(user: AuthUser | null, courseId: string, idx: AccessInde
|
||||
return !!user && (user.roles.includes("owner") || user.roles.includes("author") || idx.authorOf.has(courseId));
|
||||
}
|
||||
|
||||
/** Learners see courses with a manifest that `canView` allows; owners also see broken ones. */
|
||||
/**
|
||||
* Learners see courses with a manifest that core `canView` allows; owners also see broken ones. The author input
|
||||
* is per-course authorship only, the same as core's media route, so every route applies one rule. (The global
|
||||
* `author` role grants viewing only once Phase 2 extends `canView`; contract request learner §6.)
|
||||
*/
|
||||
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
|
||||
if (!c.manifest) return isOwner(user);
|
||||
return canView(user, c, idx.acl.get(c.id) ?? [], canEdit(user, c.id, idx));
|
||||
return canView(user, c, idx.acl.get(c.id) ?? [], idx.authorOf.has(c.id));
|
||||
}
|
||||
|
||||
/** The course if this user may read it, else null (callers answer 404, never 403, so hidden courses stay hidden). */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { userDataRoutes } from "./userdata";
|
||||
import { codeRoutes } from "./code";
|
||||
import { transpileRoutes } from "./transpile";
|
||||
import { assetRoutes } from "./assets";
|
||||
import { runnerFrameRoutes } from "./runnerFrame";
|
||||
|
||||
/**
|
||||
* The learner feature: auth, library, reader, notes, progress and exercises.
|
||||
@@ -34,6 +35,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
|
||||
...codeRoutes(ctx),
|
||||
...transpileRoutes(),
|
||||
...assetRoutes(),
|
||||
...runnerFrameRoutes(),
|
||||
};
|
||||
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
|
||||
for (const methods of Object.values(routes)) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Feature } from "@stc/contracts";
|
||||
import { WORKER_SOURCE } from "../web/runner/worker-source";
|
||||
|
||||
/**
|
||||
* The exercise runner frame. The page loads it in `<iframe sandbox="allow-scripts">` (never allow-same-origin),
|
||||
* so it has an opaque origin: no cookies, no storage, no access to the page. Its own CSP allows only this one
|
||||
* inline script (by hash) and blob: scripts and workers, and no connections at all (`connect-src 'none'`), which
|
||||
* also covers dynamic import of remote URLs, Cache.add, nested workers, EventSource and WebSocket. The worker
|
||||
* inherits that policy. The page talks to the frame only with postMessage, and both sides check `event.source`.
|
||||
*/
|
||||
export const RUNNER_FRAME_PATH = "/api/learner/runner-frame";
|
||||
|
||||
export const FRAME_SCRIPT = `(() => {
|
||||
"use strict";
|
||||
const SRC = ${JSON.stringify(WORKER_SOURCE)};
|
||||
let worker = null;
|
||||
const send = (msg) => parent.postMessage(msg, "*");
|
||||
addEventListener("message", (ev) => {
|
||||
if (ev.source !== parent) return;
|
||||
const m = ev.data;
|
||||
if (!m || m.type !== "run" || worker) return;
|
||||
const url = URL.createObjectURL(new Blob([SRC], { type: "text/javascript" }));
|
||||
// A classic worker: module workers from blob: URLs fail to start in an opaque-origin frame in Chrome.
|
||||
// The worker still loads the learner and test modules with import() of blob: URLs.
|
||||
worker = new Worker(url);
|
||||
worker.onmessage = (e) => send({ type: "worker", data: e.data });
|
||||
worker.onerror = (e) => { e.preventDefault(); send({ type: "crash", message: e.message || "The exercise worker crashed" }); };
|
||||
worker.postMessage({ code: String(m.code), tests: String(m.tests) });
|
||||
});
|
||||
send({ type: "ready" });
|
||||
})();`;
|
||||
|
||||
const hash = new Bun.CryptoHasher("sha256").update(FRAME_SCRIPT).digest("base64");
|
||||
|
||||
export const RUNNER_FRAME_CSP = [
|
||||
"default-src 'none'",
|
||||
`script-src 'sha256-${hash}' blob:`,
|
||||
"worker-src blob:",
|
||||
"connect-src 'none'",
|
||||
"img-src 'none'",
|
||||
"style-src 'none'",
|
||||
"font-src 'none'",
|
||||
"media-src 'none'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
"sandbox allow-scripts",
|
||||
].join("; ");
|
||||
|
||||
const HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Exercise runner</title></head><body><script>${FRAME_SCRIPT}</script></body></html>`;
|
||||
|
||||
export function runnerFrameRoutes(): Feature["routes"] {
|
||||
return {
|
||||
[RUNNER_FRAME_PATH]: {
|
||||
GET: async () => new Response(HTML, {
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Content-Security-Policy": RUNNER_FRAME_CSP,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { HTMLBundle } from "bun";
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for the SPA document. No inline script: `script-src 'self'` only (plus, in development,
|
||||
* the hashes of the inline helper scripts Bun's dev server injects). Style attributes are allowed because React
|
||||
* components set them; styles cannot run script. The exercise runner frame is a same-origin URL (`frame-src
|
||||
* 'self'`) with its own, stricter policy, see `runnerFrame.ts`.
|
||||
*/
|
||||
export function spaCsp(inlineScriptHashes: string[] = []): string {
|
||||
const hashes = inlineScriptHashes.map((h) => ` '${h}'`).join("");
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src 'self'${hashes}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self' blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
/** `sha256-…` CSP sources for every inline `<script>` (no `src`) in `html`. */
|
||||
export function inlineScriptHashes(html: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)) {
|
||||
out.push(`sha256-${new Bun.CryptoHasher("sha256").update(m[1]!).digest("base64")}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the SPA (`index.html` bundle) with a CSP header. Bun.serve cannot add headers to an HTML import route,
|
||||
* so the bundle is served by an internal server bound to 127.0.0.1 on a random port, and these routes proxy to
|
||||
* it, adding the header to HTML responses. Phase 2 can replace this once core sets the CSP globally
|
||||
* (docs/contract-requests/learner.md §5).
|
||||
*/
|
||||
export function spaWithCsp(indexHtml: HTMLBundle | ((req: Request) => Response | Promise<Response>), opts: { development: boolean }) {
|
||||
const inner = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
routes: { "/*": indexHtml },
|
||||
// HMR needs a websocket on the public server; it is off, and the bundle still rebuilds per request in dev.
|
||||
development: opts.development ? { hmr: false, console: false } : false,
|
||||
fetch: () => new Response("Not found", { status: 404 }),
|
||||
});
|
||||
const base = `http://127.0.0.1:${inner.port}`;
|
||||
|
||||
async function handle(req: Request): Promise<Response> {
|
||||
const u = new URL(req.url);
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.set("accept-encoding", "identity");
|
||||
const r = await fetch(base + u.pathname + u.search, {
|
||||
method: req.method, headers, body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(), redirect: "manual",
|
||||
});
|
||||
const out = new Headers(r.headers);
|
||||
out.delete("content-encoding");
|
||||
out.delete("content-length");
|
||||
out.delete("transfer-encoding");
|
||||
if ((out.get("content-type") ?? "").includes("text/html")) {
|
||||
const html = await r.text();
|
||||
out.set("Content-Security-Policy", spaCsp(opts.development ? inlineScriptHashes(html) : []));
|
||||
out.set("X-Content-Type-Options", "nosniff");
|
||||
out.set("Referrer-Policy", "same-origin");
|
||||
return new Response(html, { status: r.status, headers: out });
|
||||
}
|
||||
return new Response(r.body, { status: r.status, headers: out });
|
||||
}
|
||||
|
||||
return {
|
||||
routes: { "/*": handle } as Record<string, (req: Request) => Promise<Response>>,
|
||||
stop: () => inner.stop(true),
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import { Chev } from "../icons";
|
||||
|
||||
export const RUN_TIMEOUT_MS = 3000;
|
||||
|
||||
/** Text Enter inserts at `pos`: a newline plus the indentation of the line the cursor is on. */
|
||||
export function newlineWithIndent(value: string, pos: number): string {
|
||||
const lineStart = value.lastIndexOf("\n", pos - 1) + 1;
|
||||
return "\n" + /^[ \t]*/.exec(value.slice(lineStart, pos))![0];
|
||||
}
|
||||
|
||||
export function localCommandFor(cmd: LessonPayload["localCommand"], track: string | null): string | null {
|
||||
if (!cmd) return null;
|
||||
if (typeof cmd === "string") return cmd;
|
||||
@@ -94,13 +100,14 @@ function BrowserExercise({ lesson, track, save, onSaved, runner }: {
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onScroll={(e) => { if (gutter.current) gutter.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop; }}
|
||||
onKeyDown={(e) => {
|
||||
// Tab indents inside the editor; Escape leaves it so Tab moves focus again.
|
||||
if (e.key === "Tab" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
const t = e.currentTarget;
|
||||
t.setRangeText(" ", t.selectionStart, t.selectionEnd, "end");
|
||||
setCode(t.value);
|
||||
} else if (e.key === "Escape") e.currentTarget.blur();
|
||||
// Tab and Shift+Tab move focus as everywhere else (spec 4.1). Enter keeps the previous line's indentation.
|
||||
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || e.nativeEvent.isComposing) return;
|
||||
const t = e.currentTarget;
|
||||
const insert = newlineWithIndent(t.value, t.selectionStart);
|
||||
if (insert === "\n") return;
|
||||
e.preventDefault();
|
||||
t.setRangeText(insert, t.selectionStart, t.selectionEnd, "end");
|
||||
setCode(t.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Marked, type Token, type Tokens } from "marked";
|
||||
import { slugify } from "@stc/course-format";
|
||||
import sanitize, { type IOptions as SanitizeOptions } from "sanitize-html";
|
||||
import { mediaUrl } from "../shared/types";
|
||||
|
||||
/**
|
||||
* Lesson Markdown → HTML for the reader.
|
||||
*
|
||||
* - Raw HTML is reduced to an allowlist of attribute-free tags (`details`, `summary`, `kbd`, …); every other
|
||||
* tag is shown as text, so course files cannot inject script or styles. Link and image URLs are limited to
|
||||
* - The rendered HTML goes through `sanitizeHtml` (sanitize-html allowlist). Raw HTML tags outside the allowlist
|
||||
* are shown as text, so course files cannot inject script or styles. Link and image URLs are limited to
|
||||
* http(s), mailto, in-page anchors and course-relative paths.
|
||||
* - Consecutive code blocks in two or more track languages (and their bold "**Scala**" labels) form a
|
||||
* variant group; only the chosen track's variant is kept. A group without that track keeps every variant.
|
||||
@@ -25,17 +26,54 @@ export interface RenderOptions {
|
||||
stripFirstHeading?: boolean;
|
||||
}
|
||||
|
||||
const ALLOWED = new Set(["details", "summary", "br", "kbd", "sub", "sup", "em", "strong", "b", "i", "u", "mark", "small", "code", "s", "del", "ins", "abbr", "hr", "p", "span", "ul", "ol", "li", "blockquote", "pre", "dl", "dt", "dd", "table", "thead", "tbody", "tr", "th", "td"]);
|
||||
|
||||
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
/** Keeps attribute-free allowlisted tags (and `<details open>`), escapes every other tag and comment. */
|
||||
/** Tags a lesson may contain after rendering. Everything else is escaped (shown as text). */
|
||||
export const ALLOWED_TAGS = [
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", "dl", "dt", "dd", "code", "pre", "a", "img",
|
||||
"details", "summary", "table", "thead", "tbody", "tr", "th", "td", "blockquote", "em", "strong", "b", "i",
|
||||
"s", "del", "sub", "sup", "mark", "kbd", "br", "hr", "span", "div",
|
||||
];
|
||||
const ALLOWED_SET = new Set(ALLOWED_TAGS);
|
||||
const HEADING_ATTRS = ["id"];
|
||||
|
||||
const SANITIZE: SanitizeOptions = {
|
||||
allowedTags: ALLOWED_TAGS,
|
||||
allowedAttributes: {
|
||||
h1: HEADING_ATTRS, h2: HEADING_ATTRS, h3: HEADING_ATTRS, h4: HEADING_ATTRS, h5: HEADING_ATTRS, h6: HEADING_ATTRS,
|
||||
a: ["href", "title", "target", "rel"],
|
||||
img: ["src", "alt", "title", "loading"],
|
||||
pre: ["class", "data-lang", "data-t"],
|
||||
details: ["class", "open", "data-hint", "data-group", "data-tier", "data-tiers"],
|
||||
span: ["class"], div: ["class"], ol: ["start"], th: ["align"], td: ["align"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
allowedSchemesByTag: { img: ["http", "https"] },
|
||||
allowedSchemesAppliedToAttributes: ["href", "src"],
|
||||
allowProtocolRelative: false,
|
||||
allowedClasses: { pre: ["code"], span: ["sum", "tier", "chev"], div: ["in"], details: ["fold"] },
|
||||
disallowedTagsMode: "escape",
|
||||
transformTags: {
|
||||
// Any link that opens a new window must not get a handle on this page.
|
||||
a: (tagName, attribs) => (attribs.target ? { tagName, attribs: { ...attribs, target: "_blank", rel: "noopener noreferrer" } } : { tagName, attribs }),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The security boundary for lesson HTML: a parser-based allowlist (sanitize-html, htmlparser2) over the fully
|
||||
* rendered output. Only the tags and attributes the reader needs survive; no event handlers, no style, no
|
||||
* svg/math/iframe/object/embed/form; URLs are limited to http(s), mailto (links), relative paths and anchors.
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
return html.replace(/<!--[\s\S]*?-->|<\/?[A-Za-z][^>]*>|<(?![A-Za-z/!])/g, (tag) => {
|
||||
const m = /^<(\/?)([A-Za-z][A-Za-z0-9]*)\s*(open)?\s*(\/?)>$/.exec(tag);
|
||||
if (m && ALLOWED.has(m[2]!.toLowerCase()) && (!m[3] || m[2]!.toLowerCase() === "details")) return tag.toLowerCase().replace(/\s*\/>$/, ">");
|
||||
return escapeHtml(tag);
|
||||
});
|
||||
return sanitize(html, SANITIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes the `<` of raw HTML that is not an exact allowlisted lowercase tag (and of comments), so text such as
|
||||
* `IO<A>` or `<Stream>` in prose stays visible. This only ever escapes; `sanitizeHtml` is still applied after.
|
||||
*/
|
||||
function escapeForeignTags(html: string): string {
|
||||
return html.replace(/<(?=!|\/?([A-Za-z][\w-]*))(\/?)?/g, (m, name: string | undefined) => (name && ALLOWED_SET.has(name) ? m : `<${m.slice(1)}`));
|
||||
}
|
||||
|
||||
const LANG_ALIASES: Record<string, string[]> = {
|
||||
@@ -101,7 +139,7 @@ export function selectTrack(tokens: Token[], tracks: { id: string; label: string
|
||||
}
|
||||
|
||||
const HINT = /\b(hint|nudge|stronger|near[- ]?solution|solution)\b/i;
|
||||
const OPEN_DETAILS = /^\s*<details(\s+open)?\s*>\s*(?:<summary>([\s\S]*?)<\/summary>)?/i;
|
||||
const OPEN_DETAILS = /^\s*<details(\s+open)?\s*>\s*(?:<summary(?:\s[^>]*)?>([\s\S]*?)<\/summary>)?/i;
|
||||
|
||||
interface FoldPlan { hint: boolean; group: string | null; tier: number; tiers: number }
|
||||
|
||||
@@ -153,7 +191,7 @@ function planFolds(tokens: Token[]): Map<Token, FoldPlan> {
|
||||
return plans;
|
||||
}
|
||||
|
||||
const CHEVRON = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 2l3 3-3 3"/></svg>';
|
||||
const CHEVRON = '<span class="chev" aria-hidden="true"></span>';
|
||||
|
||||
function dirOf(file: string | null): string {
|
||||
if (!file) return "";
|
||||
@@ -233,21 +271,21 @@ export function renderLessonHtml(md: string, opts: RenderOptions): string {
|
||||
let text = token.text;
|
||||
if (plan) {
|
||||
const m = OPEN_DETAILS.exec(text)!;
|
||||
const summary = sanitizeHtml(m[2] ?? (plan.hint ? "Hint" : "Details"));
|
||||
const summary = escapeForeignTags(m[2] ?? (plan.hint ? "Hint" : "Details"));
|
||||
const attrs = plan.hint
|
||||
? ` data-hint="1"${plan.group ? ` data-group="${plan.group}" data-tier="${plan.tier}" data-tiers="${plan.tiers}"` : ""}`
|
||||
: "";
|
||||
const tier = plan.group ? `<span class="tier">${plan.tier} of ${plan.tiers}</span>` : "";
|
||||
const head = `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${summary}</span>${tier}</summary><div class="in">`;
|
||||
text = head + sanitizeHtml(text.slice(m[0].length)).replace(/<\/details>/gi, "</div></details>");
|
||||
text = head + escapeForeignTags(text.slice(m[0].length)).replace(/<\/details>/gi, "</div></details>");
|
||||
return text;
|
||||
}
|
||||
if (/^\s*<\/details>/i.test(text)) return sanitizeHtml(text).replace(/<\/details>/i, "</div></details>");
|
||||
return sanitizeHtml(text);
|
||||
if (/^\s*<\/details>/i.test(text)) return escapeForeignTags(text).replace(/<\/details>/i, "</div></details>");
|
||||
return escapeForeignTags(text);
|
||||
},
|
||||
};
|
||||
marked.use({ renderer: renderer as never });
|
||||
return marked.parser(tokens) as string;
|
||||
return sanitizeHtml(marked.parser(tokens) as string);
|
||||
}
|
||||
|
||||
const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s);
|
||||
@@ -255,10 +293,10 @@ const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s);
|
||||
/** Exercise hints (already matched by the server) as one tier group of folds. */
|
||||
export function renderHintsHtml(hints: { label: string; body: string }[], opts: RenderOptions): string {
|
||||
const n = hints.length;
|
||||
return hints.map((h, i) => {
|
||||
return sanitizeHtml(hints.map((h, i) => {
|
||||
const label = h.label ? cap(h.label) : `Hint ${i + 1}`;
|
||||
const tier = n > 1 ? `<span class="tier">${i + 1} of ${n}</span>` : "";
|
||||
const attrs = n > 1 ? ` data-hint="1" data-group="ex" data-tier="${i + 1}" data-tiers="${n}"` : ' data-hint="1"';
|
||||
return `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${escapeHtml(label)}</span>${tier}</summary><div class="in">${renderLessonHtml(h.body, opts)}</div></details>`;
|
||||
}).join("\n");
|
||||
}).join("\n"));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
import type { ExerciseLanguage, ExerciseRunner, TestResult } from "@stc/contracts";
|
||||
import { WORKER_SOURCE } from "./worker-source";
|
||||
|
||||
/** Where the worker runs. The browser default is a sandboxed iframe (`frameHost`); tests may pass a plain Worker. */
|
||||
export interface RunnerHost {
|
||||
onmessage: ((ev: { data: unknown }) => void) | null;
|
||||
onerror: ((ev: { message: string; preventDefault?: () => void }) => void) | null;
|
||||
postMessage(data: { code: string; tests: string }): void;
|
||||
terminate(): void;
|
||||
}
|
||||
|
||||
export interface RunnerOptions {
|
||||
/** TypeScript → JavaScript. Defaults to the server's `POST /api/learner/transpile` (Bun.Transpiler). */
|
||||
transpile?: (code: string) => Promise<string>;
|
||||
createWorker?: () => Worker;
|
||||
createWorker?: () => RunnerHost | Worker;
|
||||
}
|
||||
|
||||
export const RUNNER_FRAME_URL = "/api/learner/runner-frame";
|
||||
|
||||
/**
|
||||
* Runs the exercise worker inside `<iframe sandbox="allow-scripts">` (opaque origin, no cookies, CSP with
|
||||
* connect-src 'none'; see src/server/runnerFrame.ts). Messages are accepted only from that frame's window, and
|
||||
* `terminate()` removes the frame, which ends the worker.
|
||||
*/
|
||||
export function frameHost(url = RUNNER_FRAME_URL): RunnerHost {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.setAttribute("sandbox", "allow-scripts");
|
||||
iframe.setAttribute("aria-hidden", "true");
|
||||
iframe.tabIndex = -1;
|
||||
iframe.title = "Exercise runner";
|
||||
iframe.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden";
|
||||
iframe.src = url;
|
||||
let ready = false;
|
||||
const queue: { code: string; tests: string }[] = [];
|
||||
const onMsg = (ev: MessageEvent) => {
|
||||
// Only the frame's own window, and only while it is sandboxed (an opaque origin serialises as "null").
|
||||
if (ev.source !== iframe.contentWindow || ev.origin !== "null") return;
|
||||
const m = ev.data as { type?: string; data?: unknown; message?: string } | null;
|
||||
if (!m) return;
|
||||
if (m.type === "ready") {
|
||||
ready = true;
|
||||
for (const d of queue.splice(0)) host.postMessage(d);
|
||||
} else if (m.type === "worker") host.onmessage?.({ data: m.data });
|
||||
else if (m.type === "crash") host.onerror?.({ message: String(m.message ?? "The exercise worker crashed") });
|
||||
};
|
||||
const host: RunnerHost = {
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
postMessage(d) {
|
||||
if (ready) iframe.contentWindow?.postMessage({ type: "run", code: d.code, tests: d.tests }, "*");
|
||||
else queue.push(d);
|
||||
},
|
||||
terminate() {
|
||||
window.removeEventListener("message", onMsg);
|
||||
iframe.remove();
|
||||
},
|
||||
};
|
||||
window.addEventListener("message", onMsg);
|
||||
document.body.append(iframe);
|
||||
return host;
|
||||
}
|
||||
|
||||
export async function serverTranspile(code: string): Promise<string> {
|
||||
@@ -14,10 +67,14 @@ export async function serverTranspile(code: string): Promise<string> {
|
||||
return b.code;
|
||||
}
|
||||
|
||||
const defaultWorker = () => new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" });
|
||||
/** In a browser: the sandboxed frame. Elsewhere (bun test): a plain module worker. */
|
||||
const defaultWorker = (): RunnerHost | Worker => (typeof document !== "undefined"
|
||||
? frameHost()
|
||||
: new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" }));
|
||||
|
||||
/**
|
||||
* Runs learner code against a tests module in a fresh Web Worker (never in the page), then terminates it.
|
||||
* Runs learner code against a tests module in a fresh Web Worker (never in the page; in a browser, inside a
|
||||
* sandboxed iframe), then terminates it.
|
||||
* When `timeoutMs` passes, the worker is killed and every test without a result is reported as timed out.
|
||||
*/
|
||||
export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOptions = {}): ExerciseRunner {
|
||||
@@ -50,7 +107,7 @@ export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOpti
|
||||
const rest = pending(`Timed out after ${timeoutMs} ms. Is there an endless loop?`);
|
||||
finish(rest.length ? rest : [{ name: "Timed out", passed: false, message: `No result after ${timeoutMs} ms` }]);
|
||||
}, timeoutMs);
|
||||
worker.onmessage = (ev: MessageEvent) => {
|
||||
worker.onmessage = (ev: { data: unknown }) => {
|
||||
const m = ev.data as { type: string; names?: string[]; result?: TestResult; stage?: string; message?: string };
|
||||
if (m.type === "tests") names = m.names ?? [];
|
||||
else if (m.type === "result" && m.result) results.push(m.result);
|
||||
@@ -62,7 +119,7 @@ export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOpti
|
||||
} else finish([{ name: "The tests did not load", passed: false, message: m.message }]);
|
||||
}
|
||||
};
|
||||
worker.onerror = (ev: ErrorEvent) => {
|
||||
worker.onerror = (ev: { message: string; preventDefault?: () => void }) => {
|
||||
ev.preventDefault?.();
|
||||
finish(pending(ev.message || "The exercise worker crashed"));
|
||||
};
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
*/
|
||||
export const WORKER_SOURCE = String.raw`
|
||||
"use strict";
|
||||
// Second layer only: the worker runs inside a sandboxed, opaque-origin iframe whose CSP has connect-src 'none'
|
||||
// and allows scripts only from blob: (runnerFrame.ts). These stubs give a clear message for the common cases.
|
||||
const blocked = () => { throw new Error("Network access is turned off while exercises run"); };
|
||||
for (const k of ["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "importScripts"]) {
|
||||
for (const k of ["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "importScripts", "Worker", "SharedWorker"]) {
|
||||
try { Object.defineProperty(self, k, { value: blocked, configurable: false, writable: false }); } catch (_) {}
|
||||
}
|
||||
try { Object.defineProperty(self, "caches", { value: undefined, configurable: false, writable: false }); } catch (_) {}
|
||||
|
||||
class AssertionError extends Error {
|
||||
constructor(message, expected, actual) { super(message); this.name = "AssertionError"; this.expected = expected; this.actual = actual; this.hasValues = arguments.length > 1; }
|
||||
|
||||
@@ -127,7 +127,9 @@ export const learnerCss = String.raw`
|
||||
.lr details.fold > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 12px 2px; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
|
||||
.lr details.fold > summary::-webkit-details-marker { display: none; }
|
||||
.lr details.fold > summary svg { color: var(--faint); transition: transform var(--fast); flex: none; }
|
||||
.lr details.fold[open] > summary svg { transform: rotate(90deg); }
|
||||
.lr details.fold[open] > summary svg, .lr details.fold[open] > summary .chev { transform: rotate(90deg); }
|
||||
.lr details.fold > summary .chev { flex: none; width: 10px; height: 10px; display: grid; place-items: center; color: var(--faint); transition: transform var(--fast); }
|
||||
.lr details.fold > summary .chev::before { content: ""; width: 5px; height: 5px; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: translateX(-1px) rotate(-45deg); }
|
||||
.lr details.fold > summary:hover { color: var(--head); }
|
||||
.lr details.fold > summary .tier { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: 400; white-space: nowrap; }
|
||||
.lr details.fold[data-wait] > summary { color: var(--faint); cursor: default; }
|
||||
|
||||
@@ -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=\"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);
|
||||
});
|
||||
@@ -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("<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>");
|
||||
expect(sanitizeHtml('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe('<script>alert(1)</script><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(" 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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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(/:/gi, ":").replace(/&tab;/gi, "\t").replace(/&newline;/gi, "\n").replace(/&/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	script:alert(1)",
|
||||
"javascript:alert(1)", "javascript:alert(1)", "javascript:alert(1)",
|
||||
"javascript: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 })`, 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<A>");
|
||||
expect(html).toContain("Stream<Action>");
|
||||
});
|
||||
Reference in New Issue
Block a user