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

Merged opened this

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

Merged

torque/cs-engine:torque/129-the-security-floor-stops-trusting-inline → main 2 commits fork

  • Merged. torque/129-the-security-floor-stops-trusting-inline was merged into main as c2956bd.

Review

Sign in to review this change.
9 files changed +347 −49
README.md +21 −1
@@ -717,6 +717,24 @@ never checked itself.
717717 `HOST` is also what a shell sets to the machine's name, so only an address —
718718 an IPv4 or IPv6 literal, or `localhost` — is bound and anything else is
719719 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.
720738 - `src/pages/shell.ts` — the document every game is served inside, and nothing
721739 about any one game: `page({ title, description, heading, nav, env, pull,
722740 commit, script, ssh, terminalLead, stageHtml, sectionsHtml, footer,
@@ -741,7 +759,9 @@ never checked itself.
741759 is that game's own registry entry, never prose hand-written next door, which
742760 is how it goes stale.
743761 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
745765 writes its overlays, its touch buttons and its sections, and gets the rest**;
746766 `src/pages/game.ts` is what that looks like for cs and `src/pages/range.ts`
747767 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
7171 * This page's own CSS: the share link, which is an anchor and has to be as big
7272 * as the buttons around it, and the id itself, which one click selects whole
7373 * 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.
7480 */
75 const STYLE = `
81 export const STYLE = `
7682 #thismatch code { user-select:all; word-break:break-all; }
7783 a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7rem; border-radius:.45rem;
7884 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("");
3636 * These pages' own CSS. The list of games is the way out of a miss, so each
3737 * link is the same target the header's nav is — 44px on a side, with the
3838 * 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.
3943 */
40 const STYLE = `
44 export const STYLE = `
4145 section.miss p { max-width: 42rem; }
4246 ul.games { list-style:none; padding:0; margin:.5rem 0 0; }
4347 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 @@
66 */
77
88 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";
1010 import { GAMES, navFor } from "./games.ts";
1111
1212 const XSS = `</script><script>alert(1)</script>`;
@@ -132,6 +132,28 @@ test("a game's own style is appended, and cannot close the block", () => {
132132 expect(broken.match(/<script/g)?.length).toBe(1);
133133 });
134134
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
135157 test("the header carries the games, and the one you are on is not a link", () => {
136158 const html = page(options);
137159 expect(html).toContain(
src/pages/shell.ts +25 −1
@@ -20,6 +20,15 @@
2020 * id, a nav entry's href and label); a parameter that carries markup the
2121 * caller already rendered says so in its name (`stageHtml`, `sectionsHtml`, an
2222 * 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.
2332 */
2433
2534 export const escape = (s: string): string =>
@@ -94,6 +103,21 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
94103 @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } }
95104 `;
96105
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
97121 /**
98122 * The icon, in the document rather than beside it: a crosshair on the page's
99123 * own background, written as an SVG data URI. A browser with no `<link
@@ -229,7 +253,7 @@ export function page(options: PageOptions): string {
229253 <link rel="icon" href="${FAVICON}">
230254 <title>${escape(options.title)}</title>
231255 <meta name="description" content="${escape(options.description)}">
232 <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style>
256 <style>${styleFor(options.extraStyle)}</style>
233257 <header>
234258 <h1>${escape(options.heading)}</h1>
235259 ${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> =>
3333 ...over,
3434 });
3535
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
3665 const post = (body: unknown, cookie = "", headers: Record<string, string> = {}): Promise<Response> =>
3766 fetch(base + "/api/match", {
3867 method: "POST",
@@ -239,10 +268,9 @@ test("every game in the registry is served: its own page, its own bundle, its ow
239268 expect(body).toContain(`<a href="${escape(other.href)}">${escape(other.label)}</a>`);
240269 expect(body.indexOf("<nav")).toBeLessThan(body.indexOf("<main>"));
241270
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));
246274
247275 // HEAD is GET without a body, on every game's path.
248276 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
299327 expect(body).toContain("<footer>");
300328 // Nothing runs on it, so nothing is downloaded for it.
301329 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));
306333 expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
307334 });
308335
@@ -348,10 +375,11 @@ test("a bug is a document too, and the error stays in the log", async () => {
348375
349376 test("the range stands on the same security floor as the game", async () => {
350377 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));
354379 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"));
355383 // No session is started for a page that stores nothing.
356384 expect(range.headers.get("set-cookie")).toBeNull();
357385 });
@@ -395,6 +423,70 @@ test("stands on the security floor", async () => {
395423 expect(response.headers.get("referrer-policy")).toBe("strict-origin-when-cross-origin");
396424 });
397425
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
398490 test("a result is stored and comes back as the leaderboard", async () => {
399491 const response = await post(result({ name: "Vesper" }));
400492 expect(response.status).toBe(200);
src/server.ts +92 −33
@@ -8,8 +8,15 @@
88 * What a game is — its bundle's name, its path, its label, its source and its
99 * ceiling — is one entry in `src/games.ts`, which the build reads too. The read
1010 * 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.
1320 *
1421 * Both halves of a game are a start-up contract: an entry with no page and an
1522 * 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";
3037 import { GAMES } from "./games.ts";
3138 import { createLimiter } from "./limit.ts";
3239 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";
3441 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";
3643 import { rangePage } from "./pages/range.ts";
44 import { policyFor } from "./policy.ts";
45 import { styleFor } from "./pages/shell.ts";
3746 import { cleanName, parseResult } from "./result.ts";
3847 import { who, type Who } from "./who.ts";
3948
@@ -48,27 +57,47 @@ const ssh = process.env.FORGE_APP_SSH;
4857 // every visitor is a guest, and a guest's score is labelled as one.
4958 const identityKey = process.env.FORGE_APP_IDENTITY_KEY;
5059
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.
5564 const publicOrigin = new URL(process.env.FORGE_APP_URL ?? "http://localhost").origin;
5665 const secure = publicOrigin.startsWith("https:");
5766
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,
6171 "x-content-type-options": "nosniff",
6272 "referrer-policy": "strict-origin-when-cross-origin",
6373 "permissions-policy": "camera=(), microphone=(), geolocation=()",
6474 // Once served over https, always: browsers remember for a year.
6575 ...(secure ? { "strict-transport-security": "max-age=31536000" } : {}),
66 };
76 });
6777
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 =>
6998 new Response(body, {
7099 status,
71 headers: { "content-type": "text/html; charset=utf-8", ...SECURITY, ...extra },
100 headers: { "content-type": "text/html; charset=utf-8", ...security, ...extra },
72101 });
73102
74103 // 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
201230 const scripts = findScripts();
202231
203232 /**
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.
208237 */
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 };
210253
211254 /**
212255 * What a link to the shooter asked for. `/?match=<id>` plays that match again
@@ -233,19 +276,23 @@ const asked = (request: Request): Asked => {
233276 const RENDER: Record<string, Render> = {
234277 // The shooter: the forge's identity, this environment's database, and a
235278 // 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 },
243289 },
244290 // 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 }) }) },
247294 // 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 }) }) },
249296 };
250297
251298 // 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>(
258305 if (render === undefined) throw new Error(`the game ${JSON.stringify(game.name)} has no page to render it`);
259306 // Every game resolved to a bundle that exists, or this module threw above.
260307 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 ];
262318 }),
263319 );
264320
@@ -271,6 +327,9 @@ const READ = new Map<string, (request: Request) => Response>(
271327 // is escaped where it is rendered, like anywhere else markup is made here.
272328 const NOT_FOUND = notFoundPage({ env, pull, commit, ssh });
273329 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);
274333
275334 /**
276335 * 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 });
282341 */
283342 export function onError(error: unknown): Response {
284343 console.error(error);
285 return html(SERVER_ERROR, 500);
344 return html(SERVER_ERROR, MISS_SECURITY, 500);
286345 }
287346
288347 // HEAD is GET without the body: the headers, the status and nothing to read.
@@ -322,7 +381,7 @@ export const server = Bun.serve({
322381 const session = held ?? newSession();
323382 const stored = saveMatch(db, playerOf(visitor, session, result.name), result);
324383 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) });
326385 }
327386
328387 // The games, one table rather than one branch each. HEAD is GET without a
@@ -346,7 +405,7 @@ export const server = Bun.serve({
346405
347406 // Nothing answers here. HEAD is GET without a body on a miss too: the
348407 // 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));
350409 },
351410 error: onError,
352411 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs