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
+1
View File
@@ -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>
+57 -19
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
/** 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 : `&lt;${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; }
+3 -1
View File
@@ -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; }