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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/192-the-security-floor-is-missing-its-two-is → main 1 commit fork
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. | ||
| 642 | 642 | `collect` and `gradients` run for real, through `worker.ts`, wherever |
| 643 | 643 | `src/rl/pool.test.ts` runs a pool. `scripts/train.ts` |
| 644 | 644 | 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. | |
| 651 | 659 | - `src/bundle.ts` bundles the client; `src/server.ts` runs it at start-up and |
| 652 | 660 | serves the result from memory as `/static/app.<hash>.js`, cached for a year. |
| 653 | 661 | `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 () => { | ||
| 91 | 91 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); |
| 92 | 92 | }); |
| 93 | 93 | |
| 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 | ||
| 94 | 136 | test("the first paint is kept out of shared caches; the bundle is kept in them", async () => { |
| 95 | 137 | const page = await fetch(base + "/"); |
| 96 | 138 | expect(page.headers.get("cache-control")).toContain("private"); |
src/server.ts +5 −1
| @@ -23,7 +23,9 @@ const ssh = process.env.FORGE_APP_SSH; | ||
| 23 | 23 | |
| 24 | 24 | // The security floor every response stands on. Scripts and styles come from |
| 25 | 25 | // 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. | |
| 27 | 29 | // The app's public address is what the forge told us, never the socket's. |
| 28 | 30 | const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin; |
| 29 | 31 | const secure = publicOrigin.startsWith("https:"); |
| @@ -34,6 +36,8 @@ const SECURITY = { | ||
| 34 | 36 | "x-content-type-options": "nosniff", |
| 35 | 37 | "referrer-policy": "strict-origin-when-cross-origin", |
| 36 | 38 | "permissions-policy": "camera=(), microphone=(), geolocation=()", |
| 39 | "cross-origin-opener-policy": "same-origin", | |
| 40 | "cross-origin-resource-policy": "same-origin", | |
| 37 | 41 | // Once served over https, always: browsers remember for a year. |
| 38 | 42 | ...(secure ? { "strict-transport-security": "max-age=31536000" } : {}), |
| 39 | 43 | }; |