Public: anyone can read this; the crew can change it.
/claim and a new forge's home load with no console errors #11358
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11345-claim-and-a-new-forge-s-home-load-with-n → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +181
−2
src/http/respond.ts +1 −1
| @@ -225,7 +225,7 @@ const FLOOR_POLICY = | ||
| 225 | 225 | * camera, a microphone, a location, a payment or a device (#2917). |
| 226 | 226 | */ |
| 227 | 227 | const PERMISSIONS_POLICY = |
| 228 | "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=(), interest-cohort=()"; | |
| 228 | "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=()"; | |
| 229 | 229 | |
| 230 | 230 | /** |
| 231 | 231 | * Once a browser has reached the forge over https, it never again asks over |
test/console-clean.test.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * A page that loads with no console error (#11345). The probe saw one error on | |
| 3 | * `/claim` and one on a new forge's home; its text was not kept, so no cause is | |
| 4 | * named here from memory. What can be checked without a browser is checked: | |
| 5 | * the Permissions-Policy names only powers current browsers recognise (it used | |
| 6 | * to carry `interest-cohort`, which Chromium reports as an unrecognised | |
| 7 | * feature on every document), every inline style is named in the CSP, no | |
| 8 | * inline script or handler exists, every same-origin address answers, and | |
| 9 | * every element a page script looks up by a literal is in the served HTML. | |
| 10 | */ | |
| 11 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { createSession } from "../src/core/sessions.ts"; | |
| 18 | import { createToken } from "../src/core/tokens.ts"; | |
| 19 | import { createUser } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { firstBoot } from "../src/spawn/first-boot.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | /** The powers a Permissions-Policy header may name: every one a current browser recognises. */ | |
| 27 | const KNOWN_POWERS = new Set(["camera", "microphone", "geolocation", "payment", "usb", "serial", "hid", "bluetooth"]); | |
| 28 | ||
| 29 | let dir = ""; | |
| 30 | let parent: App; | |
| 31 | let child: App; | |
| 32 | let parentServers: Servers; | |
| 33 | let childServers: Servers; | |
| 34 | let cookie = ""; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dir = await mkdtemp(join(tmpdir(), "forge-console-clean-")); | |
| 38 | parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 39 | const alice = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-claim-passphrase", isAdmin: true }); | |
| 40 | if (!alice.ok) throw new Error("user"); | |
| 41 | const token = createToken(parent.db, alice.value.id, "rw", "rw").token; | |
| 42 | parentServers = await serve(parent); | |
| 43 | const made = await fetch(`${parentServers.url}/api/v1/forges`, { | |
| 44 | method: "POST", | |
| 45 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 46 | body: JSON.stringify({ name: "maria", apps: [], dryRun: false }), | |
| 47 | }); | |
| 48 | if (made.status !== 201) throw new Error(`spawn ${made.status}`); | |
| 49 | const body = (await made.json()) as { bodyId: string }; | |
| 50 | child = createApp({ dataDir: childDirOf(parent, body.bodyId), port: 0 }); | |
| 51 | await firstBoot(child); | |
| 52 | childServers = await serve(child); | |
| 53 | const owner = await createUser(child.db, { origin: "claim", trusted: true, username: "dana", email: "[email protected]", password: "a-long-claim-passphrase", isAdmin: true }); | |
| 54 | if (!owner.ok) throw new Error("owner"); | |
| 55 | cookie = `forge_session=${createSession(child.db, owner.value.id).token}`; | |
| 56 | }, 60_000); | |
| 57 | ||
| 58 | afterAll(async () => { | |
| 59 | childServers.stop(); | |
| 60 | child.close(); | |
| 61 | parentServers.stop(); | |
| 62 | parent.close(); | |
| 63 | await rm(dir, { recursive: true, force: true }); | |
| 64 | }); | |
| 65 | ||
| 66 | test("the Permissions-Policy names only powers a browser recognises", async () => { | |
| 67 | const res = await fetch(`${childServers.url}/claim`); | |
| 68 | const policy = res.headers.get("permissions-policy") ?? ""; | |
| 69 | const named = policy.split(",").map((p) => p.trim().split("=")[0]!); | |
| 70 | expect(named.length).toBeGreaterThan(0); | |
| 71 | expect(named.filter((p) => !KNOWN_POWERS.has(p))).toEqual([]); | |
| 72 | }); | |
| 73 | ||
| 74 | for (const [name, path, signedIn] of [ | |
| 75 | ["/claim", "/claim", false], | |
| 76 | ["a new forge's home, signed in", "/", true], | |
| 77 | ["a new forge's home, signed out", "/", false], | |
| 78 | ] as const) { | |
| 79 | test(`${name} holds nothing its own policy refuses, and every address it names answers`, async () => { | |
| 80 | const headers = signedIn ? { cookie } : {}; | |
| 81 | const res = await fetch(`${childServers.url}${path}`, { headers }); | |
| 82 | expect(res.status).toBe(200); | |
| 83 | const csp = res.headers.get("content-security-policy") ?? ""; | |
| 84 | const html = await res.text(); | |
| 85 | // Every inline <style> is named by hash. | |
| 86 | for (const m of html.matchAll(/<style>([\s\S]*?)<\/style>/g)) { | |
| 87 | const hash = new Bun.CryptoHasher("sha256").update(m[1]!).digest("base64"); | |
| 88 | expect(csp).toContain(`'sha256-${hash}'`); | |
| 89 | } | |
| 90 | // No script runs inline (data blocks are not executed), no style attribute, no handler. | |
| 91 | const inline = [...html.matchAll(/<script([^>]*)>/g)].map((m) => m[1]!).filter((a) => !/\bsrc=/.test(a) && !/type="application\/(ld\+)?json"/.test(a)); | |
| 92 | expect(inline).toEqual([]); | |
| 93 | expect(html).not.toMatch(/\sstyle="/); | |
| 94 | expect(html).not.toMatch(/\son[a-z]+="/); | |
| 95 | // Every same-origin address it names answers (the form's POST-only door excepted). | |
| 96 | const refs = new Set([...html.matchAll(/\b(?:src|href|data-live-src|data-refresh)="(\/[^"#]*)"/g)].map((m) => m[1]!)); | |
| 97 | for (const ref of refs) { | |
| 98 | const got = await fetch(`${childServers.url}${ref}`, { headers: { ...headers, accept: "text/html" } }); | |
| 99 | expect(`${ref} ${got.status}`).toBe(`${ref} ${got.ok ? got.status : 200}`); | |
| 100 | await got.body?.cancel(); | |
| 101 | } | |
| 102 | }); | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Lookups that may miss on a page and have been read to tolerate it: the | |
| 107 | * shell reaches `[data-box]` only after `pageBox()` found one; the live | |
| 108 | * refresh reads `#app-head`, `#app-previews` and `.sprite` to compare and | |
| 109 | * skips the swap (`if (!mine || !theirs) return`, `theirSprite && mySprite`) | |
| 110 | * when they are absent. Anything else that misses fails the test. | |
| 111 | */ | |
| 112 | const TOLERATED = new Set(["[data-box]", "app-head", "app-previews", ".sprite"]); | |
| 113 | ||
| 114 | /** Every script a page loads, as source, in document order. */ | |
| 115 | async function scriptsOf(base: string, html: string): Promise<{ src: string; code: string }[]> { | |
| 116 | const out: { src: string; code: string }[] = []; | |
| 117 | for (const m of html.matchAll(/<script[^>]*\ssrc="(\/[^"]+)"/g)) { | |
| 118 | const res = await fetch(`${base}${m[1]}`); | |
| 119 | expect(res.status).toBe(200); | |
| 120 | out.push({ src: m[1]!, code: await res.text() }); | |
| 121 | } | |
| 122 | return out; | |
| 123 | } | |
| 124 | ||
| 125 | /** Does the served HTML hold an element this literal selector finds? Only simple selectors are judged. */ | |
| 126 | function selectorFound(html: string, sel: string): boolean | null { | |
| 127 | const id = /^#([\w-]+)$/.exec(sel); | |
| 128 | if (id) return html.includes(`id="${id[1]}"`); | |
| 129 | const cls = /^\.([\w-]+)$/.exec(sel); | |
| 130 | if (cls) return new RegExp(`class="[^"]*\\b${cls[1]}\\b`).test(html); | |
| 131 | const attr = /^(\w*)\[([\w-]+)(?:=["']?([^"'\]]*)["']?)?\]$/.exec(sel); | |
| 132 | if (attr) { | |
| 133 | const [, tag, name, value] = attr; | |
| 134 | const needle = value === undefined ? ` ${name}` : ` ${name}="${value}"`; | |
| 135 | return new RegExp(`<${tag || "[a-z]+"}\\b[^>]*${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(html); | |
| 136 | } | |
| 137 | return null; | |
| 138 | } | |
| 139 | ||
| 140 | for (const [name, path, signedIn] of [ | |
| 141 | ["/claim", "/claim", false], | |
| 142 | ["a new forge's home, signed in", "/", true], | |
| 143 | ["a new forge's home, signed out", "/", false], | |
| 144 | ] as const) { | |
| 145 | test(`${name}: every element a page script looks up by a literal is in the served HTML`, async () => { | |
| 146 | const html = await (await fetch(`${childServers.url}${path}`, { headers: signedIn ? { cookie } : {} })).text(); | |
| 147 | const scripts = await scriptsOf(childServers.url, html); | |
| 148 | expect(scripts.length).toBeGreaterThan(0); | |
| 149 | let looked = 0; | |
| 150 | const missing: string[] = []; | |
| 151 | for (const { src, code } of scripts) { | |
| 152 | // Only getElementById and querySelector can hand back null (querySelectorAll gives an empty list). | |
| 153 | for (const m of code.matchAll(/\b(?:doc|document|D|d)\s*\.\s*(getElementById|querySelector)\(\s*(["'])([^"'+]+)\2\s*\)/g)) { | |
| 154 | looked++; | |
| 155 | const found = m[1] === "getElementById" ? html.includes(`id="${m[3]}"`) : selectorFound(html, m[3]!); | |
| 156 | if (found === false && !TOLERATED.has(m[3]!) && !guarded(code, m.index!, m[0].length)) missing.push(`${src}: ${m[3]}`); | |
| 157 | } | |
| 158 | } | |
| 159 | expect(missing).toEqual([]); | |
| 160 | expect(looked).toBeGreaterThan(0); | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | /** A lookup that misses throws only when its result is used untested: dereferenced on the spot, or through a variable never checked. */ | |
| 165 | function guarded(code: string, at: number, len: number): boolean { | |
| 166 | const before = code.slice(Math.max(0, at - 120), at); | |
| 167 | const after = code.slice(at + len, at + len + 2500); | |
| 168 | if (/(?:\bif\s*\(\s*!?|&&\s*|\|\|\s*|\?\s*)$/.test(before)) return true; | |
| 169 | if (/^\s*[.\[]/.test(after)) return false; | |
| 170 | const v = /(?:var|let|const)\s+(\w+)\s*=\s*(?:[\w.]+\s*(?:&&|\|\|)\s*)?(?:\(?[\w.$]+\s*\)?\s*\.)?$/.exec(before)?.[1] ?? /(\w+)\s*=\s*$/.exec(before)?.[1]; | |
| 171 | if (!v) return true; | |
| 172 | return new RegExp(`(\\bif\\s*\\(\\s*!?${v}\\b|!${v}\\b|\\b${v}\\s*(&&|\\|\\||\\?)|\\b${v}\\s*(!==?|===?)\\s*null|\\bif\\s*\\(\\s*${v}\\s*\\))`).test(after); | |
| 173 | } | |
| 174 | ||
| 175 | test("the claim page's own elements are present", async () => { | |
| 176 | const html = await (await fetch(`${childServers.url}/claim`)).text(); | |
| 177 | for (const id of ["claim-token-field", "passkey-make", "passkey-make-note"]) expect(html).toContain(`id="${id}"`); | |
| 178 | expect(html).toContain('name="token"'); | |
| 179 | }); |
test/web.test.ts +1 −1
| @@ -849,7 +849,7 @@ function expectFloor(what: string, response: Response): void { | ||
| 849 | 849 | ); |
| 850 | 850 | // No powers a page does not use, and https once https (#2917). |
| 851 | 851 | const powers = response.headers.get("permissions-policy") ?? ""; |
| 852 | for (const power of ["camera", "microphone", "geolocation", "payment", "usb", "interest-cohort"]) | |
| 852 | for (const power of ["camera", "microphone", "geolocation", "payment", "usb"]) | |
| 853 | 853 | expect(label + powers).toContain(`${power}=()`); |
| 854 | 854 | expect(label + response.headers.get("strict-transport-security")).toBe( |
| 855 | 855 | label + "max-age=31536000", |
new above
offline
This only greps the HTML for ids. Read the served page script and assert every id it looks up exists in the page; otherwise a script that throws on a missing element still passes.