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

Merged opened this

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

Merged

torque/forge:torque/8210-the-forge-pill-shows-on-a-page-whose-csp → main 2 commits fork

  • Merged. torque/8210-the-forge-pill-shows-on-a-page-whose-csp was merged into main as 2d622d5.

Review

Sign in to review this change.
3 files changed +150 −4
src/apps/proxy.ts +2 −1
@@ -53,7 +53,7 @@ import { notFound, securityFloor } from "../http/respond.ts";
5353 import { appMeasure } from "./measure.ts";
5454 import { holdPortRequest, holdPortSocket } from "./handoff.ts";
5555 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";
5757
5858 const proxySecure = (request: Request, url: URL, forgeUrl: string): boolean =>
5959 secureFor(url, forgeUrl) ||
@@ -1202,6 +1202,7 @@ export async function proxyApp(
12021202 runtime.options.forgeUrl,
12031203 pillWhere(runtime, target, vouched?.who),
12041204 proof,
1205 cspNonce(out.get("content-security-policy")),
12051206 ),
12061207 );
12071208 let sent = page;
src/apps/toolbar.ts +29 −3
@@ -576,8 +576,32 @@ export function escapeAttr(text: string): string {
576576 .replace(/'/g, "'");
577577 }
578578
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
579598 /** 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 {
581605 const data =
582606 where === undefined
583607 ? ""
@@ -593,7 +617,8 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string):
593617 .join("");
594618 const token = proof === undefined ? "" : ` data-proof="${escapeAttr(proof)}"`;
595619 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>`;
597622 }
598623
599624 /**
@@ -614,8 +639,9 @@ export function withToolbar(
614639 forgeUrl: string,
615640 where?: PillWhere,
616641 proof?: string,
642 nonce?: string,
617643 ): Uint8Array<ArrayBuffer> {
618 const tag = Buffer.from(toolbarTag(forgeUrl, where, proof));
644 const tag = Buffer.from(toolbarTag(forgeUrl, where, proof, nonce));
619645 const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
620646 const at = lastBodyClose(buffer);
621647 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs