Files
STCourses/packages/feature-learner/test/spa.test.ts
T
Lucas WintherandClaude Opus 5.5 213f2247b4 fix(learner): SPA proxy forwards no credentials and never goes through HTTP_PROXY
spaWithCsp proxies GET and HEAD only (405 otherwise, no body buffering) and forwards only accept,
if-none-match, if-modified-since and range. The internal server listens on a Unix socket in a private
0700 temp dir. Bun 1.3.14 ignores proxy: false for this hop, and over a socket with a proxy set it sends
an absolute-form target that breaks asset routing, so localhost, 127.0.0.1 and ::1 are appended to
NO_PROXY/no_proxy (existing entries kept). accept-encoding is no longer forced; fetch decompresses and
the encoding headers are dropped.

Test: in a subprocess with HTTP(S)_PROXY and ALL_PROXY set to a dead proxy, the SPA and an asset load
with the CSP, POST gets 405, and the upstream sees no Cookie, Authorization or X-Forwarded-For.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:27:56 +01:00

88 lines
4.7 KiB
TypeScript

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);