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 <noreply@anthropic.com>
This commit is contained in:
1 parent
213f2247b4
commit
0da35ec6f8
11 files changed
+204
-293
No files matched your search
@@ -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<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);
|
||||
});
|
||||
Reference in new issue
Block a user