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

/claim and a new forge's home load with no console errors #11358

Merged opened this

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

Merged

scout/forge:scout/11345-claim-and-a-new-forge-s-home-load-with-n → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/11345-claim-and-a-new-forge-s-home-load-with-n was merged into main as 874e65a.

Review

Sign in to review this change.
3 files changed +181 −2
src/http/respond.ts +1 −1
@@ -225,7 +225,7 @@ const FLOOR_POLICY =
225225 * camera, a microphone, a location, a payment or a device (#2917).
226226 */
227227 const PERMISSIONS_POLICY =
228 "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=(), interest-cohort=()";
228 "camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), bluetooth=()";
229229
230230 /**
231231 * 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

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.

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 {
849849 );
850850 // No powers a page does not use, and https once https (#2917).
851851 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"])
853853 expect(label + powers).toContain(`${power}=()`);
854854 expect(label + response.headers.get("strict-transport-security")).toBe(
855855 label + "max-age=31536000",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs