diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index ca26cd7..9187eae 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -2,7 +2,7 @@ import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerConte import { createApp, createJobQueue, loadConfig } from "@stc/core"; import { createCourseRepository } from "@stc/course-format"; 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"; // Used until a feature supplies an `auth` service (the learner package does). @@ -31,16 +31,19 @@ export async function startServer( ctx.jobs = jobs; const app = createApp(ctx, built); 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({ port: config.port, // 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), }); return { server, async stop() { await server.stop(true); + spa.stop(); await jobs.stop(); await db.close(); }, diff --git a/bun.lock b/bun.lock index a0be2cc..9b52941 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,10 @@ "better-auth": "^1.7.14", "marked": "^18.1.2", "pdfjs-dist": "^6.4.299", + "sanitize-html": "^2", + }, + "devDependencies": { + "@types/sanitize-html": "^2.16.2", }, }, "packages/feature-studio": { @@ -164,6 +168,8 @@ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + "@types/sanitize-html": ["@types/sanitize-html@2.16.2", "", { "dependencies": { "htmlparser2": "^12.0" } }, "sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA=="], + "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.9.12", "", {}, "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A=="], @@ -178,28 +184,56 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + "dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="], + + "domelementtype": ["domelementtype@3.0.0", "", {}, "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg=="], + + "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "^3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], + + "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "^3.0.0", "domelementtype": "^3.0.0", "domhandler": "^6.0.0" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], + + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], "fast-xml-parser": ["fast-xml-parser@5.11.2", "", { "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": ["htmlparser2@12.0.0", "", { "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": ["is-plain-object@5.1.0", "", {}, "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], "jose": ["jose@6.2.14", "", {}, "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A=="], "kysely": ["kysely@0.29.7", "", {}, "sha512-fmIq+7kv4WbFm2tcfznRzikqB4cuNpgZ40PSU2fAJ2/OO5XhojqnTPrmgrCjYSRJVwBln03XcRXJGnw9H099IQ=="], + "launder": ["launder@1.7.2", "", { "dependencies": { "dayjs": "^1.11.23" } }, "sha512-DLg3HPnHUfBi5/MxMLmJD12dmlMpFEC2HgMW6vkZ/9JR0RU00kXoRGvDxAvFmdO0o610OA77i4FgNTLucmhDVg=="], + "marked": ["marked@18.1.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg=="], + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + "nanostores": ["nanostores@1.5.5", "", {}, "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA=="], + "parse-srcset": ["parse-srcset@1.0.2", "", {}, "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "pdfjs-dist": ["pdfjs-dist@6.4.299", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.10" } }, "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], @@ -212,10 +246,14 @@ "samlify": ["samlify@2.15.1", "", { "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": ["sanitize-html@2.18.1", "", { "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": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "set-cookie-parser": ["set-cookie-parser@3.2.1", "", {}, "sha512-Npd1Pt+4zb3BrSZDPd2qFwRpS6b1EUEasRrO8xS0zMJi0fApXXK5YuzP0minyJnxwnFHoVoJwA55q0A0cu63gQ=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + "strnum": ["strnum@2.5.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA=="], "tldts": ["tldts@7.4.19", "", { "dependencies": { "tldts-core": "^7.4.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q=="], diff --git a/docs/contract-requests/learner.md b/docs/contract-requests/learner.md index d6ce696..b751e07 100644 --- a/docs/contract-requests/learner.md +++ b/docs/contract-requests/learner.md @@ -28,3 +28,17 @@ and let `startServer` take `Array`. ## 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. + +## 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`. diff --git a/packages/feature-learner/package.json b/packages/feature-learner/package.json index 807eab9..bf47b54 100644 --- a/packages/feature-learner/package.json +++ b/packages/feature-learner/package.json @@ -8,10 +8,14 @@ "@better-auth/sso": "^1.7.14", "better-auth": "^1.7.14", "marked": "^18.1.2", - "pdfjs-dist": "^6.4.299" + "pdfjs-dist": "^6.4.299", + "sanitize-html": "^2" }, "exports": { ".": "./src/index.ts", "./web": "./web/src/index.ts" + }, + "devDependencies": { + "@types/sanitize-html": "^2.16.2" } } diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts index c01208d..dc67d6b 100644 --- a/packages/feature-learner/src/index.ts +++ b/packages/feature-learner/src/index.ts @@ -1,6 +1,7 @@ // Server export { migrationsDirs, runMigrations } from "./server/migrate"; export { learnerFeature } from "./server/feature"; +export { spaWithCsp, spaCsp } from "./server/spa"; // Browser (also available without the server half as "@stc/feature-learner/web") export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web"; // Shared JSON shapes diff --git a/packages/feature-learner/src/server/access.ts b/packages/feature-learner/src/server/access.ts index 9ad4ea2..64fbbec 100644 --- a/packages/feature-learner/src/server/access.ts +++ b/packages/feature-learner/src/server/access.ts @@ -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)); } -/** 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 { 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). */ diff --git a/packages/feature-learner/src/server/feature.ts b/packages/feature-learner/src/server/feature.ts index 0d2c512..ebfe694 100644 --- a/packages/feature-learner/src/server/feature.ts +++ b/packages/feature-learner/src/server/feature.ts @@ -9,6 +9,7 @@ import { userDataRoutes } from "./userdata"; import { codeRoutes } from "./code"; import { transpileRoutes } from "./transpile"; import { assetRoutes } from "./assets"; +import { runnerFrameRoutes } from "./runnerFrame"; /** * The learner feature: auth, library, reader, notes, progress and exercises. @@ -34,6 +35,7 @@ export function learnerFeature(ctx: ServerContext): Feature & { auth: AuthServic ...codeRoutes(ctx), ...transpileRoutes(), ...assetRoutes(), + ...runnerFrameRoutes(), }; // Every handler waits for the learner migrations (a no-op once Phase 2 runs them in migrate()). for (const methods of Object.values(routes)) { diff --git a/packages/feature-learner/src/server/runnerFrame.ts b/packages/feature-learner/src/server/runnerFrame.ts new file mode 100644 index 0000000..a95ef1e --- /dev/null +++ b/packages/feature-learner/src/server/runnerFrame.ts @@ -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 `", + ].join("\n\n"); + const before = hits.length; + const html = await chrome.eval(`stcRenderLesson(${JSON.stringify(md)}, ${JSON.stringify(" { 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); +}); diff --git a/packages/feature-learner/test/chrome.ts b/packages/feature-learner/test/chrome.ts new file mode 100644 index 0000000..f9dd787 --- /dev/null +++ b/packages/feature-learner/test/chrome.ts @@ -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 void>(); + readonly console: string[] = []; + readonly dialogs: string[] = []; + private proc!: Bun.Subprocess; + private dir!: string; + + static async launch(): Promise { + 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(js: string): Promise { + 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 }); + } +} diff --git a/packages/feature-learner/test/e2e/harness.tsx b/packages/feature-learner/test/e2e/harness.tsx new file mode 100644 index 0000000..d44ff19 --- /dev/null +++ b/packages/feature-learner/test/e2e/harness.tsx @@ -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; + /** Negative control only: the old design, a same-origin blob worker in the page. */ + stcRunPlain(code: string, tests: string, timeoutMs: number): Promise; + stcRenderLesson(markdown: string, hintBody: string): Promise; + 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(); + 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; diff --git a/packages/feature-learner/test/lesson-view.test.tsx b/packages/feature-learner/test/lesson-view.test.tsx index 60f8478..fbd89f6 100644 --- a/packages/feature-learner/test/lesson-view.test.tsx +++ b/packages/feature-learner/test/lesson-view.test.tsx @@ -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")!.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"/); +}); diff --git a/packages/feature-learner/test/markdown.test.ts b/packages/feature-learner/test/markdown.test.ts index 604b444..a3b3630 100644 --- a/packages/feature-learner/test/markdown.test.ts +++ b/packages/feature-learner/test/markdown.test.ts @@ -100,11 +100,12 @@ test("systemic-design hint numbering forms one group", () => { }); test("raw HTML is reduced to safe tags", () => { - expect(sanitizeHtml('x
')).toBe("<script>alert(1)</script>x<img src=x onerror=alert(1)>
"); + expect(sanitizeHtml('x
')).toBe('<script>alert(1)</script>x
'); const html = renderLessonHtml('
hi
\n\n[x](javascript:alert(1)) s', opts); - expect(html).not.toContain("hi"); + expect(html).not.toContain("onclick"); expect(html).not.toContain("javascript:"); - expect(html).not.toContain(' { @@ -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("img/a.png", opts, "image")).toEqual({ url: "/api/media/pure-snake/course/img/a.png", external: false }); const html = renderLessonHtml("![board](img/board.png) and [ext](https://x.org)", opts); - expect(html).toContain('board'); + expect(html).toContain('board'); expect(html).toContain('target="_blank" rel="noopener noreferrer"'); }); diff --git a/packages/feature-learner/test/spa.test.ts b/packages/feature-learner/test/spa.test.ts new file mode 100644 index 0000000..7e78e56 --- /dev/null +++ b/packages/feature-learner/test/spa.test.ts @@ -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('')).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 = '
'; +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(); +}); diff --git a/packages/feature-learner/test/visibility.test.ts b/packages/feature-learner/test/visibility.test.ts new file mode 100644 index 0000000..1d9ec5f --- /dev/null +++ b/packages/feature-learner/test/visibility.test.ts @@ -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("owner@x.test"); + const author = await signUp("author@x.test"); + const coAuthor = await signUp("co@x.test"); + 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("author@x.test")}/roles`, { cookie: owner, body: { roles: ["author"] } })).status).toBe(200); + expect((await call("PUT", "/api/admin/courses/secret/access", { cookie: owner, body: { authors: [id("co@x.test")] } })).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([]); +}); diff --git a/packages/feature-learner/test/xss.test.ts b/packages/feature-learner/test/xss.test.ts new file mode 100644 index 0000000..eaa5727 --- /dev/null +++ b/packages/feature-learner/test/xss.test.ts @@ -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(/]*

or

", () => { + for (const wrapper of ["details", "div"]) { + const md = `<${wrapper}>\nThink first\n { + for (const md of [ + "bold", + "
a\n\n", + "ok'); +}); + +test("
keeps the fold but drops the handler", () => { + const html = renderLessonHtml('
Hint 1 — nudge\n\ntext\n\n
', opts); + expectSafe(html); + expect(html).toContain('
", body: " { + const html = renderLessonHtml("A value of type IO or Stream.", opts); + expect(html).toContain("IO<A>"); + expect(html).toContain("Stream<Action>"); +});