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
@@ -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",
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user