Split The code is public; the app answers only to the crew.

The security floor is missing its two isolation headers: COOP and CORP on every response #232

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/cs:scout/192-the-security-floor-is-missing-its-two-is → main 1 commit fork

  • Merged. scout/192-the-security-floor-is-missing-its-two-is was merged into main as 894d4f6.

Review

Sign in to review this change.
3 files changed +61 −7
README.md +14 −6
@@ -642,12 +642,20 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
642642 `collect` and `gradients` run for real, through `worker.ts`, wherever
643643 `src/rl/pool.test.ts` runs a pool. `scripts/train.ts`
644644 runs them and writes `src/client/weights.ts`.
645 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
646 schema and its queries, `src/result.ts` validates what a browser may claim,
647 `src/who.ts` verifies the forge's signed identity headers, `src/player.ts`
648 turns "who is asking" into who a match is credited to, `src/limit.ts` is the
649 rate limit `POST /api/match` stands behind, and `src/pages/game.ts` renders
650 the page.
645 - `src/server.ts` — the HTTP server and the security floor: `content-security-policy`
646 (scripts and styles from this origin only, `frame-ancestors 'none'`,
647 `base-uri 'none'`), `x-content-type-options: nosniff`,
648 `referrer-policy: strict-origin-when-cross-origin`, a `permissions-policy`
649 that asks for nothing, `cross-origin-opener-policy: same-origin` and
650 `cross-origin-resource-policy: same-origin` (so no window we open or that
651 opens us keeps a handle on us, and no other origin can embed the page or the
652 bundle), and `strict-transport-security` once served over https. Every path
653 carries it — the page, the bundle, the JSON endpoints, and every error.
654 `src/db.ts` is the schema and its queries, `src/result.ts` validates what a
655 browser may claim, `src/who.ts` verifies the forge's signed identity
656 headers, `src/player.ts` turns "who is asking" into who a match is credited
657 to, `src/limit.ts` is the rate limit `POST /api/match` stands behind, and
658 `src/pages/game.ts` renders the page.
651659 - `src/bundle.ts` bundles the client; `src/server.ts` runs it at start-up and
652660 serves the result from memory as `/static/app.<hash>.js`, cached for a year.
653661 `scripts/build.ts` is a thin CLI over the same function, for a file on disk.
src/server.test.ts +42
@@ -91,6 +91,48 @@ test("stands on the security floor", async () => {
9191 expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
9292 });
9393
94 test("no window we open or that opens us keeps a handle on us, and nobody else can embed us — on every kind of response", async () => {
95 const page = await (await fetch(base + "/")).text();
96 const bundleSrc = /src="(\/static\/app\.[0-9a-f]+\.js)"/.exec(page)?.[1] as string;
97 const cases: [string, number, () => Promise<Response>][] = [
98 ["the page", 200, () => fetch(base + "/")],
99 ["the bundle", 200, () => fetch(base + bundleSrc)],
100 ["the leaderboard", 200, () => fetch(base + "/api/leaderboard")],
101 ["a match post", 200, () => post(result({ matchId: crypto.randomUUID() }), "", forge("u_iso", "isolated"))],
102 ["a not-found path", 404, () => fetch(base + "/nope")],
103 [
104 "a cross-site post",
105 403,
106 () =>
107 fetch(base + "/api/match", {
108 method: "POST",
109 headers: { "content-type": "application/json", origin: "https://evil.example", "sec-fetch-site": "cross-site" },
110 body: JSON.stringify(result()),
111 }),
112 ],
113 ["an oversized body", 413, () => post(result({ name: "x".repeat(1024 * 1024) }))],
114 [
115 "a rate-limited post",
116 429,
117 async () => {
118 const them = forge("u_iso_flood", "isoflood");
119 let last = await post(result(), "", them);
120 for (let i = 0; i < 40 && last.status !== 429; i += 1) last = await post(result(), "", them);
121 return last;
122 },
123 ],
124 // An empty Host header leaves `new URL(request.url)` nothing to parse: a
125 // real crash, caught by the `error` handler and answered as the floor.
126 ["a crash the error handler catches", 500, () => fetch(base + "/", { headers: { host: "" } })],
127 ];
128 for (const [label, status, run] of cases) {
129 const response = await run();
130 expect([label, response.status]).toEqual([label, status]);
131 expect([label, response.headers.get("cross-origin-opener-policy")]).toEqual([label, "same-origin"]);
132 expect([label, response.headers.get("cross-origin-resource-policy")]).toEqual([label, "same-origin"]);
133 }
134 });
135
94136 test("the first paint is kept out of shared caches; the bundle is kept in them", async () => {
95137 const page = await fetch(base + "/");
96138 expect(page.headers.get("cache-control")).toContain("private");
src/server.ts +5 −1
@@ -23,7 +23,9 @@ const ssh = process.env.FORGE_APP_SSH;
2323
2424 // The security floor every response stands on. Scripts and styles come from
2525 // this origin (inline styles allowed, inline scripts not), nothing frames us,
26 // browsers never guess a content type, and referrers stay on this site.
26 // browsers never guess a content type, referrers stay on this site, no
27 // window we open or that opens us keeps a handle on us, and no other origin
28 // may embed our bundle as a subresource.
2729 // The app's public address is what the forge told us, never the socket's.
2830 const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
2931 const secure = publicOrigin.startsWith("https:");
@@ -34,6 +36,8 @@ const SECURITY = {
3436 "x-content-type-options": "nosniff",
3537 "referrer-policy": "strict-origin-when-cross-origin",
3638 "permissions-policy": "camera=(), microphone=(), geolocation=()",
39 "cross-origin-opener-policy": "same-origin",
40 "cross-origin-resource-policy": "same-origin",
3741 // Once served over https, always: browsers remember for a year.
3842 ...(secure ? { "strict-transport-security": "max-age=31536000" } : {}),
3943 };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs