Files
Lucas WintherandClaude Opus 5.5 0da35ec6f8 fix(learner): remove the SPA proxy; CSP as a meta tag in index.html (ruling R16)
The proxy, its internal server, Unix socket and NO_PROXY changes are gone; main.ts serves the HTML
import directly again. apps/web/index.html carries the SPA policy as <meta http-equiv=CSP> ahead of every
script (script-src 'self' plus the hash of Bun's dev-server helper, no unsafe-inline/eval; no
frame-ancestors, which meta cannot carry). Dev HMR connects under it. The runner frame keeps its header CSP.

Tests: the served index.html (prod and dev, in a subprocess) has the meta policy and every inline script
is hash-covered; the XSS browser test fails with the sanitizer disabled and passes with it, and the meta
CSP alone blocks unsanitized handlers (with a no-CSP control); no source assigns proxy env vars, and the
feature leaves them untouched at runtime. Contract request 5 asks core for frame-ancestors/X-Frame-Options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:38:04 +01:00

97 lines
4.8 KiB
TypeScript

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<string, string[]> | null {
const m = /<meta\s+http-equiv="Content-Security-Policy"\s+content="([^"]+)"\s*\/?>/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<string, string[]> | 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("<script"));
});
for (const mode of ["prod", "dev"]) {
test(`the served index.html contains the meta CSP (${mode})`, () => {
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(/<script\b(?![^>]*\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);
});