Public: anyone can read this; the crew can change it.
The forge pill shows on a page whose CSP admits scripts by nonce: the pill's tag carries the page's nonce #8342
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8210-the-forge-pill-shows-on-a-page-whose-csp → main 2 commits fork
3 files
changed +150
−4
src/apps/proxy.ts +2 −1
| @@ -53,7 +53,7 @@ import { notFound, securityFloor } from "../http/respond.ts"; | ||
| 53 | 53 | import { appMeasure } from "./measure.ts"; |
| 54 | 54 | import { holdPortRequest, holdPortSocket } from "./handoff.ts"; |
| 55 | 55 | import { secureFor } from "../http/cookie_secure.ts"; |
| 56 | import { htmlAnswer, readPage, rewritable, TOOLBAR_ASSET_PATH, TOOLBAR_ASSET_PREFIX, toolbarResponse, withToolbar, type PillWhere } from "./toolbar.ts"; | |
| 56 | import { cspNonce, htmlAnswer, readPage, rewritable, TOOLBAR_ASSET_PATH, TOOLBAR_ASSET_PREFIX, toolbarResponse, withToolbar, type PillWhere } from "./toolbar.ts"; | |
| 57 | 57 | |
| 58 | 58 | const proxySecure = (request: Request, url: URL, forgeUrl: string): boolean => |
| 59 | 59 | secureFor(url, forgeUrl) || |
| @@ -1202,6 +1202,7 @@ export async function proxyApp( | ||
| 1202 | 1202 | runtime.options.forgeUrl, |
| 1203 | 1203 | pillWhere(runtime, target, vouched?.who), |
| 1204 | 1204 | proof, |
| 1205 | cspNonce(out.get("content-security-policy")), | |
| 1205 | 1206 | ), |
| 1206 | 1207 | ); |
| 1207 | 1208 | let sent = page; |
src/apps/toolbar.ts +29 −3
| @@ -576,8 +576,32 @@ export function escapeAttr(text: string): string { | ||
| 576 | 576 | .replace(/'/g, "'"); |
| 577 | 577 | } |
| 578 | 578 | |
| 579 | /** | |
| 580 | * The nonce a response's own Content-Security-Policy admits scripts by: from | |
| 581 | * `script-src`, or `default-src` when a policy has no `script-src`. Only a | |
| 582 | * well-formed base64 value is returned; anything else is no nonce. | |
| 583 | */ | |
| 584 | export function cspNonce(policy: string | null): string | undefined { | |
| 585 | if (policy === null) return undefined; | |
| 586 | for (const one of policy.split(",")) { | |
| 587 | const directives = one.split(";").map((d) => d.trim().split(/\s+/)); | |
| 588 | const names = (n: string) => directives.find((d) => d[0]?.toLowerCase() === n); | |
| 589 | const chosen = names("script-src") ?? names("default-src"); | |
| 590 | for (const source of chosen?.slice(1) ?? []) { | |
| 591 | const m = /^'nonce-([A-Za-z0-9+/_-]+={0,2})'$/i.exec(source); | |
| 592 | if (m) return m[1]; | |
| 593 | } | |
| 594 | } | |
| 595 | return undefined; | |
| 596 | } | |
| 597 | ||
| 579 | 598 | /** The script tag, with where-you-are as escaped data attributes. */ |
| 580 | export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string): string { | |
| 599 | export function toolbarTag( | |
| 600 | forgeUrl: string, | |
| 601 | where?: PillWhere, | |
| 602 | proof?: string, | |
| 603 | nonce?: string, | |
| 604 | ): string { | |
| 581 | 605 | const data = |
| 582 | 606 | where === undefined |
| 583 | 607 | ? "" |
| @@ -593,7 +617,8 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string): | ||
| 593 | 617 | .join(""); |
| 594 | 618 | const token = proof === undefined ? "" : ` data-proof="${escapeAttr(proof)}"`; |
| 595 | 619 | const forge = ` data-forge="${escapeAttr(new URL(forgeUrl).origin)}"`; |
| 596 | return `<script src="${TOOLBAR_ASSET_PATH}"${forge}${data}${token} defer></script>`; | |
| 620 | const admitted = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`; | |
| 621 | return `<script src="${TOOLBAR_ASSET_PATH}"${admitted}${forge}${data}${token} defer></script>`; | |
| 597 | 622 | } |
| 598 | 623 | |
| 599 | 624 | /** |
| @@ -614,8 +639,9 @@ export function withToolbar( | ||
| 614 | 639 | forgeUrl: string, |
| 615 | 640 | where?: PillWhere, |
| 616 | 641 | proof?: string, |
| 642 | nonce?: string, | |
| 617 | 643 | ): Uint8Array<ArrayBuffer> { |
| 618 | const tag = Buffer.from(toolbarTag(forgeUrl, where, proof)); | |
| 644 | const tag = Buffer.from(toolbarTag(forgeUrl, where, proof, nonce)); | |
| 619 | 645 | const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 620 | 646 | const at = lastBodyClose(buffer); |
| 621 | 647 | const cut = at < 0 ? buffer.length : at; |
test/apps-toolbar-nonce.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * An app whose CSP admits scripts by nonce (#8210): the pill's tag carries the | |
| 3 | * nonce from the response's own header, and none when it is malformed or absent. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, expect, spyOn, 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 { newApp } from "../src/actions/apps.ts"; | |
| 12 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | const DOMAIN = "apps.test"; | |
| 20 | let reached = 0; | |
| 21 | ||
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let runtime: AppsRuntime; | |
| 26 | let backend: ReturnType<typeof Bun.serve>; | |
| 27 | let status: ReturnType<typeof spyOn>; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | backend = Bun.serve({ | |
| 31 | port: 0, | |
| 32 | fetch: (req) => { | |
| 33 | reached++; | |
| 34 | const path = new URL(req.url).pathname; | |
| 35 | const policy = | |
| 36 | path === "/bad" | |
| 37 | ? "script-src 'nonce-a\"><b'" | |
| 38 | : path === "/default" | |
| 39 | ? "default-src 'nonce-xyz'" | |
| 40 | : path === "/none" | |
| 41 | ? "script-src 'self'" | |
| 42 | : "script-src 'nonce-abc'"; | |
| 43 | // The request's own words never reach the tag. | |
| 44 | return new Response(`<html><body><h1>hi</h1><i>nonce="evil"</i></body></html>`, { | |
| 45 | headers: { "content-type": "text/html", "content-security-policy": policy }, | |
| 46 | }); | |
| 47 | }, | |
| 48 | }); | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-toolbar-nonce-")); | |
| 50 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 51 | servers = await serve(app); | |
| 52 | runtime = new AppsRuntime(app, { | |
| 53 | domain: DOMAIN, | |
| 54 | scheme: "http", | |
| 55 | publicPort: servers.port, | |
| 56 | forgeUrl: servers.url, | |
| 57 | concurrency: 1, | |
| 58 | }); | |
| 59 | await runtime.start(); | |
| 60 | status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 61 | () => | |
| 62 | ({ | |
| 63 | env: "production", | |
| 64 | state: "running", | |
| 65 | port: backend.port, | |
| 66 | commitOid: "a".repeat(40), | |
| 67 | }) as EnvStatus, | |
| 68 | ); | |
| 69 | const a = await createUser(app.db, { | |
| 70 | origin: "seed", | |
| 71 | trusted: true, | |
| 72 | username: "alice", | |
| 73 | email: "[email protected]", | |
| 74 | password: "a-good-passphrase", | |
| 75 | adminIfFirst: true, | |
| 76 | }); | |
| 77 | if (!a.ok) throw new Error(a.error); | |
| 78 | const made = await newApp(app, a.value, { | |
| 79 | name: "notes", | |
| 80 | description: "A place for words", | |
| 81 | visibility: "public", | |
| 82 | }); | |
| 83 | if (!made.ok) throw new Error(String(made.error)); | |
| 84 | }, 120_000); | |
| 85 | ||
| 86 | afterAll(async () => { | |
| 87 | status.mockRestore(); | |
| 88 | await runtime.stop(); | |
| 89 | servers.stop(); | |
| 90 | backend.stop(true); | |
| 91 | app.close(); | |
| 92 | await rm(dataDir, { recursive: true, force: true }); | |
| 93 | }); | |
| 94 | ||
| 95 | const get = (path: string) => | |
| 96 | fetch(`${servers.url}${path}`, { headers: { host: `notes.${DOMAIN}`, accept: "text/html" }, redirect: "manual" }); | |
| 97 | ||
| 98 | const tagOf = async (path: string) => { | |
| 99 | const html = await (await get(path)).text(); | |
| 100 | const tag = /<script src="[^"]+"[^>]*><\/script>/.exec(html); | |
| 101 | expect(tag).not.toBeNull(); | |
| 102 | return tag![0]; | |
| 103 | }; | |
| 104 | ||
| 105 | test("a script-src nonce is copied onto the pill's tag", async () => { | |
| 106 | expect(await tagOf("/")).toContain(' nonce="abc"'); | |
| 107 | }); | |
| 108 | ||
| 109 | test("default-src supplies the nonce when there is no script-src", async () => { | |
| 110 | expect(await tagOf("/default")).toContain(' nonce="xyz"'); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a malformed nonce copies nothing", async () => { | |
| 114 | expect(await tagOf("/bad")).not.toContain("nonce="); | |
| 115 | }); | |
| 116 | ||
| 117 | test("no nonce in the policy leaves the tag as before", async () => { | |
| 118 | expect(await tagOf("/none")).not.toContain("nonce="); | |
| 119 | }); |
new above
offline