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
@@ -6,7 +6,6 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { connect, type Db } from "@stc/db";
|
||||
import { runMigrations } from "../src/server/migrate";
|
||||
import { spaCsp } from "../src/server/spa";
|
||||
import { makeApp, FIXTURES } from "./app";
|
||||
import { Chrome, chromePath } from "./chrome";
|
||||
|
||||
@@ -18,6 +17,17 @@ let P = "", ORIGIN = "";
|
||||
|
||||
const run = chromePath ? describe : describe.skip;
|
||||
|
||||
/** Navigates to a harness page and waits until its bundle has loaded. */
|
||||
async function openHarness(path: string) {
|
||||
await chrome.goto(`${ORIGIN}${path}`, 200);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// The old document still has stcReady until the navigation commits, so check the location as well.
|
||||
if (await chrome.eval<boolean>(`location.pathname === ${JSON.stringify(path)} && window.stcReady === true`).catch(() => false)) return;
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
throw new Error(`harness ${path} did not load`);
|
||||
}
|
||||
|
||||
run("browser isolation (headless Chrome)", () => {
|
||||
beforeAll(async () => {
|
||||
// Bundled in a separate process: Bun.build inside a full bun test run is unreliable (EISDIR on node_modules).
|
||||
@@ -42,15 +52,17 @@ run("browser isolation (headless Chrome)", () => {
|
||||
db = await connect("sqlite://:memory:");
|
||||
await runMigrations(db);
|
||||
const { app: learner } = await makeApp(db, [FIXTURES]);
|
||||
const page = `<!doctype html><html><head><meta charset="utf-8"><script type="module" src="/harness.js"></script></head><body><div id="root"></div></body></html>`;
|
||||
// The SPA's own CSP meta tag, taken from apps/web/index.html (the policy the learner app runs under).
|
||||
const meta = /<meta\s+http-equiv="Content-Security-Policy"[^>]*>/i.exec(readFileSync(join(import.meta.dir, "../../../apps/web/index.html"), "utf8"))![0];
|
||||
const page = (csp: boolean) => `<!doctype html><html><head><meta charset="utf-8">${csp ? meta : ""}<script type="module" src="/harness.js"></script></head><body><div id="root"></div></body></html>`;
|
||||
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<boolean>("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 = [
|
||||
"<details>\n<summary>Think first</summary>\n<img src=x onerror=\"window.__xss=1;" + beacon + "\"\n\nNext paragraph\n</details>",
|
||||
"<div>\n<img src=x onerror=window.__xss=2\n\nAnother\n</div>",
|
||||
"<svg onload=\"window.__xss=3\"></svg>",
|
||||
"<details><summary onclick=\"window.__xss=4\">Hint 1 — nudge</summary>\n\nx\n\n</details>",
|
||||
"[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) <a href=\"javascript:window.__xss=7\">c</a>",
|
||||
"<iframe src=\"javascript:parent.__xss=8\"></iframe><script>window.__xss=9</script>",
|
||||
].join("\n\n");
|
||||
const exploitMd = () => [
|
||||
"<details>\n<summary>Think first</summary>\n<img src=x onerror=\"window.__xss=1;fetch('" + P + "/xss')\"\n\nNext paragraph\n</details>",
|
||||
"<div>\n<img src=x onerror=window.__xss=2\n\nAnother\n</div>",
|
||||
"<svg onload=\"window.__xss=3\"></svg>",
|
||||
"<details><summary onclick=\"window.__xss=4\">Hint 1 — nudge</summary>\n\nx\n\n</details>",
|
||||
"[a](javascript:window.__xss=5) [b](JaVaScRiPt:window.__xss=6) <a href=\"javascript:window.__xss=7\">c</a>",
|
||||
"<iframe src=\"javascript:parent.__xss=8\"></iframe><script>window.__xss=9</script>",
|
||||
].join("\n\n");
|
||||
|
||||
async function renderExploit(pagePath: string) {
|
||||
await openHarness(pagePath);
|
||||
const before = hits.length;
|
||||
const html = await chrome.eval<string>(`stcRenderLesson(${JSON.stringify(md)}, ${JSON.stringify("<img src=x onerror=window.__xss=10")})`);
|
||||
const html = await chrome.eval<string>(`stcRenderLesson(${JSON.stringify(exploitMd())}, ${JSON.stringify("<img src=x onerror=window.__xss=10")})`);
|
||||
// Click every link and summary, as a learner would.
|
||||
await chrome.eval(`(() => { 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 = `<img src=x onerror="window.__xss='raw'"><svg><animate onbegin="window.__xss='svg'" attributeName="x" dur="1s"></animate></svg><a href="javascript:void(window.__xss='href')">a</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<number>(`stcRawHtml(${JSON.stringify(RAW)})`);
|
||||
expect(await chrome.eval("window.__xss ?? null")).toBeNull();
|
||||
expect(violations).toBeGreaterThan(0);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
stcRenderLesson(markdown: string, hintBody: string): Promise<string>;
|
||||
/** Sets unsanitized HTML with innerHTML (bypasses the sanitizer) to test the CSP layer on its own. */
|
||||
stcRawHtml(html: string): Promise<number>;
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, string> = {}): Promise<{ status: number; headers: Record<string, string>; body: string }> {
|
||||
const u = new URL(url);
|
||||
let raw = "";
|
||||
await new Promise<void>((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<string, string | null>[] = [];
|
||||
const DOC = '<!doctype html><html><head><script type="module" src="/app.js"></script></head><body><div id="root"></div></body></html>';
|
||||
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<string, unknown> = {};
|
||||
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));
|
||||
@@ -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('<script src="/a.js"></script><script>x()</script>')).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 = '<!doctype html><html><head><script type="module" src="/_bun/client/app.js"></script><script>helper()</script></head><body><div id="root"></div></body></html>';
|
||||
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('<div id="root">');
|
||||
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);
|
||||
Reference in new issue
Block a user