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

/claim and a spawned forge's home load with no console errors, each cause named in the pull #11403

Merged opened this

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

Merged

scout/forge:scout/11397-claim-and-a-spawned-forge-s-home-load-wi → main 2 commits fork

  • Merged. scout/11397-claim-and-a-spawned-forge-s-home-load-wi was merged into main as e8dc3cd.

Review

Sign in to review this change.
1 file changed +144 −0
test/claim-home-csp.test.ts added +144
@@ -0,0 +1,144 @@
1 /**
2 * /claim and a spawned forge's home load without a console error (#11397).
3 * Over real HTTP, against the page's own Content-Security-Policy: every script
4 * (inline or by src), stylesheet, manifest, icon, sprite and form target the
5 * markup names is admitted by the directive that governs it, and each answers
6 * 200; and the beacon's POST, as a browser on the forge's public https origin
7 * would send it, is accepted. It does not run the scripts: only a browser
8 * shows what they log.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { childDirOf } from "../src/actions/spawn.ts";
15 import { createApp, type App } from "../src/app.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { firstBoot } from "../src/spawn/first-boot.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 /** The source list of one directive of a policy (falling back to default-src). */
25 function sources(csp: string, directive: string): string[] {
26 const all = csp.split(";").map((d) => d.trim().split(/\s+/));
27 const found = all.find((d) => d[0] === directive) ?? all.find((d) => d[0] === "default-src") ?? [];
28 return found.slice(1);
29 }
30
31 /** Whether a directive's source list admits a URL, as seen from `origin`. */
32 function admits(list: string[], raw: string, origin: string): boolean {
33 const url = new URL(raw, origin);
34 if (url.protocol === "data:") return list.includes("data:");
35 return list.some((s) => (s === "'self'" ? url.origin === origin : s.replace(/\/$/, "") === url.origin));
36 }
37
38 /** The inline scripts and script srcs a policy does not admit, named; JSON data blocks never execute. */
39 function scriptsNotAllowed(html: string, csp: string, origin: string): string[] {
40 const list = sources(csp, "script-src");
41 const bad: string[] = [];
42 for (const m of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
43 const attrs = m[1] ?? "";
44 const type = /\btype="([^"]*)"/i.exec(attrs)?.[1] ?? "";
45 if (type === "application/json" || type === "application/ld+json") continue;
46 const src = /\bsrc="([^"]*)"/i.exec(attrs)?.[1];
47 if (src !== undefined) {
48 if (!admits(list, src, origin)) bad.push(`src ${src}`);
49 continue;
50 }
51 const hash = `'sha256-${new Bun.CryptoHasher("sha256").update(m[2] ?? "").digest("base64")}'`;
52 if (!list.includes(hash)) bad.push(`inline ${(m[2] ?? "").slice(0, 40)}`);
53 }
54 return bad;
55 }
56
57 /** Every load the markup names, with the directive that governs it. */
58 function loadsOf(html: string): { directive: string; href: string }[] {
59 const out: { directive: string; href: string }[] = [];
60 for (const m of html.matchAll(/<link\b([^>]*)>/gi)) {
61 const rel = /\brel="([^"]*)"/i.exec(m[1] ?? "")?.[1] ?? "";
62 const href = /\bhref="([^"]*)"/i.exec(m[1] ?? "")?.[1];
63 if (href === undefined) continue;
64 if (rel === "stylesheet") out.push({ directive: "style-src", href });
65 else if (rel === "manifest") out.push({ directive: "manifest-src", href });
66 else if (rel === "icon" || rel === "apple-touch-icon") out.push({ directive: "img-src", href });
67 }
68 for (const m of html.matchAll(/<use\b[^>]*\bhref="([^"]*)"/gi)) out.push({ directive: "img-src", href: m[1]! });
69 for (const m of html.matchAll(/<form\b[^>]*\baction="([^"]*)"/gi)) out.push({ directive: "form-action", href: m[1]! });
70 return out;
71 }
72
73 const PUBLIC = "https://maria--forge.test";
74
75 describe("/claim and a spawned forge's home against their own CSP", () => {
76 let dir = "";
77 let parent: App;
78 let parentServers: Servers;
79 let child: App;
80 let childServers: Servers;
81
82 beforeAll(async () => {
83 dir = await mkdtemp(join(tmpdir(), "forge-claim-csp-"));
84 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
85 const alice = await createUser(parent.db, {
86 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
87 password: "a-long-claim-passphrase", isAdmin: true,
88 });
89 if (!alice.ok) throw new Error("user");
90 const rw = createToken(parent.db, alice.value.id, "rw", "rw").token;
91 parentServers = await serve(parent);
92 const made = await fetch(`${parentServers.url}/api/v1/forges`, {
93 method: "POST",
94 headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" },
95 body: JSON.stringify({ name: "maria", apps: [], dryRun: false }),
96 });
97 if (made.status !== 201) throw new Error(`spawn ${made.status} ${await made.text()}`);
98 const body = (await made.json()) as { bodyId: string };
99 // A spawned forge knows its public https address, as a hosted child does.
100 child = createApp({ dataDir: childDirOf(parent, body.bodyId), url: PUBLIC, port: 0 });
101 await firstBoot(child);
102 childServers = await serve(child);
103 }, 60_000);
104
105 afterAll(async () => {
106 childServers.stop();
107 child.close();
108 parentServers.stop();
109 parent.close();
110 await rm(dir, { recursive: true, force: true });
111 });
112
113 for (const path of ["/claim", "/"]) {
114 test(`${path}: every script and load is admitted by its own policy, and answers`, async () => {
115 const res = await fetch(`${childServers.url}${path}`);
116 expect(res.status).toBe(200);
117 const csp = res.headers.get("content-security-policy") ?? "";
118 expect(csp).not.toBe("");
119 const html = await res.text();
120 expect(scriptsNotAllowed(html, csp, PUBLIC)).toEqual([]);
121 const loads = loadsOf(html);
122 expect(loads.length).toBeGreaterThan(3);
123 for (const { directive, href } of loads) {
124 expect(`${directive} ${href} ${admits(sources(csp, directive), href, PUBLIC)}`).toBe(`${directive} ${href} true`);
125 if (directive === "form-action" || href.startsWith("data:")) continue;
126 const got = await fetch(`${childServers.url}${new URL(href, PUBLIC).pathname}${new URL(href, PUBLIC).search}`);
127 expect(`${href} ${got.status}`).toBe(`${href} 200`);
128 }
129 for (const m of html.matchAll(/<script\b[^>]*\bsrc="([^"]*)"/gi)) {
130 const got = await fetch(`${childServers.url}${m[1]}`);
131 expect(`${m[1]} ${got.status}`).toBe(`${m[1]} 200`);
132 }
133 });
134
135 test(`${path}: the beacon's POST from the public origin is accepted`, async () => {
136 const sent = await fetch(`${childServers.url}/_measure`, {
137 method: "POST",
138 headers: { origin: PUBLIC },
139 body: JSON.stringify({ path, ttfb: 1, load: 1, lcp: 1, inp: 0, cls: 0, ref: "", q: "", out: "" }),
140 });
141 expect(sent.status).toBe(204);
142 });
143 }
144 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs