Files
STCourses/packages/feature-learner/test/xss.test.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

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(/&colon;/gi, ":").replace(/&tab;/gi, "\t").replace(/&newline;/gi, "\n").replace(/&amp;/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&#x09;script:alert(1)",
"jav&#x61;script:alert(1)", "&#106;avascript:alert(1)", "&#x6A;&#x61;&#x76;&#x61;&#x73;&#x63;&#x72;&#x69;&#x70;&#x74;&#x3A;alert(1)",
"javascript&colon;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 ![i](${p.replace(/ /g, "%20")})`, 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&lt;A&gt;");
expect(html).toContain("Stream&lt;Action&gt;");
});