Public: anyone can read this; the crew can change it.
Signed-out pages answer a revisit with 304 through a weak ETag #3103
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3092-signed-out-pages-answer-a-revisit-with-3 → main 4 commits fork 4 threads · 4 unresolved
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. | |
| 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"; | ||
| 15 | 15 | import { activeEdgeToken, edgeLoginUrl, edgeLogoutUrl, edgeStrangerLogin } from "../http/edge.ts"; |
| 16 | 16 | import { json } from "../http/respond.ts"; |
| 17 | 17 | import { sha256 } from "../kernel/hash.ts"; |
| 18 | import { holds, nonceFor, taggable, weakEtag } from "../http/etag.ts"; | |
| 18 | 19 | import { secret } from "../kernel/id.ts"; |
| 19 | 20 | import { runningLine } from "../kernel/version.ts"; |
| 20 | 21 | import { |
| @@ -544,9 +545,10 @@ export function refreshMeta(r: { seconds: number; url?: string }): Html { | ||
| 544 | 545 | } |
| 545 | 546 | |
| 546 | 547 | 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: |
||
| 550 | 552 | const { ctx } = options; |
| 551 | 553 | const theme = ctx.theme ?? options.theme; |
| 552 | 554 | const flash = readFlash(ctx.request); |
| @@ -651,13 +653,13 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 651 | 653 | rel="icon" |
| 652 | 654 | 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" |
| 653 | 655 | /> |
| 654 | ${raw(`<style nonce="${nonce}">${bootCss}</style>`)} | |
| 656 | ${raw(`<style nonce="${placeholder}">${bootCss}</style>`)} | |
| 655 | 657 | <link rel="stylesheet" href="${stylesheetAsset().path}" /> |
| 656 | 658 | ${when( |
| 657 | 659 | options.turnstile, |
| 658 | 660 | () => |
| 659 | 661 | html`<script |
| 660 | nonce="${nonce}" | |
| 662 | nonce="${placeholder}" | |
| 661 | 663 | src="${TURNSTILE_ORIGIN}/turnstile/v0/api.js" |
| 662 | 664 | async |
| 663 | 665 | defer |
| @@ -668,27 +670,32 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 668 | 670 | (src) => html`<script type="module" src="${src}"></script>`, |
| 669 | 671 | )} |
| 670 | 672 | ${options.styleCss |
| 671 | ? raw(`<style nonce="${nonce}">${options.styleCss}</style>`) | |
| 673 | ? raw(`<style nonce="${placeholder}">${options.styleCss}</style>`) | |
| 672 | 674 | : null} |
| 673 | 675 | </head> |
| 674 | 676 | <body> |
| 675 | 677 | ${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> | |
| 678 | 680 | ${options.scriptJs |
| 679 | 681 | ? raw( |
| 680 | `<script nonce="${nonce}">document.addEventListener("DOMContentLoaded",()=>{${options.scriptJs}\n})</script>`, | |
| 682 | `<script nonce="${placeholder}">document.addEventListener("DOMContentLoaded",()=>{${options.scriptJs}\n})</script>`, | |
| 681 | 683 | ) |
| 682 | 684 | : null} |
| 683 | 685 | </body> |
outdated
No test fails if either |
||
| 684 | 686 | </html>`; |
| 685 | 687 | |
| 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; | |
| 686 | 693 | const headers = new Headers({ |
| 687 | 694 | "content-type": "text/html; charset=utf-8", |
| 688 | 695 | // The external stylesheet plus this page's own nonce'd <style>/<script> |
| 689 | 696 | // are the only resources allowed; connect-src 'self' lets the live |
| 690 | 697 | // 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'`, | |
| 692 | 699 | "referrer-policy": "same-origin", |
| 693 | 700 | "x-content-type-options": "nosniff", |
| 694 | 701 | // Markup is revalidated every time; only the hashed assets it names are |
| @@ -698,7 +705,13 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 698 | 705 | }); |
| 699 | 706 | // A flash is shown exactly once: rendering it is also what clears it. |
| 700 | 707 | 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 }); | |
| 702 | 715 | } |
| 703 | 716 | |
| 704 | 717 | 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]> = {}; | ||
| 27 | 27 | const WHY: Record<string, [string, string]> = { |
| 28 | 28 | compression: ["https://specification.website/", "bodies leave the forge uncompressed: compression is set at the edge (edge: true)"], |
| 29 | 29 | "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"], | |
| 31 | 30 | }; |
| 32 | 31 | for (const [check, why] of Object.entries(WHY)) for (const path of PATHS) KNOWN[`${check} ${path}`] = why; |
| 33 | 32 |
The comment says pages that "set a cookie" keep a random nonce, but
taggableonly checks for a flash. No other set-cookie path reaches a 200 page today (rp.tsappends 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.