diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 9187eae..ca26cd7 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, spaWithCsp } from "@stc/feature-learner"; +import { learnerFeature } from "@stc/feature-learner"; import indexHtml from "../../web/index.html"; // Used until a feature supplies an `auth` service (the learner package does). @@ -31,19 +31,16 @@ 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), ...spa.routes }, + routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml }, fetch: (req) => app.fetch(req), }); return { server, async stop() { await server.stop(true); - spa.stop(); await jobs.stop(); await db.close(); }, diff --git a/apps/web/index.html b/apps/web/index.html index 2d79c9a..cd1895c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,6 +3,10 @@ + + STCourses diff --git a/docs/contract-requests/learner.md b/docs/contract-requests/learner.md index b751e07..6121a3d 100644 --- a/docs/contract-requests/learner.md +++ b/docs/contract-requests/learner.md @@ -29,13 +29,26 @@ and let `startServer` take `Array`. `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) +## 5. Content-Security-Policy on the SPA (review C1, ruling R16) -**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. +**Applied in `feat/learner`.** The SPA's CSP is a `` tag in `apps/web/index.html`, ahead of every script. `Bun.serve` serves the HTML import directly, as in Phase 0; there is no proxy and no change to proxy environment variables. The policy: -**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. +``` +default-src 'self'; script-src 'self' 'sha256-'; 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' +``` -**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. +- **No inline script.** + - The one hash is the inline helper Bun's dev server injects (the `/_bun/unref` beacon). Production serving does not inject it. + - If a Bun upgrade changes the helper, the only effect is a dev-only CSP console error; `test/csp.test.ts` (dev mode) will then fail and name it. +- **Dev HMR works:** the HMR websocket `ws:///_bun/hmr` is same-origin, and Chrome matches it with `connect-src 'self'` (checked: handshake 101, no violations). +- **The runner frame** (`/api/learner/runner-frame`) keeps its own header-based CSP, including `sandbox allow-scripts` and `frame-ancestors 'self'`. + +**Proposal for core (Phase 2).** +- A meta tag cannot carry `frame-ancestors`, `sandbox` or `report-uri`. Core should additionally send `Content-Security-Policy: frame-ancestors 'self'` (or `X-Frame-Options: SAMEORIGIN`), plus `X-Content-Type-Options: nosniff`, on HTML responses. That is possible if Bun ever allows headers on HTML imports, or by serving a production static build (`bun build apps/web/index.html`) through a normal route that sets headers. +- Ideally core then sends the whole policy as a header and the meta tag stays as defence in depth. +- Every feature's web code must keep to this policy: no inline scripts or event handlers, and no `eval`. The learner and studio preview, through `LessonView`, comply. ## 6. `canView` and the global `author` role (review I1) diff --git a/packages/feature-learner/src/index.ts b/packages/feature-learner/src/index.ts index dc67d6b..c01208d 100644 --- a/packages/feature-learner/src/index.ts +++ b/packages/feature-learner/src/index.ts @@ -1,7 +1,6 @@ // 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/spa.ts b/packages/feature-learner/src/server/spa.ts deleted file mode 100644 index 9b774ac..0000000 --- a/packages/feature-learner/src/server/spa.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { chmodSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -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 `
`; + // The SPA's own CSP meta tag, taken from apps/web/index.html (the policy the learner app runs under). + const meta = /]*>/i.exec(readFileSync(join(import.meta.dir, "../../../apps/web/index.html"), "utf8"))![0]; + const page = (csp: boolean) => `${csp ? meta : ""}
`; 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" } }); + if (u.pathname === "/harness.html") return new Response(page(true), { headers: { "Content-Type": "text/html", "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" } }); + if (u.pathname === "/harness-nocsp.html") return new Response(page(false), { 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") }); @@ -77,7 +89,7 @@ run("browser isolation (headless Chrome)", () => { // 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); + await openHarness("/harness.html"); expect(await chrome.eval("window.stcReady === true")).toBe(true); const c = hits.find((h) => h.path === "/control"); expect(c).toBeDefined(); @@ -138,7 +150,7 @@ run("browser isolation (headless Chrome)", () => { 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); + await openHarness("/harness-nocsp.html"); 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"); @@ -147,39 +159,62 @@ run("browser isolation (headless Chrome)", () => { }, 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); + await openHarness("/harness-nocsp.html"); 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); + await openHarness("/harness.html"); 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 = [ - "
\nThink first\n", - "
\n", - "", - "
Hint 1 — nudge\n\nx\n\n
", - "[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) c", - "", - ].join("\n\n"); + const exploitMd = () => [ + "
\nThink first\n", + "
\n", + "", + "
Hint 1 — nudge\n\nx\n\n
", + "[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) c", + "", + ].join("\n\n"); + + async function renderExploit(pagePath: string) { + await openHarness(pagePath); const before = hits.length; - const html = await chrome.eval(`stcRenderLesson(${JSON.stringify(md)}, ${JSON.stringify("(`stcRenderLesson(${JSON.stringify(exploitMd())}, ${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(); + return { html, xss: await chrome.eval("window.__xss ?? null"), leaked: hits.slice(before).filter((h) => h.path === "/xss") }; + } + + test("C1, first layer: the exploit lesson runs no script on a page without CSP (sanitizer alone)", async () => { + const r = await renderExploit("/harness-nocsp.html"); + expect(r.xss).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); + expect(r.leaked).toEqual([]); + expect(r.html).toContain("Next paragraph"); + expect(r.html).not.toMatch(/<[a-z][^>]*\son[a-z]+=/i); + }, 30_000); + + test("C1, both layers: the exploit lesson runs no script under the SPA's meta CSP", async () => { + const r = await renderExploit("/harness.html"); + expect(r.xss).toBeNull(); + expect(r.leaked).toEqual([]); + expect(r.html).toContain("Next paragraph"); + }, 30_000); + + const RAW = `a`; + + test("C1, second layer: the meta CSP alone blocks unsanitized handlers (control: they run without it)", async () => { + await openHarness("/harness-nocsp.html"); + await chrome.eval(`stcRawHtml(${JSON.stringify(RAW)})`); + expect(await chrome.eval("window.__xss ?? null")).not.toBeNull(); // the raw payload does run without a CSP + await openHarness("/harness.html"); + const violations = await chrome.eval(`stcRawHtml(${JSON.stringify(RAW)})`); + expect(await chrome.eval("window.__xss ?? null")).toBeNull(); + expect(violations).toBeGreaterThan(0); }, 30_000); }); diff --git a/packages/feature-learner/test/csp.test.ts b/packages/feature-learner/test/csp.test.ts new file mode 100644 index 0000000..e5bd7c9 --- /dev/null +++ b/packages/feature-learner/test/csp.test.ts @@ -0,0 +1,96 @@ +import { expect, test } from "bun:test"; +import { Glob } from "bun"; +import { join } from "node:path"; +import { connect } from "@stc/db"; +import { RUNNER_FRAME_CSP, RUNNER_FRAME_PATH } from "../src/server/runnerFrame"; +import { runMigrations } from "../src/server/migrate"; +import { makeApp } from "./app"; +import { forEachDb } from "./helpers"; + +const ROOT = join(import.meta.dir, "../../.."); +const INDEX = join(ROOT, "apps/web/index.html"); + +/** The CSP meta tag's policy from an HTML document, as a directive map. */ +export function metaCsp(html: string): Map | null { + const m = //i.exec(html); + if (!m) return null; + return new Map(m[1]!.split(";").map((d) => d.trim()).filter(Boolean).map((d) => { + const [name, ...values] = d.split(/\s+/); + return [name!, values] as const; + })); +} + +function expectSpaPolicy(p: Map | null) { + expect(p).not.toBeNull(); + const script = p!.get("script-src")!; + expect(script[0]).toBe("'self'"); + // No inline script: only 'self' plus exact hashes (Bun's dev-server helper), never unsafe-inline/eval. + for (const s of script.slice(1)) expect(s).toMatch(/^'sha256-[A-Za-z0-9+/=]+'$/); + expect(p!.get("default-src")).toEqual(["'self'"]); + expect(p!.get("connect-src")).toEqual(["'self'"]); + expect(p!.get("object-src")).toEqual(["'none'"]); + expect(p!.get("base-uri")).toEqual(["'self'"]); + expect(p!.get("frame-src")).toEqual(["'self'"]); + // Directives a meta tag cannot carry are not present (they would be ignored with a console warning). + for (const d of ["frame-ancestors", "sandbox", "report-uri"]) expect(p!.has(d)).toBe(false); +} + +test("apps/web/index.html carries the SPA CSP as a meta tag", async () => { + const html = await Bun.file(INDEX).text(); + expectSpaPolicy(metaCsp(html)); + // The meta tag must come before any script, so it governs all of them. + expect(html.indexOf('http-equiv="Content-Security-Policy"')).toBeLessThan(html.indexOf(" { + const p = Bun.spawnSync([process.execPath, join(import.meta.dir, "e2e/serve-index.ts"), mode], { stdout: "pipe", stderr: "pipe", cwd: ROOT }); + expect(p.exitCode).toBe(0); + const out = JSON.parse(p.stdout.toString().trim().split("\n").pop()!) as { status: number; type: string; html: string }; + expect(out.status).toBe(200); + expect(out.type).toContain("text/html"); + const policy = metaCsp(out.html); + expectSpaPolicy(policy); + // Every inline script Bun injects is covered by a hash in the policy (dev: the /_bun/unref helper). + const allowed = new Set(policy!.get("script-src")!.slice(1)); + for (const m of out.html.matchAll(/]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)) { + expect(allowed.has(`'sha256-${new Bun.CryptoHasher("sha256").update(m[1]!).digest("base64")}'`)).toBe(true); + } + }, 60_000); +} + +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(csp.split(";").map((d) => d.trim()).find((d) => d.startsWith("script-src"))).toMatch(/^script-src 'sha256-[A-Za-z0-9+/=]+' blob:$/); + await db.close(); +}); + +const PROXY_VARS = ["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"]; + +test("no source file changes proxy environment variables", async () => { + const offenders: string[] = []; + const assign = new RegExp(`process\\.env(\\.(${PROXY_VARS.join("|")})|\\[[^\\]]+\\])\\s*(=(?!=)|\\?\\?=|\\|\\|=)|delete\\s+process\\.env|Object\\.assign\\(\\s*process\\.env`); + for (const dir of ["packages", "apps"]) { + for await (const f of new Glob("*/src/**/*.{ts,tsx}").scan({ cwd: join(ROOT, dir) })) { + const src = await Bun.file(join(ROOT, dir, f)).text(); + if (assign.test(src)) offenders.push(`${dir}/${f}`); + } + } + expect(offenders).toEqual([]); +}); + +forEachDb("starting the learner feature and serving requests leaves proxy variables untouched", async (db) => { + const before = Object.fromEntries(PROXY_VARS.map((k) => [k, process.env[k]])); + const { call } = await makeApp(db); + for (const p of ["/api/library", "/api/courses/sample", RUNNER_FRAME_PATH, "/api/learner/pdf.worker.mjs"]) expect((await call("GET", p)).status).toBe(200); + expect(Object.fromEntries(PROXY_VARS.map((k) => [k, process.env[k]]))).toEqual(before); +}); diff --git a/packages/feature-learner/test/e2e/harness.tsx b/packages/feature-learner/test/e2e/harness.tsx index d44ff19..ef6f0fa 100644 --- a/packages/feature-learner/test/e2e/harness.tsx +++ b/packages/feature-learner/test/e2e/harness.tsx @@ -11,6 +11,8 @@ declare global { /** 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; + /** Sets unsanitized HTML with innerHTML (bypasses the sanitizer) to test the CSP layer on its own. */ + stcRawHtml(html: string): Promise; stcReady: boolean; } } @@ -36,4 +38,15 @@ window.stcRenderLesson = async (markdown, hintBody) => { return el.innerHTML; }; +window.stcRawHtml = async (html) => { + let violations = 0; + document.addEventListener("securitypolicyviolation", () => { violations++; }); + const el = document.createElement("div"); + el.innerHTML = html; + document.body.append(el); + for (const a of el.querySelectorAll("a, summary, img")) { try { a.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); } catch (_) { /* ignore */ } } + await new Promise((r) => setTimeout(r, 600)); + return violations; +}; + window.stcReady = true; diff --git a/packages/feature-learner/test/e2e/serve-index.ts b/packages/feature-learner/test/e2e/serve-index.ts new file mode 100644 index 0000000..70ea2a8 --- /dev/null +++ b/packages/feature-learner/test/e2e/serve-index.ts @@ -0,0 +1,12 @@ +// Run by csp.test.ts in a subprocess (bundling an HTML import inside a full bun test run is unreliable): +// serves apps/web/index.html the way main.ts does, as a plain Bun HTML import, and prints the document. +import indexHtml from "../../../../apps/web/index.html"; + +const development = process.argv[2] === "dev"; +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, routes: { "/*": indexHtml }, development }); +try { + const r = await fetch(`http://127.0.0.1:${server.port}/c/sample/m1-intro`); + console.log(JSON.stringify({ status: r.status, type: r.headers.get("content-type"), html: await r.text() })); +} finally { + server.stop(true); +} diff --git a/packages/feature-learner/test/e2e/spa-proxy-check.ts b/packages/feature-learner/test/e2e/spa-proxy-check.ts deleted file mode 100644 index 8e91deb..0000000 --- a/packages/feature-learner/test/e2e/spa-proxy-check.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Run by spa.test.ts in a subprocess with HTTP_PROXY/ALL_PROXY pointing at an unreachable proxy. -import { spaWithCsp } from "../../src/server/spa"; - -/** - * The test client speaks raw HTTP/1.1 over Bun.connect: fetch and Bun's node:http both honour HTTP_PROXY, and - * only the code under test should meet the proxy. - */ -async function get(url: string, method = "GET", headers: Record = {}): Promise<{ status: number; headers: Record; body: string }> { - const u = new URL(url); - let raw = ""; - await new Promise((resolve, reject) => { - Bun.connect({ - hostname: u.hostname, port: Number(u.port), - socket: { - open(sock) { - const body = method === "POST" ? "x" : ""; - const lines = [`${method} ${u.pathname}${u.search} HTTP/1.1`, `Host: ${u.host}`, "Connection: close", ...Object.entries(headers).map(([k, v]) => `${k}: ${v}`)]; - if (body) lines.push(`Content-Length: ${body.length}`); - sock.write(`${lines.join("\r\n")}\r\n\r\n${body}`); - }, - data(sock, d) { - raw += new TextDecoder().decode(d); - // Bun keeps the connection open despite "Connection: close"; stop once the whole response is here. - const end = raw.indexOf("\r\n\r\n"); - if (end < 0) return; - const len = /\r\ncontent-length:\s*(\d+)/i.exec(raw.slice(0, end)); - const done = len ? raw.length - end - 4 >= Number(len[1]) : /\r\n0\r\n\r\n$/.test(raw); - if (done) { sock.end(); resolve(); } - }, - close() { resolve(); }, - error(_s, e) { reject(e); }, - }, - }).catch(reject); - }); - const [head, ...rest] = raw.split("\r\n\r\n"); - const [statusLine, ...hl] = head!.split("\r\n"); - const hs = Object.fromEntries(hl.map((l) => [l.slice(0, l.indexOf(":")).toLowerCase(), l.slice(l.indexOf(":") + 1).trim()])); - let body = rest.join("\r\n\r\n"); - if (hs["transfer-encoding"] === "chunked") { - let out = "", i = 0; - for (;;) { const nl = body.indexOf("\r\n", i); const n = parseInt(body.slice(i, nl), 16); if (!n) break; out += body.slice(nl + 2, nl + 2 + n); i = nl + 2 + n + 2; } - body = out; - } - return { status: Number(statusLine!.split(" ")[1]), headers: hs, body }; -} - -// Control, before spaWithCsp touches NO_PROXY: a plain loopback fetch in this process goes to the env proxy. -const plain = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("direct") }); -const controlViaEnvProxy = await fetch(`http://127.0.0.1:${plain.port}/`, { signal: AbortSignal.timeout(2000) }).then((r) => `status ${r.status}`, (e) => `error ${(e as Error).name}`); -plain.stop(true); - -const seen: Record[] = []; -const DOC = '
'; -const spa = spaWithCsp((req) => { - if (new URL(req.url).pathname === "/app.js") return new Response("console.log(1)", { headers: { "Content-Type": "text/javascript" } }); - seen.push({ cookie: req.headers.get("cookie"), authorization: req.headers.get("authorization"), xff: req.headers.get("x-forwarded-for"), accept: req.headers.get("accept") }); - return new Response(DOC, { headers: { "Content-Type": "text/html;charset=utf-8" } }); -}, { development: false }); -const server = Bun.serve({ hostname: "127.0.0.1", port: 0, routes: spa.routes }); -const url = `http://127.0.0.1:${server.port}/c/sample`; -const out: Record = {}; -try { - out.controlViaEnvProxy = controlViaEnvProxy; - const r = await get(url, "GET", { cookie: "better-auth.session_token=secret", authorization: "Bearer secret", "x-forwarded-for": "1.2.3.4", accept: "text/html" }); - out.status = r.status; - out.csp = r.headers["content-security-policy"]; - out.body = r.body; - out.post = (await get(url, "POST")).status; - const asset = await get(`http://127.0.0.1:${server.port}/app.js`); - out.asset = `${asset.status} ${asset.headers["content-type"]} ${asset.body}`; - out.noProxy = process.env.NO_PROXY; - out.seen = seen; -} finally { - server.stop(true); - spa.stop(); -} -console.log(JSON.stringify(out)); diff --git a/packages/feature-learner/test/spa.test.ts b/packages/feature-learner/test/spa.test.ts deleted file mode 100644 index 2e9bd2a..0000000 --- a/packages/feature-learner/test/spa.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -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(); -}); - -test("the SPA proxy ignores HTTP_PROXY/ALL_PROXY and forwards no credentials", () => { - const dead = "http://127.0.0.1:9"; // nothing listens on the discard port - const p = Bun.spawnSync([process.execPath, new URL("./e2e/spa-proxy-check.ts", import.meta.url).pathname], { - env: { ...process.env, HTTP_PROXY: dead, http_proxy: dead, HTTPS_PROXY: dead, https_proxy: dead, ALL_PROXY: dead, all_proxy: dead, NO_PROXY: "corp.example", no_proxy: "" }, - stdout: "pipe", stderr: "pipe", - }); - expect(p.exitCode).toBe(0); - const out = JSON.parse(p.stdout.toString().trim().split("\n").pop()!); - // The environment proxy really is in effect for plain fetches in that process... - expect(out.controlViaEnvProxy).not.toBe("status 200"); - // ...but the SPA still loads through spaWithCsp, with its CSP. - expect(out.status).toBe(200); - expect(out.body).toContain('
'); - expect(out.csp).toContain("script-src 'self'"); - expect(out.post).toBe(405); - // Assets come through too (with a proxy in the env, Bun's socket fetch would otherwise hit the HTML fallback). - expect(out.asset).toBe("200 text/javascript console.log(1)"); - expect(out.noProxy).toBe("corp.example,localhost,127.0.0.1,::1"); - // The upstream saw only the allowlisted headers: no Cookie, Authorization or X-Forwarded-*. - expect(out.seen).toEqual([{ cookie: null, authorization: null, xff: null, accept: "text/html" }]); -}, 30_000);