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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11397-claim-and-a-spawned-forge-s-home-load-wi → main 2 commits fork
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 | }); |
new above
offline