Public: anyone can read this; the crew can change it.
forked from trevato/cs
The security floor stops trusting inline styles: one hashed style block, and 'unsafe-inline' leaves the CSP #202
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/129-the-security-floor-stops-trusting-inline → main 2 commits fork
9 files
changed +347
−49
README.md +21 −1
| @@ -717,6 +717,24 @@ never checked itself. | ||
| 717 | 717 | `HOST` is also what a shell sets to the machine's name, so only an address — |
| 718 | 718 | an IPv4 or IPv6 literal, or `localhost` — is bound and anything else is |
| 719 | 719 | ignored with a line in the log rather than failing to resolve at start-up. |
| 720 | - `src/policy.ts` — **the content security policy**, and the one part of it that | |
| 721 | is not a constant. The floor is `default-src 'self'`, no inline script, | |
| 722 | `frame-ancestors 'none'`, `base-uri 'none'`, `form-action 'self'`, and | |
| 723 | `img-src 'self' data:` for the icon in the head. The style block the shell | |
| 724 | writes into every document — which is why the first paint is one request for | |
| 725 | the HTML and one for the bundle — is named by its hash rather than waved | |
| 726 | through: `style-src 'self' 'sha256-…'` over the exact text `styleFor()` puts | |
| 727 | between the tags, computed once per page at start-up in `src/server.ts` and | |
| 728 | never per request. There is **no `'unsafe-inline'` and no `'unsafe-hashes'`** | |
| 729 | anywhere, on any route, which `src/server.test.ts` asserts over every | |
| 730 | response this server sends, the 404 and the 500 included; the same test hashes | |
| 731 | each page's rendered style block and holds the header to it, so a game that | |
| 732 | adds a rule without telling the server fails a test rather than losing its | |
| 733 | styling in a browser. A page that needs a rule gets a class: an inline | |
| 734 | `style=` attribute would not apply. A style block that is not a constant | |
| 735 | cannot be hashed at start-up, which is the constraint a future page inherits. | |
| 736 | Responses with no document in them — a bundle, the leaderboard fragment, a | |
| 737 | refusal — carry `style-src 'self'` and accept no inline style at all. | |
| 720 | 738 | - `src/pages/shell.ts` — the document every game is served inside, and nothing |
| 721 | 739 | about any one game: `page({ title, description, heading, nav, env, pull, |
| 722 | 740 | commit, script, ssh, terminalLead, stageHtml, sectionsHtml, footer, |
| @@ -741,7 +759,9 @@ never checked itself. | ||
| 741 | 759 | is that game's own registry entry, never prose hand-written next door, which |
| 742 | 760 | is how it goes stale. |
| 743 | 761 | A rule only one game needs goes in `extraStyle`, never in the shared block, |
| 744 | and CSS handed in there cannot close the block. So **a second game's page | |
| 762 | and CSS handed in there cannot close the block. It has to be a constant the | |
| 763 | page exports, because `src/server.ts` hashes the shared block plus that page's | |
| 764 | own into the `style-src` it is served with. So **a second game's page | |
| 745 | 765 | writes its overlays, its touch buttons and its sections, and gets the rest**; |
| 746 | 766 | `src/pages/game.ts` is what that looks like for cs and `src/pages/range.ts` |
| 747 | 767 | for the range. The ids are the contract |
src/pages/game.ts +7 −1
| @@ -71,8 +71,14 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 71 | 71 | * This page's own CSS: the share link, which is an anchor and has to be as big |
| 72 | 72 | * as the buttons around it, and the id itself, which one click selects whole |
| 73 | 73 | * because the only thing anyone does with it is copy it. |
| 74 | * | |
| 75 | * Exported because the policy names it: `src/server.ts` hashes the shell's | |
| 76 | * block with this appended and puts that hash in this page's `style-src`, so | |
| 77 | * a rule added here has to be a rule the header knows about. It is a constant | |
| 78 | * for the same reason — a style block built per request could not be hashed | |
| 79 | * before the request. | |
| 74 | 80 | */ |
| 75 | const STYLE = ` | |
| 81 | export const STYLE = ` | |
| 76 | 82 | #thismatch code { user-select:all; word-break:break-all; } |
| 77 | 83 | a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7rem; border-radius:.45rem; |
| 78 | 84 | background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; } |
src/pages/miss.ts +5 −1
| @@ -36,8 +36,12 @@ const NAV = navFor(""); | ||
| 36 | 36 | * These pages' own CSS. The list of games is the way out of a miss, so each |
| 37 | 37 | * link is the same target the header's nav is — 44px on a side, with the |
| 38 | 38 | * padding pulled back so the text still lines up with the paragraph above it. |
| 39 | * | |
| 40 | * Exported because the policy names it: `src/server.ts` hashes the shell's | |
| 41 | * block with this appended and puts that hash in the `style-src` both of these | |
| 42 | * pages carry. | |
| 39 | 43 | */ |
| 40 | const STYLE = ` | |
| 44 | export const STYLE = ` | |
| 41 | 45 | section.miss p { max-width: 42rem; } |
| 42 | 46 | ul.games { list-style:none; padding:0; margin:.5rem 0 0; } |
| 43 | 47 | ul.games li { padding:.15rem 0; border-bottom:1px solid var(--line); color:var(--dim); font-size:.92rem; } |
src/pages/shell.test.ts +23 −1
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | import { escape, nav, page, stage, terminal, where } from "./shell.ts"; | |
| 9 | import { escape, nav, page, stage, styleFor, terminal, where } from "./shell.ts"; | |
| 10 | 10 | import { GAMES, navFor } from "./games.ts"; |
| 11 | 11 | |
| 12 | 12 | const XSS = `</script><script>alert(1)</script>`; |
| @@ -132,6 +132,28 @@ test("a game's own style is appended, and cannot close the block", () => { | ||
| 132 | 132 | expect(broken.match(/<script/g)?.length).toBe(1); |
| 133 | 133 | }); |
| 134 | 134 | |
| 135 | test("the style block is exactly the text the hash is taken over", () => { | |
| 136 | // The CSP carries a sha256 of what is between the tags, and a browser hashes | |
| 137 | // those bytes and no others. So the document must interpolate the same string | |
| 138 | // the hash is taken over — this is that string, and it is not reassembled | |
| 139 | // anywhere: nothing here re-reads a file, and the document below is checked | |
| 140 | // against it character for character. | |
| 141 | const extra = "#board { color: red; }"; | |
| 142 | for (const css of [undefined, extra]) { | |
| 143 | const text = styleFor(css); | |
| 144 | expect(page({ ...options, extraStyle: css })).toContain(`<style>${text}</style>`); | |
| 145 | // Whitespace included: the shared block opens with its own newline. | |
| 146 | expect(text.startsWith("\n:root {")).toBe(true); | |
| 147 | expect(text.endsWith(css === undefined ? "}\n" : extra)).toBe(true); | |
| 148 | } | |
| 149 | // A page with no CSS of its own gets the shared block and nothing appended. | |
| 150 | expect(styleFor()).toBe(styleFor(undefined)); | |
| 151 | expect(styleFor(extra)).toBe(styleFor() + extra); | |
| 152 | // And what is hashed is what was escaped: a page's CSS cannot close the block | |
| 153 | // in the document or slip past the hash by being written differently here. | |
| 154 | expect(styleFor("}</style>")).toBe(styleFor() + "}\\3c /style>"); | |
| 155 | }); | |
| 156 | ||
| 135 | 157 | test("the header carries the games, and the one you are on is not a link", () => { |
| 136 | 158 | const html = page(options); |
| 137 | 159 | expect(html).toContain( |
src/pages/shell.ts +25 −1
| @@ -20,6 +20,15 @@ | ||
| 20 | 20 | * id, a nav entry's href and label); a parameter that carries markup the |
| 21 | 21 | * caller already rendered says so in its name (`stageHtml`, `sectionsHtml`, an |
| 22 | 22 | * overlay's `html`) and is written through untouched. There is no inline script: the CSP is `default-src 'self'`. |
| 23 | * | |
| 24 | * The one inline thing left is the style block, and it is named in the policy | |
| 25 | * rather than waved through: `styleFor(extraStyle)` is the exact text that ends | |
| 26 | * up between the tags, and `src/policy.ts` hashes that string into the | |
| 27 | * `style-src 'sha256-…'` the response carries. The document and the header are | |
| 28 | * one string apart, so they cannot say different things — and nothing else | |
| 29 | * inline, here or in any page, is accepted by a browser. A page that needs a | |
| 30 | * rule gets a class and puts the rule here or in its own `extraStyle`; an | |
| 31 | * inline `style=` attribute would not apply. | |
| 23 | 32 | */ |
| 24 | 33 | |
| 25 | 34 | export const escape = (s: string): string => |
| @@ -94,6 +103,21 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; } | ||
| 94 | 103 | @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } } |
| 95 | 104 | `; |
| 96 | 105 | |
| 106 | /** | |
| 107 | * Exactly what a page with this extra CSS puts between its `<style>` tags: the | |
| 108 | * shared block, then that page's own, escaped the way the document escapes it. | |
| 109 | * Byte for byte — the leading newline, every space — because a browser hashes | |
| 110 | * what is between the tags and nothing near it. | |
| 111 | * | |
| 112 | * It is a function rather than a constant because the header needs the same | |
| 113 | * string the document does: `src/server.ts` hashes this at start-up, once per | |
| 114 | * page, and `page()` below interpolates it. Neither re-reads a file, and a page | |
| 115 | * whose style block is not known before a request cannot be hashed at all — | |
| 116 | * which is why `extraStyle` is a constant in every page that has one. | |
| 117 | */ | |
| 118 | export const styleFor = (extraStyle?: string): string => | |
| 119 | `${STYLE}${extraStyle === undefined ? "" : cssSafe(extraStyle)}`; | |
| 120 | ||
| 97 | 121 | /** |
| 98 | 122 | * The icon, in the document rather than beside it: a crosshair on the page's |
| 99 | 123 | * own background, written as an SVG data URI. A browser with no `<link |
| @@ -229,7 +253,7 @@ export function page(options: PageOptions): string { | ||
| 229 | 253 | <link rel="icon" href="${FAVICON}"> |
| 230 | 254 | <title>${escape(options.title)}</title> |
| 231 | 255 | <meta name="description" content="${escape(options.description)}"> |
| 232 | <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style> | |
| 256 | <style>${styleFor(options.extraStyle)}</style> | |
| 233 | 257 | <header> |
| 234 | 258 | <h1>${escape(options.heading)}</h1> |
| 235 | 259 | ${nav(options.nav)} |
src/policy.test.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** | |
| 2 | * The policy is a string a browser parses, so this reads like a hand check: the | |
| 3 | * hash against a value computed the other way round, and the sentence against | |
| 4 | * the one the header should say, word for word. | |
| 5 | */ | |
| 6 | ||
| 7 | import { expect, test } from "bun:test"; | |
| 8 | import { createHash } from "node:crypto"; | |
| 9 | import { policyFor, sha256 } from "./policy.ts"; | |
| 10 | ||
| 11 | test("the hash is base64 sha256 of the bytes, checked against another implementation", () => { | |
| 12 | // Node's own hasher, not Bun's, so the two have to agree on the same answer. | |
| 13 | for (const text of ["", "body { color: red; }", "\né { content: '☺'; }\n"]) | |
| 14 | expect(sha256(text)).toBe(createHash("sha256").update(text, "utf8").digest("base64")); | |
| 15 | // The one value anyone can check by hand: the empty string. | |
| 16 | expect(sha256("")).toBe("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="); | |
| 17 | // Non-ASCII is hashed as utf-8, which is what the document is served as. | |
| 18 | expect(sha256("é")).not.toBe(sha256("e")); | |
| 19 | }); | |
| 20 | ||
| 21 | test("a document's policy names its style block, and nothing else inline", () => { | |
| 22 | const css = "body { color: red; }"; | |
| 23 | expect(policyFor(css)).toBe( | |
| 24 | `default-src 'self'; style-src 'self' 'sha256-${sha256(css)}'; img-src 'self' data:; ` + | |
| 25 | `frame-ancestors 'none'; base-uri 'none'; form-action 'self'`, | |
| 26 | ); | |
| 27 | // Nothing is waved through: not inline styles in general, not inline scripts, | |
| 28 | // not a style attribute through 'unsafe-hashes'. | |
| 29 | for (const policy of [policyFor(css), policyFor()]) { | |
| 30 | expect(policy).not.toContain("unsafe-inline"); | |
| 31 | expect(policy).not.toContain("unsafe-hashes"); | |
| 32 | expect(policy).not.toContain("unsafe-eval"); | |
| 33 | expect(policy).toContain("default-src 'self'"); | |
| 34 | expect(policy).toContain("frame-ancestors 'none'"); | |
| 35 | expect(policy).toContain("base-uri 'none'"); | |
| 36 | } | |
| 37 | // A response with no document in it allows no inline style at all. | |
| 38 | expect(policyFor()).toContain("style-src 'self'; "); | |
| 39 | expect(policyFor()).not.toContain("sha256"); | |
| 40 | // One rule more is one hash different: the header cannot go stale quietly. | |
| 41 | expect(policyFor(css)).not.toBe(policyFor(`${css} p { margin:0; }`)); | |
| 42 | }); |
src/policy.ts added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | /** | |
| 2 | * The content security policy: one sentence, and the one part of it that is | |
| 3 | * not a constant. | |
| 4 | * | |
| 5 | * Everything here is fixed — scripts and everything else come from this origin, | |
| 6 | * nothing frames us, no `<base>` may move a relative URL, forms post here — | |
| 7 | * except the style block. The shell writes its CSS into the document so the | |
| 8 | * first paint costs one request for the HTML and one for the bundle, and a | |
| 9 | * policy that allowed that with `'unsafe-inline'` would allow every other | |
| 10 | * inline style with it, including one an injection wrote. A `sha256-…` source | |
| 11 | * names that exact block instead: the same CSS, inline, and nothing else. | |
| 12 | * | |
| 13 | * The hash is over the bytes between the tags, which is what `styleFor()` in | |
| 14 | * `./pages/shell.ts` returns. A response with no document in it — a bundle, a | |
| 15 | * fragment, a refusal — passes no style at all and allows no inline style at | |
| 16 | * all. There is no `'unsafe-hashes'` here either: a `style=` attribute is not | |
| 17 | * accepted anywhere, and a page that wants a rule gets a class. | |
| 18 | */ | |
| 19 | ||
| 20 | /** Base64 of the SHA-256 of this text, as a CSP source spells it. */ | |
| 21 | export const sha256 = (text: string): string => new Bun.CryptoHasher("sha256").update(text, "utf8").digest("base64"); | |
| 22 | ||
| 23 | /** | |
| 24 | * The policy for a response carrying this style block, or — with nothing handed | |
| 25 | * in — for a response carrying no inline style at all. | |
| 26 | */ | |
| 27 | export const policyFor = (styleText?: string): string => | |
| 28 | `default-src 'self'; style-src 'self'${styleText === undefined ? "" : ` 'sha256-${sha256(styleText)}'`}; ` + | |
| 29 | `img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`; |
src/server.test.ts +103 −11
| @@ -33,6 +33,35 @@ const result = (over: Record<string, unknown> = {}): Record<string, unknown> => | ||
| 33 | 33 | ...over, |
| 34 | 34 | }); |
| 35 | 35 | |
| 36 | /** | |
| 37 | * The floor, as a thing two responses can be compared by. Every header in it is | |
| 38 | * a constant except one: the policy carries the sha256 of that page's own style | |
| 39 | * block, so "the same floor" means the same sentence with the hash taken out. | |
| 40 | * The hash itself is checked against the bytes it describes, page by page, in | |
| 41 | * `the policy names each page's own style block` below. | |
| 42 | */ | |
| 43 | const FLOOR = ["content-security-policy", "x-content-type-options", "referrer-policy", | |
| 44 | "permissions-policy", "content-type"]; | |
| 45 | ||
| 46 | const withoutStyleHash = (policy: string | null): string | null => | |
| 47 | policy === null ? null : policy.replace(/'sha256-[A-Za-z0-9+/=]+'/, "'sha256-…'"); | |
| 48 | ||
| 49 | const floorOf = (response: Response): Record<string, string | null> => | |
| 50 | Object.fromEntries(FLOOR.map((header) => [ | |
| 51 | header, | |
| 52 | header === "content-security-policy" ? withoutStyleHash(response.headers.get(header)) : response.headers.get(header), | |
| 53 | ])); | |
| 54 | ||
| 55 | /** The text between this document's one pair of `<style>` tags, which is what a browser hashes. */ | |
| 56 | const styleBlock = (body: string): string => /<style>([\s\S]*?)<\/style>/.exec(body)?.[1] as string; | |
| 57 | ||
| 58 | const sha256 = (text: string): string => new Bun.CryptoHasher("sha256").update(text, "utf8").digest("base64"); | |
| 59 | ||
| 60 | /** The whole policy a document carrying this style block must be served with. */ | |
| 61 | const policyFor = (block: string): string => | |
| 62 | `default-src 'self'; style-src 'self' 'sha256-${sha256(block)}'; img-src 'self' data:; ` + | |
| 63 | `frame-ancestors 'none'; base-uri 'none'; form-action 'self'`; | |
| 64 | ||
| 36 | 65 | const post = (body: unknown, cookie = "", headers: Record<string, string> = {}): Promise<Response> => |
| 37 | 66 | fetch(base + "/api/match", { |
| 38 | 67 | method: "POST", |
| @@ -239,10 +268,9 @@ test("every game in the registry is served: its own page, its own bundle, its ow | ||
| 239 | 268 | expect(body).toContain(`<a href="${escape(other.href)}">${escape(other.label)}</a>`); |
| 240 | 269 | expect(body.indexOf("<nav")).toBeLessThan(body.indexOf("<main>")); |
| 241 | 270 | |
| 242 | // The same floor under every one of them. | |
| 243 | for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy", | |
| 244 | "permissions-policy", "content-type"]) | |
| 245 | expect(response.headers.get(header)).toBe(floor.headers.get(header)); | |
| 271 | // The same floor under every one of them, hash aside: that one is its own | |
| 272 | // style block's, and is checked against it below. | |
| 273 | expect(floorOf(response)).toEqual(floorOf(floor)); | |
| 246 | 274 | |
| 247 | 275 | // HEAD is GET without a body, on every game's path. |
| 248 | 276 | const head = await server.fetch(new Request(base + game.href, { method: "HEAD" })); |
| @@ -299,10 +327,9 @@ test("a wrong path lands on the same document a game does, not on a fragment", a | ||
| 299 | 327 | expect(body).toContain("<footer>"); |
| 300 | 328 | // Nothing runs on it, so nothing is downloaded for it. |
| 301 | 329 | expect(body).not.toContain("<script"); |
| 302 | // The whole floor, header for header, is the same as the front page's. | |
| 303 | for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy", | |
| 304 | "permissions-policy", "content-type"]) | |
| 305 | expect(response.headers.get(header)).toBe(floor.headers.get(header)); | |
| 330 | // The whole floor, header for header, is the same as the front page's — bar | |
| 331 | // the style hash, which is this page's own block. | |
| 332 | expect(floorOf(response)).toEqual(floorOf(floor)); | |
| 306 | 333 | expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); |
| 307 | 334 | }); |
| 308 | 335 | |
| @@ -348,10 +375,11 @@ test("a bug is a document too, and the error stays in the log", async () => { | ||
| 348 | 375 | |
| 349 | 376 | test("the range stands on the same security floor as the game", async () => { |
| 350 | 377 | const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]); |
| 351 | for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy", | |
| 352 | "permissions-policy", "content-type"]) | |
| 353 | expect(range.headers.get(header)).toBe(game.headers.get(header)); | |
| 378 | expect(floorOf(range)).toEqual(floorOf(game)); | |
| 354 | 379 | expect(range.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 380 | // The shooter has CSS of its own and the range has none, so the one thing | |
| 381 | // that differs is the style hash — each page's own, never the other's. | |
| 382 | expect(range.headers.get("content-security-policy")).not.toBe(game.headers.get("content-security-policy")); | |
| 355 | 383 | // No session is started for a page that stores nothing. |
| 356 | 384 | expect(range.headers.get("set-cookie")).toBeNull(); |
| 357 | 385 | }); |
| @@ -395,6 +423,70 @@ test("stands on the security floor", async () => { | ||
| 395 | 423 | expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin"); |
| 396 | 424 | }); |
| 397 | 425 | |
| 426 | test("the policy names each page's own style block, hashed from the bytes the page serves", async () => { | |
| 427 | // The hash is computed here the way a browser computes it: over the text | |
| 428 | // between this document's own <style> tags, whitespace and all. A game that | |
| 429 | // adds a rule and does not tell src/server.ts about it fails here, rather | |
| 430 | // than losing its styling in a browser where nobody is watching. | |
| 431 | const seen = new Set<string>(); | |
| 432 | for (const game of GAMES) { | |
| 433 | const response = await fetch(base + game.href); | |
| 434 | const block = styleBlock(await response.text()); | |
| 435 | expect(block.length).toBeGreaterThan(500); | |
| 436 | expect(response.headers.get("content-security-policy")).toBe(policyFor(block)); | |
| 437 | seen.add(block); | |
| 438 | } | |
| 439 | // The 404 goes through the same shell, and carries the hash of what it serves. | |
| 440 | const miss = await fetch(base + "/nope"); | |
| 441 | expect(miss.status).toBe(404); | |
| 442 | expect(miss.headers.get("content-security-policy")).toBe(policyFor(styleBlock(await miss.text()))); | |
| 443 | ||
| 444 | // And so does the 500, which nothing routes to: the handler itself. | |
| 445 | const console_error = console.error; | |
| 446 | console.error = () => {}; | |
| 447 | const error = onError(new Error("boom")); | |
| 448 | console.error = console_error; | |
| 449 | expect(error.status).toBe(500); | |
| 450 | expect(error.headers.get("content-security-policy")).toBe(policyFor(styleBlock(await error.text()))); | |
| 451 | ||
| 452 | // A page with CSS of its own has a block of its own, so the hashes are not | |
| 453 | // one hash wearing three hats. | |
| 454 | expect(seen.size).toBeGreaterThan(1); | |
| 455 | }); | |
| 456 | ||
| 457 | test("`unsafe-inline` is in no response this server sends, on any route", async () => { | |
| 458 | // Not in a header, not in a body, not on a page, a fragment, a file, a | |
| 459 | // refusal, a miss or a bug. The style block is named by its hash instead. | |
| 460 | const player = as("u-csp", "csp"); | |
| 461 | const console_error = console.error; | |
| 462 | console.error = () => {}; | |
| 463 | const bug = onError(new Error("boom")); | |
| 464 | console.error = console_error; | |
| 465 | ||
| 466 | const script = /src="(\/static\/app\.[0-9a-f]+\.js)"/.exec(await (await fetch(base + "/")).text())?.[1] as string; | |
| 467 | const responses = [ | |
| 468 | ...(await Promise.all(GAMES.map((game) => fetch(base + game.href)))), | |
| 469 | ...(await Promise.all(GAMES.map((game) => server.fetch(new Request(base + game.href, { method: "HEAD" }))))), | |
| 470 | await fetch(base + "/nope"), | |
| 471 | await fetch(base + script), | |
| 472 | await post(result({ name: "csp" }), "", player), // 200, the leaderboard fragment | |
| 473 | await post("not a result", "", player), // 422 | |
| 474 | await post(result(), "", { ...player, "sec-fetch-site": "cross-site" }), // 403 | |
| 475 | await fetch(base + "/api/match", { method: "POST", headers: { ...player }, body: "{" }), // 400 | |
| 476 | await fetch(base + "/api/match", { method: "POST", headers: { ...player }, body: "x".repeat(20 * 1024) }), // 413 | |
| 477 | bug, | |
| 478 | ]; | |
| 479 | expect(responses.map((r) => r.status)).toEqual([200, 200, 200, 200, 200, 200, 404, 200, 200, 422, 403, 400, 413, 500]); | |
| 480 | for (const response of responses) { | |
| 481 | for (const [name, value] of response.headers) expect(`${name}: ${value}`).not.toContain("unsafe-inline"); | |
| 482 | expect(response.headers.get("content-security-policy")).toContain("style-src 'self'"); | |
| 483 | expect(await response.text()).not.toContain("unsafe-inline"); | |
| 484 | } | |
| 485 | // And nothing anywhere leans on a style attribute instead, which no hash | |
| 486 | // would cover and 'unsafe-hashes' is not the way out of. | |
| 487 | for (const game of GAMES) expect(await (await fetch(base + game.href)).text()).not.toContain(" style="); | |
| 488 | }); | |
| 489 | ||
| 398 | 490 | test("a result is stored and comes back as the leaderboard", async () => { |
| 399 | 491 | const response = await post(result({ name: "Vesper" })); |
| 400 | 492 | expect(response.status).toBe(200); |
src/server.ts +92 −33
| @@ -8,8 +8,15 @@ | ||
| 8 | 8 | * What a game is — its bundle's name, its path, its label, its source and its |
| 9 | 9 | * ceiling — is one entry in `src/games.ts`, which the build reads too. The read |
| 10 | 10 | * routes below are that registry against `RENDER`, the one map from a game's |
| 11 | * name to the function that draws its page. The POST route is not in it: | |
| 12 | * `/api/match` belongs to the shooter, not to games in general. | |
| 11 | * name to the page that draws it and the CSS that page appends to the shell's | |
| 12 | * style block. The POST route is not in it: `/api/match` belongs to the | |
| 13 | * shooter, not to games in general. | |
| 14 | * | |
| 15 | * The floor every response stands on is here too, and the one part of it that | |
| 16 | * is not a constant is the style hash: each page's block is hashed once, at | |
| 17 | * start-up, into that page's `style-src 'sha256-…'` — so the block stays inline | |
| 18 | * and the first paint stays two requests, without `'unsafe-inline'` waving in | |
| 19 | * every other inline style with it. `src/policy.ts` writes the sentence. | |
| 13 | 20 | * |
| 14 | 21 | * Both halves of a game are a start-up contract: an entry with no page and an |
| 15 | 22 | * entry with no bundle in static/ each throw before this listens, so neither is |
| @@ -30,10 +37,12 @@ import { openDatabase, type Player, saveMatch } from "./db.ts"; | ||
| 30 | 37 | import { GAMES } from "./games.ts"; |
| 31 | 38 | import { createLimiter } from "./limit.ts"; |
| 32 | 39 | import { examplePage } from "./pages/example.ts"; |
| 33 | import { type Asked, gamePage, statsHtml } from "./pages/game.ts"; | |
| 40 | import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts"; | |
| 34 | 41 | import { matchFrom } from "./matchid.ts"; |
| 35 | import { errorPage, notFoundPage } from "./pages/miss.ts"; | |
| 42 | import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; | |
| 36 | 43 | import { rangePage } from "./pages/range.ts"; |
| 44 | import { policyFor } from "./policy.ts"; | |
| 45 | import { styleFor } from "./pages/shell.ts"; | |
| 37 | 46 | import { cleanName, parseResult } from "./result.ts"; |
| 38 | 47 | import { who, type Who } from "./who.ts"; |
| 39 | 48 | |
| @@ -48,27 +57,47 @@ const ssh = process.env.FORGE_APP_SSH; | ||
| 48 | 57 | // every visitor is a guest, and a guest's score is labelled as one. |
| 49 | 58 | const identityKey = process.env.FORGE_APP_IDENTITY_KEY; |
| 50 | 59 | |
| 51 | // The security floor every response stands on. Scripts and styles come from | |
| 52 | // this origin (inline styles allowed, inline scripts not), nothing frames us, | |
| 53 | // browsers never guess a content type, and referrers stay on this site. | |
| 54 | // The app's public address is what the forge told us, never the socket's. | |
| 60 | // The security floor every response stands on. Everything comes from this | |
| 61 | // origin, nothing frames us, browsers never guess a content type, and referrers | |
| 62 | // stay on this site. The app's public address is what the forge told us, never | |
| 63 | // the socket's. | |
| 55 | 64 | const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin; |
| 56 | 65 | const secure = publicOrigin.startsWith("https:"); |
| 57 | 66 | |
| 58 | const SECURITY = { | |
| 59 | "content-security-policy": | |
| 60 | "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", | |
| 67 | type Headers = Record<string, string>; | |
| 68 | ||
| 69 | const floorWith = (policy: string): Headers => ({ | |
| 70 | "content-security-policy": policy, | |
| 61 | 71 | "x-content-type-options": "nosniff", |
| 62 | 72 | "referrer-policy": "strict-origin-when-cross-origin", |
| 63 | 73 | "permissions-policy": "camera=(), microphone=(), geolocation=()", |
| 64 | 74 | // Once served over https, always: browsers remember for a year. |
| 65 | 75 | ...(secure ? { "strict-transport-security": "max-age=31536000" } : {}), |
| 66 | }; | |
| 76 | }); | |
| 67 | 77 | |
| 68 | const html = (body: string, status = 200, extra: Record<string, string> = {}): Response => | |
| 78 | /** | |
| 79 | * The floor under a response that carries no document: a bundle, the | |
| 80 | * leaderboard fragment, a refusal. No inline style is accepted on it at all. | |
| 81 | */ | |
| 82 | const SECURITY = floorWith(policyFor()); | |
| 83 | ||
| 84 | /** | |
| 85 | * The floor under one page: the same sentence, with the sha256 of the exact | |
| 86 | * text that page puts between its `<style>` tags. The shell owns that text | |
| 87 | * (`styleFor`), so the hash and the document come from one string and the | |
| 88 | * block stays inline without `'unsafe-inline'` letting every other inline | |
| 89 | * style in with it. | |
| 90 | * | |
| 91 | * Called once per page below, at start-up, never per request: a page whose | |
| 92 | * style block is not a constant could not be hashed before the request, which | |
| 93 | * is why every page's `extraStyle` is one. | |
| 94 | */ | |
| 95 | const documentFloor = (extraStyle?: string): Headers => floorWith(policyFor(styleFor(extraStyle))); | |
| 96 | ||
| 97 | const html = (body: string, security: Headers = SECURITY, status = 200, extra: Headers = {}): Response => | |
| 69 | 98 | new Response(body, { |
| 70 | 99 | status, |
| 71 | headers: { "content-type": "text/html; charset=utf-8", ...SECURITY, ...extra }, | |
| 100 | headers: { "content-type": "text/html; charset=utf-8", ...security, ...extra }, | |
| 72 | 101 | }); |
| 73 | 102 | |
| 74 | 103 | // A browser on another site can make a person's browser POST here; it cannot |
| @@ -201,12 +230,26 @@ export function findScripts(dir = STATIC_DIR, games = GAMES): Map<string, string | ||
| 201 | 230 | const scripts = findScripts(); |
| 202 | 231 | |
| 203 | 232 | /** |
| 204 | * How one game's page is drawn for one request. Everything else about a game — | |
| 205 | * where it answers, what it is called, which bundle it ships — is data in | |
| 206 | * `./games.ts`; a page is a function and cannot be, so this is the map, and one | |
| 207 | * line here is the whole of what a third game adds to this file. | |
| 233 | * How one game's page is drawn for one request: its document, and any header | |
| 234 | * of its own — the shooter's session cookie is the only one. The status, the | |
| 235 | * content type and the floor are the same for all three and are put on below, | |
| 236 | * where the page's style hash is known. | |
| 208 | 237 | */ |
| 209 | type Render = (request: Request, script: string) => Response; | |
| 238 | type Drawn = { body: string; extra?: Headers }; | |
| 239 | ||
| 240 | /** | |
| 241 | * One game's page: what it draws, and the CSS it appends to the shell's style | |
| 242 | * block. The CSS is here because the header is: the hash of that page's block | |
| 243 | * is computed from it once, at start-up, and a page that adds a rule without | |
| 244 | * saying so here loses its styling in a browser — which is what | |
| 245 | * `src/server.test.ts` checks, hash against rendered page, for every game. | |
| 246 | * | |
| 247 | * Everything else about a game — where it answers, what it is called, which | |
| 248 | * bundle it ships — is data in `./games.ts`; a page is a function and cannot | |
| 249 | * be, so this is the map, and one entry here is the whole of what a third game | |
| 250 | * adds to this file. | |
| 251 | */ | |
| 252 | type Render = { extraStyle?: string; draw: (request: Request, script: string) => Drawn }; | |
| 210 | 253 | |
| 211 | 254 | /** |
| 212 | 255 | * What a link to the shooter asked for. `/?match=<id>` plays that match again |
| @@ -233,19 +276,23 @@ const asked = (request: Request): Asked => { | ||
| 233 | 276 | const RENDER: Record<string, Render> = { |
| 234 | 277 | // The shooter: the forge's identity, this environment's database, and a |
| 235 | 278 | // session cookie for a browser that arrives without one. |
| 236 | app: (request, script) => { | |
| 237 | const session = readSession(request); | |
| 238 | return html( | |
| 239 | gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey), asked: asked(request) }), | |
| 240 | 200, | |
| 241 | session === null ? { "set-cookie": sessionCookie(newSession()) } : {}, | |
| 242 | ); | |
| 279 | app: { | |
| 280 | extraStyle: GAME_STYLE, | |
| 281 | draw: (request, script) => { | |
| 282 | const session = readSession(request); | |
| 283 | const extra: Headers = session === null ? { "set-cookie": sessionCookie(newSession()) } : {}; | |
| 284 | return { | |
| 285 | body: gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey), asked: asked(request) }), | |
| 286 | extra, | |
| 287 | }; | |
| 288 | }, | |
| 243 | 289 | }, |
| 244 | 290 | // The range: its own page and its own bundle, and no session — it stores |
| 245 | // nothing, so it has nothing to name a browser for. | |
| 246 | range: (_request, script) => html(rangePage({ env, pull, commit, script, ssh })), | |
| 291 | // nothing, so it has nothing to name a browser for. No CSS of its own either: | |
| 292 | // its style block is the shell's, exactly. | |
| 293 | range: { draw: (_request, script) => ({ body: rangePage({ env, pull, commit, script, ssh }) }) }, | |
| 247 | 294 | // The example: the guide's game with a page around it, and no session either. |
| 248 | example: (_request, script) => html(examplePage({ env, pull, commit, script, ssh })), | |
| 295 | example: { draw: (_request, script) => ({ body: examplePage({ env, pull, commit, script, ssh }) }) }, | |
| 249 | 296 | }; |
| 250 | 297 | |
| 251 | 298 | // The read routes, resolved once at start-up: the path each game answers on, |
| @@ -258,7 +305,16 @@ const READ = new Map<string, (request: Request) => Response>( | ||
| 258 | 305 | if (render === undefined) throw new Error(`the game ${JSON.stringify(game.name)} has no page to render it`); |
| 259 | 306 | // Every game resolved to a bundle that exists, or this module threw above. |
| 260 | 307 | const script = scripts.get(game.name) as string; |
| 261 | return [game.href, (request: Request) => render(request, script)]; | |
| 308 | // This game's floor, hashed here and not again: the style block it serves | |
| 309 | // is the same on every request, so its policy is too. | |
| 310 | const security = documentFloor(render.extraStyle); | |
| 311 | return [ | |
| 312 | game.href, | |
| 313 | (request: Request) => { | |
| 314 | const { body, extra } = render.draw(request, script); | |
| 315 | return html(body, security, 200, extra); | |
| 316 | }, | |
| 317 | ]; | |
| 262 | 318 | }), |
| 263 | 319 | ); |
| 264 | 320 | |
| @@ -271,6 +327,9 @@ const READ = new Map<string, (request: Request) => Response>( | ||
| 271 | 327 | // is escaped where it is rendered, like anywhere else markup is made here. |
| 272 | 328 | const NOT_FOUND = notFoundPage({ env, pull, commit, ssh }); |
| 273 | 329 | const SERVER_ERROR = errorPage({ env, pull, commit, ssh }); |
| 330 | // One style block for both, so one hash: they are the same document with | |
| 331 | // different words in it. | |
| 332 | const MISS_SECURITY = documentFloor(MISS_STYLE); | |
| 274 | 333 | |
| 275 | 334 | /** |
| 276 | 335 | * What a bug answers with: the floor, the shell's document and nothing else — |
| @@ -282,7 +341,7 @@ const SERVER_ERROR = errorPage({ env, pull, commit, ssh }); | ||
| 282 | 341 | */ |
| 283 | 342 | export function onError(error: unknown): Response { |
| 284 | 343 | console.error(error); |
| 285 | return html(SERVER_ERROR, 500); | |
| 344 | return html(SERVER_ERROR, MISS_SECURITY, 500); | |
| 286 | 345 | } |
| 287 | 346 | |
| 288 | 347 | // HEAD is GET without the body: the headers, the status and nothing to read. |
| @@ -322,7 +381,7 @@ export const server = Bun.serve({ | ||
| 322 | 381 | const session = held ?? newSession(); |
| 323 | 382 | const stored = saveMatch(db, playerOf(visitor, session, result.name), result); |
| 324 | 383 | if (!stored) return new Response("not yours", { status: 409, headers: SECURITY }); |
| 325 | return html(statsHtml(db), 200, { "set-cookie": sessionCookie(session) }); | |
| 384 | return html(statsHtml(db), SECURITY, 200, { "set-cookie": sessionCookie(session) }); | |
| 326 | 385 | } |
| 327 | 386 | |
| 328 | 387 | // The games, one table rather than one branch each. HEAD is GET without a |
| @@ -346,7 +405,7 @@ export const server = Bun.serve({ | ||
| 346 | 405 | |
| 347 | 406 | // Nothing answers here. HEAD is GET without a body on a miss too: the |
| 348 | 407 | // status and the headers, and nothing to read. |
| 349 | return withoutBodyIfHead(request, html(NOT_FOUND, 404)); | |
| 408 | return withoutBodyIfHead(request, html(NOT_FOUND, MISS_SECURITY, 404)); | |
| 350 | 409 | }, |
| 351 | 410 | error: onError, |
| 352 | 411 | }); |
new above
offline