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]>
85 lines
4.6 KiB
TypeScript
85 lines
4.6 KiB
TypeScript
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>");
|
|
});
|