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]>
68 lines
2.7 KiB
TypeScript
68 lines
2.7 KiB
TypeScript
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",
|
|
},
|
|
}),
|
|
},
|
|
};
|
|
}
|