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:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 14:38:04 +01:00
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=\"jav&#x61;script: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=\"jav&#x61;script: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);
});
+96
View File
@@ -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));
-87
View File
@@ -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);