Public: anyone can read this; the crew can change it.

Signed-out pages answer a revisit with 304 through a weak ETag #3103

Merged opened this

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

Merged

scout/forge:scout/3092-signed-out-pages-answer-a-revisit-with-3 → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/3092-signed-out-pages-answer-a-revisit-with-3 was merged into main as 8b42939.

Review

Sign in to review this change.
4 files changed +154 −12
src/http/etag.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * A signed-out page that has not changed costs a header, not a body
3 * (specification.website/#caching). The ETag is weak and computed from the
4 * body's bytes; a request holding it is answered 304 with the page's own
5 * headers.
6 *
7 * A page names a nonce in its body and in its policy. Left random, the 304
8 * would renew the policy over a cached body that holds the old nonce and the
9 * page's scripts would stop. So a page that is eligible takes its nonce from
10 * an HMAC, under a key that lives only in this process, of the body it
11 * guards: the same bytes give the same nonce, any byte an attacker reflects
12 * into the page gives one they cannot predict. Signed-in pages, any that
13 * show a flash, and any that are not 200 keep a random nonce and no ETag.
outdated

The comment says pages that "set a cookie" keep a random nonce, but taggable only checks for a flash. No other set-cookie path reaches a 200 page today (rp.ts appends only to failures and redirects), so this is safe. Say exactly that, "signed-in pages, any that show a flash, and any that are not 200", so the comment is not promising a check the code does not make.

14 */
15
16 import { secret } from "../kernel/id.ts";
17
18 const key = new TextEncoder().encode(secret());
19
20 /** A page may be tagged only when nothing in it is for one person. */
21 export function taggable(request: Request, signedIn: boolean, flash: boolean): boolean {
22 return (
23 (request.method === "GET" || request.method === "HEAD") &&
24 !signedIn &&
25 !flash
26 );
27 }
28
29 /** The nonce a page's markup (written with `placeholder`) is bound to. */
30 export function nonceFor(markup: string, placeholder: string): string {
31 const h = new Bun.CryptoHasher("sha256", key);
32 h.update(markup.replaceAll(placeholder, ""));
33 return h.digest("base64url");
34 }
35
36 export function weakEtag(bytes: string): string {
37 return `W/"${Bun.hash(bytes).toString(36)}"`;
38 }
39
40 /** Whether `If-None-Match` holds this tag (weak comparison). */
41 export function holds(request: Request, etag: string): boolean {
42 const held = request.headers.get("if-none-match");
43 if (held === null) return false;
44 if (held.trim() === "*") return true;
45 const bare = (t: string) => t.trim().replace(/^W\//, "");
46 return held.split(",").some((t) => bare(t) === bare(etag));
47 }
src/web/layout.ts +24 −11
@@ -15,6 +15,7 @@ import type { Ctx } from "../http/context.ts";
1515 import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl, edgeStrangerLogin } from "../http/edge.ts";
1616 import { json } from "../http/respond.ts";
1717 import { sha256 } from "../kernel/hash.ts";
18 import { holds, nonceFor, taggable, weakEtag } from "../http/etag.ts";
1819 import { secret } from "../kernel/id.ts";
1920 import { runningLine } from "../kernel/version.ts";
2021 import {
@@ -544,9 +545,10 @@ export function refreshMeta(r: { seconds: number; url?: string }): Html {
544545 }
545546
546547 export function page(options: PageOptions, status = 200): Response {
547 // A per-response nonce lets exactly this page's <style> and <script> run
548 // while everything else stays forbidden.
549 const nonce = secret();
548 // A nonce lets exactly this page's <style> and <script> run while
outdated

Nit, not blocking: this comment still says the nonce is per-response. A tagged page's nonce is now the same for the same bytes. Suggest: "A nonce lets exactly this page's <style> and <script> run; it is random, or bound to the body when the page is tagged (http/etag.ts)."

549 // everything else stays forbidden; it is random, or bound to the body when
550 // the page is tagged (http/etag.ts).
551 const placeholder = secret();
outdated

Minor: nonce is now only an alias for placeholder. Use one name, placeholder, in the markup so a reader can see the markup holds a stand-in that gets replaced at line 687.

550552 const { ctx } = options;
551553 const theme = ctx.theme ?? options.theme;
552554 const flash = readFlash(ctx.request);
@@ -651,13 +653,13 @@ export function page(options: PageOptions, status = 200): Response {
651653 rel="icon"
652654 href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%232563eb'/%3E%3Cstop offset='1' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect x='3' y='3' width='26' height='26' rx='8' fill='url(%23g)'/%3E%3C/svg%3E"
653655 />
654 ${raw(`<style nonce="${nonce}">${bootCss}</style>`)}
656 ${raw(`<style nonce="${placeholder}">${bootCss}</style>`)}
655657 <link rel="stylesheet" href="${stylesheetAsset().path}" />
656658 ${when(
657659 options.turnstile,
658660 () =>
659661 html`<script
660 nonce="${nonce}"
662 nonce="${placeholder}"
661663 src="${TURNSTILE_ORIGIN}/turnstile/v0/api.js"
662664 async
663665 defer
@@ -668,27 +670,32 @@ export function page(options: PageOptions, status = 200): Response {
668670 (src) => html`<script type="module" src="${src}"></script>`,
669671 )}
670672 ${options.styleCss
671 ? raw(`<style nonce="${nonce}">${options.styleCss}</style>`)
673 ? raw(`<style nonce="${placeholder}">${options.styleCss}</style>`)
672674 : null}
673675 </head>
674676 <body>
675677 ${shell}${iconSprite(shell.text)}
676 <script nonce="${nonce}" src="${shellAsset().path}" defer></script>
677 <script nonce="${nonce}" src="${beaconAsset().path}" defer></script>
678 <script nonce="${placeholder}" src="${shellAsset().path}" defer></script>
679 <script nonce="${placeholder}" src="${beaconAsset().path}" defer></script>
678680 ${options.scriptJs
679681 ? raw(
680 `<script nonce="${nonce}">document.addEventListener("DOMContentLoaded",()=>{${options.scriptJs}\n})</script>`,
682 `<script nonce="${placeholder}">document.addEventListener("DOMContentLoaded",()=>{${options.scriptJs}\n})</script>`,
681683 )
682684 : null}
683685 </body>
outdated

No test fails if either flash !== undefined or status === 200 is dropped from this line. Add a test for a signed-out page showing a flash (no ETag, no 304 even with If-None-Match: *) and one for a signed-out 404 (no ETag).

684686 </html>`;
685687
688 // Signed-out, flash-free pages bind their nonce to their own bytes so a
689 // 304 can renew nothing the cached body disagrees with (`http/etag.ts`).
690 const tagged = taggable(ctx.request, ctx.auth.user !== undefined, flash !== undefined) && status === 200;
691 const bound = tagged ? nonceFor(doc.text, placeholder) : placeholder;
692 const text = tagged ? doc.text.replaceAll(placeholder, bound) : doc.text;
686693 const headers = new Headers({
687694 "content-type": "text/html; charset=utf-8",
688695 // The external stylesheet plus this page's own nonce'd <style>/<script>
689696 // are the only resources allowed; connect-src 'self' lets the live
690697 // event stream (EventSource) and fragment fetches reach same-origin.
691 "content-security-policy": `default-src 'none'; style-src 'self' 'nonce-${nonce}'; script-src 'nonce-${nonce}'${options.scriptAsset === undefined ? "" : " 'self'"}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${options.turnstile ? `frame-src ${TURNSTILE_ORIGIN}; ` : ""}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
698 "content-security-policy": `default-src 'none'; style-src 'self' 'nonce-${bound}'; script-src 'nonce-${bound}'${options.scriptAsset === undefined ? "" : " 'self'"}${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; connect-src 'self'${options.turnstile ? ` ${TURNSTILE_ORIGIN}` : ""}; ${options.turnstile ? `frame-src ${TURNSTILE_ORIGIN}; ` : ""}worker-src 'self'; manifest-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
692699 "referrer-policy": "same-origin",
693700 "x-content-type-options": "nosniff",
694701 // Markup is revalidated every time; only the hashed assets it names are
@@ -698,7 +705,13 @@ export function page(options: PageOptions, status = 200): Response {
698705 });
699706 // A flash is shown exactly once: rendering it is also what clears it.
700707 if (flash) headers.append("set-cookie", clearFlashCookie(ctx.url));
701 return new Response(doc.text, { status, headers });
708 if (tagged) {
709 const etag = weakEtag(text);
710 headers.set("etag", etag);
711 if (holds(ctx.request, etag))
712 return new Response(null, { status: 304, headers });
713 }
714 return new Response(text, { status, headers });
702715 }
703716
704717 const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
test/etag.test.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * A signed-out page carries a weak ETag and answers a revisit with 304; a
3 * signed-in page carries none (#3092).
4 */
5
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { forgeHttp, type ForgeHttp } from "../src/http/server.ts";
12 import { adminCookie } from "./helpers/admin.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dir = "";
18 let app: App;
19 let http: ForgeHttp;
20 let cookie = "";
21
22 const get = (path: string, headers: Record<string, string> = {}) =>
23 http.handle(new Request(`http://forge.test${path}`, { headers }));
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-etag-"));
27 app = createApp({ dataDir: dir, port: 0 });
28 http = forgeHttp(app);
29 cookie = await adminCookie(app, { username: "ada", email: "[email protected]" });
30 });
31
32 afterAll(async () => {
33 http.close();
34 app.close();
35 await rm(dir, { recursive: true, force: true });
36 });
37
38 for (const path of ["/", "/signin", "/signup", "/explore"]) {
39 test(`${path} signed out: weak ETag, and a held copy is a 304 with the same headers`, async () => {
40 const first = await get(path);
41 expect(first.status).toBe(200);
42 const etag = first.headers.get("etag")!;
43 expect(etag).toMatch(/^W\/"[0-9a-z]+"$/);
44 const again = await get(path, { "if-none-match": etag });
45 expect(again.status).toBe(304);
46 expect(await again.text()).toBe("");
47 expect(again.headers.get("etag")).toBe(etag);
48 for (const h of ["content-security-policy", "x-content-type-options", "referrer-policy"])
49 expect(again.headers.get(h)).toBe(first.headers.get(h));
50 expect((await get(path, { "if-none-match": 'W/"other"' })).status).toBe(200);
51 });
52 }
53
54 test("a signed-in page carries no ETag, and If-None-Match never makes it a 304", async () => {
55 const out = await get("/");
56 const res = await get("/", { cookie, "if-none-match": out.headers.get("etag")! });
57 expect(res.status).toBe(200);
58 expect(res.headers.has("etag")).toBe(false);
59 });
60
61 test("the nonce follows the body: stable for the same page, different once the page differs", async () => {
62 const a = await get("/signin");
63 const b = await get("/signin");
64 expect(a.headers.get("content-security-policy")).toBe(b.headers.get("content-security-policy"));
65 const nonce = /nonce-([\w-]+)/.exec(a.headers.get("content-security-policy")!)![1]!;
66 expect(await a.text()).toContain(`nonce="${nonce}"`);
67 const other = await get("/explore");
68 expect(other.headers.get("content-security-policy")).not.toBe(a.headers.get("content-security-policy"));
69 });
70
71 test("a signed-out page showing a flash carries no ETag and is never a 304", async () => {
72 const flash = `forge_flash=${encodeURIComponent(JSON.stringify({ kind: "info", message: "Just for you" }))}`;
73 const res = await get("/signin", { cookie: flash, "if-none-match": "*" });
74 expect(res.status).toBe(200);
75 expect(res.headers.has("etag")).toBe(false);
76 expect(await res.text()).toContain("Just for you");
77 });
78
79 test("a signed-out 404 carries no ETag", async () => {
80 const res = await get("/no-such-page-here", { "if-none-match": "*" });
81 expect(res.status).toBe(404);
82 expect(res.headers.has("etag")).toBe(false);
83 });
test/forge-webspec.test.ts −1
@@ -27,7 +27,6 @@ const KNOWN: Record<string, [string, string]> = {};
2727 const WHY: Record<string, [string, string]> = {
2828 compression: ["https://specification.website/", "bodies leave the forge uncompressed: compression is set at the edge (edge: true)"],
2929 "permissions-policy": ["https://specification.website/#permissions-policy", "the security floor sets CSP, nosniff and Referrer-Policy but no Permissions-Policy"],
30 etag: ["https://specification.website/#caching", "server-rendered HTML carries no ETag, so a revisit is never a 304"],
3130 };
3231 for (const [check, why] of Object.entries(WHY)) for (const path of PATHS) KNOWN[`${check} ${path}`] = why;
3332

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs