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); });