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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
213f2247b4
commit
0da35ec6f8
@@ -2,7 +2,7 @@ import type { AppConfig, AuthService, Feature, JobHandler, JobQueue, ServerConte
|
||||
import { createApp, createJobQueue, loadConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import { learnerFeature, spaWithCsp } from "@stc/feature-learner";
|
||||
import { learnerFeature } from "@stc/feature-learner";
|
||||
import indexHtml from "../../web/index.html";
|
||||
|
||||
// Used until a feature supplies an `auth` service (the learner package does).
|
||||
@@ -31,19 +31,16 @@ export async function startServer(
|
||||
ctx.jobs = jobs;
|
||||
const app = createApp(ctx, built);
|
||||
jobs.start();
|
||||
// The SPA document carries a CSP header (served through spaWithCsp; contract request learner §5).
|
||||
const spa = spaWithCsp(indexHtml, { development: process.env.NODE_ENV !== "production" });
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
// Bun routes win over fetch; "/api/*" falls through to the composed app (404 for unknown API paths).
|
||||
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), ...spa.routes },
|
||||
routes: { ...app.routes, "/api/*": (req) => app.fetch(req), "/*": indexHtml },
|
||||
fetch: (req) => app.fetch(req),
|
||||
});
|
||||
return {
|
||||
server,
|
||||
async stop() {
|
||||
await server.stop(true);
|
||||
spa.stop();
|
||||
await jobs.stop();
|
||||
await db.close();
|
||||
},
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- CSP (learner review C1, ruling R16). No inline script: the one hash is Bun's dev-server helper
|
||||
(/_bun/unref beacon), which production builds do not inject. frame-ancestors cannot be set in a meta tag;
|
||||
see docs/contract-requests/learner.md section 5. -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-ES8VF47udzjVFFPh4RhgcgjUrADRd1Ef0GD9UagS2mY='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
|
||||
<title>STCourses</title>
|
||||
<link rel="stylesheet" href="../../packages/ui/src/fonts.css">
|
||||
<link rel="stylesheet" href="../../packages/ui/src/tokens.css">
|
||||
|
||||
@@ -29,13 +29,26 @@ and let `startServer` take `Array<Feature | FeatureFactory>`.
|
||||
|
||||
`import { LessonView } from "@stc/feature-learner/web"`. Props: `lesson: LessonPayload` (the JSON from `GET /api/courses/:id/lessons/:lessonId`), plus optional `look`, `track`, `onTrackChange`, `preview`. With `preview` set, it makes no notes, attempt or progress requests; it still loads code listings and decks through the normal media routes. It injects its own stylesheet (scoped under `.lr`) and works with or without a router.
|
||||
|
||||
## 5. Content-Security-Policy on the SPA, set by core (review C1)
|
||||
## 5. Content-Security-Policy on the SPA (review C1, ruling R16)
|
||||
|
||||
**Problem.** Lesson HTML is author content. The sanitizer (sanitize-html allowlist) is the first defence, and a CSP without inline script is the second. Bun.serve cannot attach headers to an HTML import route.
|
||||
**Applied in `feat/learner`.** The SPA's CSP is a `<meta http-equiv="Content-Security-Policy">` tag in `apps/web/index.html`, ahead of every script. `Bun.serve` serves the HTML import directly, as in Phase 0; there is no proxy and no change to proxy environment variables. The policy:
|
||||
|
||||
**Workaround in `feat/learner` (applied).** `spaWithCsp(indexHtml, { development })` in `@stc/feature-learner` serves the bundle from an internal server bound to 127.0.0.1 and proxies it. HTML responses get `Content-Security-Policy` (`script-src 'self'`, no `'unsafe-inline'`; `connect-src 'self'`; `object-src 'none'`; `frame-ancestors 'self'`; see `spaCsp()`), plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: same-origin`. `main.ts` uses it for `"/*"`. HMR is off on the internal server, so Bun injects no inline script. If it ever does in development, only the hashes of those exact scripts are allowed.
|
||||
```
|
||||
default-src 'self'; script-src 'self' 'sha256-<Bun dev helper>'; style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self';
|
||||
worker-src 'self' blob:; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'
|
||||
```
|
||||
|
||||
**Proposal.** Core owns the SPA route and sets this CSP globally (and for every feature's HTML), replacing the proxy. Other features must then avoid inline scripts as well. The studio preview through `LessonView` already complies.
|
||||
- **No inline script.**
|
||||
- The one hash is the inline helper Bun's dev server injects (the `/_bun/unref` beacon). Production serving does not inject it.
|
||||
- If a Bun upgrade changes the helper, the only effect is a dev-only CSP console error; `test/csp.test.ts` (dev mode) will then fail and name it.
|
||||
- **Dev HMR works:** the HMR websocket `ws://<host>/_bun/hmr` is same-origin, and Chrome matches it with `connect-src 'self'` (checked: handshake 101, no violations).
|
||||
- **The runner frame** (`/api/learner/runner-frame`) keeps its own header-based CSP, including `sandbox allow-scripts` and `frame-ancestors 'self'`.
|
||||
|
||||
**Proposal for core (Phase 2).**
|
||||
- A meta tag cannot carry `frame-ancestors`, `sandbox` or `report-uri`. Core should additionally send `Content-Security-Policy: frame-ancestors 'self'` (or `X-Frame-Options: SAMEORIGIN`), plus `X-Content-Type-Options: nosniff`, on HTML responses. That is possible if Bun ever allows headers on HTML imports, or by serving a production static build (`bun build apps/web/index.html`) through a normal route that sets headers.
|
||||
- Ideally core then sends the whole policy as a header and the meta tag stays as defence in depth.
|
||||
- Every feature's web code must keep to this policy: no inline scripts or event handlers, and no `eval`. The learner and studio preview, through `LessonView`, comply.
|
||||
|
||||
## 6. `canView` and the global `author` role (review I1)
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Server
|
||||
export { migrationsDirs, runMigrations } from "./server/migrate";
|
||||
export { learnerFeature } from "./server/feature";
|
||||
export { spaWithCsp, spaCsp } from "./server/spa";
|
||||
// Browser (also available without the server half as "@stc/feature-learner/web")
|
||||
export { learnerWeb, LessonView, type LessonViewProps, ensureLearnerStyles } from "./web";
|
||||
// Shared JSON shapes
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { chmodSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { HTMLBundle } from "bun";
|
||||
|
||||
/**
|
||||
* Content-Security-Policy for the SPA document. No inline script: `script-src 'self'` only (plus, in development,
|
||||
* the hashes of the inline helper scripts Bun's dev server injects). Style attributes are allowed because React
|
||||
* components set them; styles cannot run script. The exercise runner frame is a same-origin URL (`frame-src
|
||||
* 'self'`) with its own, stricter policy, see `runnerFrame.ts`.
|
||||
*/
|
||||
export function spaCsp(inlineScriptHashes: string[] = []): string {
|
||||
const hashes = inlineScriptHashes.map((h) => ` '${h}'`).join("");
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src 'self'${hashes}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"media-src 'self' blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
/** `sha256-…` CSP sources for every inline `<script>` (no `src`) in `html`. */
|
||||
export function inlineScriptHashes(html: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const m of html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)) {
|
||||
out.push(`sha256-${new Bun.CryptoHasher("sha256").update(m[1]!).digest("base64")}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the SPA (`index.html` bundle) with a CSP header. Bun.serve cannot add headers to an HTML import route,
|
||||
* so the bundle is served by an internal server on a private Unix socket, and these routes proxy to it (GET and
|
||||
* HEAD only, a few cache/range headers only, never cookies), adding the header to HTML responses. Phase 2 can replace this once core sets the CSP globally
|
||||
* (docs/contract-requests/learner.md §5).
|
||||
*/
|
||||
export function spaWithCsp(indexHtml: HTMLBundle | ((req: Request) => Response | Promise<Response>), opts: { development: boolean }) {
|
||||
// Bun 1.3.14 sends this hop through HTTP_PROXY even with `proxy: false`, and over a Unix socket it then uses an
|
||||
// absolute-form request target that breaks asset routing. Loopback never belongs behind a proxy, so the loopback
|
||||
// names are appended to NO_PROXY (existing entries are kept). The socket lives in a private (0700) temp dir.
|
||||
for (const k of ["NO_PROXY", "no_proxy"]) {
|
||||
const have = (process.env[k] ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
||||
process.env[k] = [...new Set([...have, "localhost", "127.0.0.1", "::1"])].join(",");
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), "stc-spa-"));
|
||||
chmodSync(dir, 0o700);
|
||||
const socket = join(dir, "spa.sock");
|
||||
const inner = Bun.serve({
|
||||
unix: socket,
|
||||
routes: { "/*": indexHtml },
|
||||
// HMR needs a websocket on the public server; it is off, and the bundle still rebuilds per request in dev.
|
||||
development: opts.development ? { hmr: false, console: false } : false,
|
||||
fetch: () => new Response("Not found", { status: 404 }),
|
||||
});
|
||||
|
||||
/** Only these request headers reach the internal server: never Cookie, Authorization or X-Forwarded-*. */
|
||||
const FORWARD = ["accept", "if-none-match", "if-modified-since", "range"];
|
||||
|
||||
async function handle(req: Request): Promise<Response> {
|
||||
// The SPA and its assets are read-only; nothing else is proxied (and no request body is buffered).
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
|
||||
const u = new URL(req.url);
|
||||
const headers = new Headers();
|
||||
for (const h of FORWARD) { const v = req.headers.get(h); if (v !== null) headers.set(h, v); }
|
||||
const r = await fetch(`http://localhost${u.pathname}${u.search}`, { method: req.method, headers, redirect: "manual", unix: socket, proxy: false });
|
||||
const out = new Headers(r.headers);
|
||||
// fetch already decompressed the body, so the upstream encoding and length no longer apply.
|
||||
out.delete("content-encoding");
|
||||
out.delete("content-length");
|
||||
out.delete("transfer-encoding");
|
||||
if ((out.get("content-type") ?? "").includes("text/html")) {
|
||||
const html = await r.text();
|
||||
out.set("Content-Security-Policy", spaCsp(opts.development ? inlineScriptHashes(html) : []));
|
||||
out.set("X-Content-Type-Options", "nosniff");
|
||||
out.set("Referrer-Policy", "same-origin");
|
||||
return new Response(html, { status: r.status, headers: out });
|
||||
}
|
||||
return new Response(r.body, { status: r.status, headers: out });
|
||||
}
|
||||
|
||||
return {
|
||||
routes: { "/*": handle } as Record<string, (req: Request) => Promise<Response>>,
|
||||
stop: () => { inner.stop(true); rmSync(dir, { recursive: true, force: true }); },
|
||||
};
|
||||
}
|
||||
@@ -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