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
@@ -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 }); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user