Files
STCourses/packages/feature-learner/src/server/runnerFrame.ts
T
Lucas WintherandClaude Opus 5.5 c7b9e1f98e 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]>
2026-10-30 05:20:43 +01:00

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",
},
}),
},
};
}