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

The forge pill loads on an app whose CSP allows only its own scripts: its tag is same-origin and its links still point at the forge #8314

Merged opened this

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

Merged

torque/forge:torque/8268-the-forge-pill-loads-on-an-app-whose-csp → main 1 commit fork

  • Merged. torque/8268-the-forge-pill-loads-on-an-app-whose-csp was merged into main as 82c6d5f.

Review

Sign in to review this change.
8 files changed +127 −25
src/apps/toolbar.ts +10 −8
@@ -20,8 +20,10 @@ export const toolbarJs = `(function () {
2020 if (window.__forgeToolbar) return;
2121 window.__forgeToolbar = true;
2222 var me = document.currentScript;
23 var home = "/";
24 try { home = new URL(me.src).origin + "/"; } catch (e) {}
23 // The forge's origin comes from data-forge: the script itself is served from the app's own host.
24 var fo = "";
25 try { fo = new URL((me.dataset || {}).forge || "").origin; } catch (e) {}
26 var home = fo ? fo + "/" : "/";
2527 var host = document.createElement("div");
2628 host.id = "forge-toolbar";
2729 host.style.cssText = "all:initial;position:fixed;z-index:2147483647";
@@ -74,7 +76,7 @@ export const toolbarJs = `(function () {
7476 var pullHref = "";
7577 // The pull this preview shows, one tap away: built from the script's own origin, never from page text.
7678 if (d.env !== "production" && /^\\d{1,9}$/.test(d.pull || "") && /^[\\w.-]+\\/[\\w.-]+$/.test(d.repo || "")) {
77 try { pullHref = new URL("/" + d.repo + "/pulls/" + d.pull, me.src).href; } catch (e) {}
79 try { pullHref = new URL("/" + d.repo + "/pulls/" + d.pull, fo).href; } catch (e) {}
7880 }
7981 if (d.env === "production") {}
8082 else if (pullHref) {
@@ -92,13 +94,12 @@ export const toolbarJs = `(function () {
9294 link.style.cssText = LS;
9395 try {
9496 var target = new URL(home);
95 if (target.origin === new URL(me.src).origin) link.href = target.href;
97 if (target.origin === fo) link.href = target.href;
9698 } catch (e) {}
9799 // Tell the builders: one box, one button, no account. The note goes to
98100 // this app's own host (same-origin, so the sender's cookie goes along) and
99101 // the answer is a live status the panel keeps fresh while it is open.
100 var forgeOrigin = "";
101 try { forgeOrigin = new URL(me.src).origin; } catch (e) {}
102 var forgeOrigin = fo;
102103 var tellBox = document.createElement("form");
103104 tellBox.setAttribute("aria-label", "Tell the builders");
104105 tellBox.style.cssText = "display:flex;flex-direction:column;gap:6px;margin:6px 0";
@@ -320,7 +321,7 @@ export const toolbarJs = `(function () {
320321 .then(function (r) { return r.ok ? r.json() : {}; })
321322 .then(function (c) {
322323 if (!c) return;
323 var forge = new URL(me.src).origin;
324 var forge = fo;
324325 // Signed in, the crew can be told from here. The form is the forge's
325326 // own page, on its own origin, with its own CSRF check: the app's
326327 // page only carries the address and the pull to it.
@@ -591,7 +592,8 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string):
591592 .map(([k, v]) => ` data-${k}="${escapeAttr(v)}"`)
592593 .join("");
593594 const token = proof === undefined ? "" : ` data-proof="${escapeAttr(proof)}"`;
594 return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data}${token} defer></script>`;
595 const forge = ` data-forge="${escapeAttr(new URL(forgeUrl).origin)}"`;
596 return `<script src="${TOOLBAR_ASSET_PATH}"${forge}${data}${token} defer></script>`;
595597 }
596598
597599 /**
test/apps-pill-where.test.ts +1 −1
@@ -168,6 +168,6 @@ describe("the pill's script", () => {
168168 test("puts text in with textContent and checks the home href against the forge origin", () => {
169169 expect(toolbarJs).toContain("textContent = text");
170170 expect(toolbarJs).not.toMatch(/innerHTML|outerHTML|insertAdjacentHTML|document\.write/);
171 expect(toolbarJs).toContain("target.origin === new URL(me.src).origin");
171 expect(toolbarJs).toContain("target.origin === fo");
172172 });
173173 });
test/apps-toolbar-same-origin.test.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * An app that sends `script-src 'self'` shows the pill (#8268): the tag points
3 * at the app host's own copy, and `data-forge` keeps the forge's origin for its links.
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 { TOOLBAR_ASSET_PATH } from "../src/apps/toolbar.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const DOMAIN = "apps.test";
21 let reached = 0;
22
23 let dataDir: string;
24 let app: App;
25 let servers: Servers;
26 let runtime: AppsRuntime;
27 let backend: ReturnType<typeof Bun.serve>;
28 let status: ReturnType<typeof spyOn>;
29
30 beforeAll(async () => {
31 backend = Bun.serve({
32 port: 0,
33 fetch: () => {
34 reached++;
35 return new Response("<html><body><h1>hi</h1></body></html>", {
36 headers: { "content-type": "text/html", "content-security-policy": "script-src 'self'" },
37 });
38 },
39 });
40 dataDir = await mkdtemp(join(tmpdir(), "forge-toolbar-same-origin-"));
41 app = createApp({ dataDir, port: 0, host: "forge.test" });
42 servers = await serve(app);
43 runtime = new AppsRuntime(app, {
44 domain: DOMAIN,
45 scheme: "http",
46 publicPort: servers.port,
47 forgeUrl: servers.url,
48 concurrency: 1,
49 });
50 await runtime.start();
51 status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation(
52 () =>
53 ({
54 env: "production",
55 state: "running",
56 port: backend.port,
57 commitOid: "a".repeat(40),
58 }) as EnvStatus,
59 );
60 const a = await createUser(app.db, {
61 origin: "seed",
62 trusted: true,
63 username: "alice",
64 email: "[email protected]",
65 password: "a-good-passphrase",
66 adminIfFirst: true,
67 });
68 if (!a.ok) throw new Error(a.error);
69 const made = await newApp(app, a.value, {
70 name: "notes",
71 description: "A place for words",
72 visibility: "public",
73 });
74 if (!made.ok) throw new Error(String(made.error));
75 }, 120_000);
76
77 afterAll(async () => {
78 status.mockRestore();
79 await runtime.stop();
80 servers.stop();
81 backend.stop(true);
82 app.close();
83 await rm(dataDir, { recursive: true, force: true });
84 });
85
86 const get = (path: string) =>
87 fetch(`${servers.url}${path}`, { headers: { host: `notes.${DOMAIN}`, accept: "text/html" }, redirect: "manual" });
88
89 test("under script-src 'self' the tag is same-origin and data-forge is the forge's origin", async () => {
90 const res = await get("/");
91 expect(res.headers.get("content-security-policy")).toContain("script-src 'self'");
92 const html = await res.text();
93 const tag = /<script src="([^"]+)"([^>]*)><\/script>/.exec(html);
94 expect(tag).not.toBeNull();
95 expect(tag![1]).toBe(TOOLBAR_ASSET_PATH);
96 expect(new URL(tag![1]!, `http://notes.${DOMAIN}`).origin).toBe(`http://notes.${DOMAIN}`);
97 expect(tag![2]).toContain(` data-forge="${servers.url}"`);
98 // The same-origin copy answers, so the browser can run it.
99 expect((await get(tag![1]!)).status).toBe(200);
100 });
test/apps-toolbar-tell.test.ts +1 −1
@@ -95,7 +95,7 @@ describe("tell the crew from the pill", () => {
9595 const make = (tag: string) => { const e = el(); e.tag = tag; if (tag === "a") links.push(e); return e; };
9696 const run = async (dataset: Record<string, string>, tell: unknown, page: string) => {
9797 links.length = 0;
98 const me = { src: `${forge}/assets/toolbar-x.js`, dataset };
98 const me = { src: "/__forge/toolbar-x.js", dataset: { forge, ...dataset } };
9999 const doc = {
100100 currentScript: me,
101101 createElement: make,
test/apps-toolbar.test.ts +10 −10
@@ -17,7 +17,7 @@ import { getAppByRepo } from "../src/core/apps.ts";
1717 import { listRuns } from "../src/core/runs.ts";
1818 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
1919 import { parseManifest } from "../src/apps/manifest.ts";
20 import { REWRITE_MAX, TOOLBAR_PATH, readPage, toolbarJs, withToolbar } from "../src/apps/toolbar.ts";
20 import { REWRITE_MAX, TOOLBAR_ASSET_PATH, TOOLBAR_PATH, readPage, toolbarJs, withToolbar } from "../src/apps/toolbar.ts";
2121 import { createUser } from "../src/core/users.ts";
2222 import { serve, type Servers } from "../src/http/server.ts";
2323 import { quietLogs } from "./helpers/globals.ts";
@@ -187,7 +187,7 @@ describe("the forge pill", () => {
187187 redirect: "manual",
188188 });
189189 const tag = () =>
190 `<script src="${servers.url}${TOOLBAR_PATH}" data-app="notes" data-repo="alice/notes" data-env="production" defer></script>`;
190 `<script src="${TOOLBAR_ASSET_PATH}" data-forge="${servers.url}" data-app="notes" data-repo="alice/notes" data-env="production" defer></script>`;
191191
192192 test("HTML gets one script tag before </body>, with its length fixed", async () => {
193193 toolbar = undefined;
@@ -227,7 +227,7 @@ describe("the forge pill", () => {
227227 const got = Buffer.from(await res.arrayBuffer()).toString();
228228 const piece = "<p>" + "b".repeat(1024 * 1024) + "</p>";
229229 expect(got).toBe("<html><body>" + piece.repeat(5) + "</body></html>");
230 expect(got).not.toContain(TOOLBAR_PATH);
230 expect(got).not.toContain(TOOLBAR_ASSET_PATH);
231231 });
232232
233233 test("a streamed page still open after the wait arrives whole with no pill", async () => {
@@ -273,7 +273,7 @@ describe("the forge pill", () => {
273273 });
274274 expect(plain.headers.get("content-encoding")).toBeNull();
275275 expect(plain.headers.get("vary")).toContain("accept-encoding");
276 expect(await plain.text()).toContain(TOOLBAR_PATH);
276 expect(await plain.text()).toContain(TOOLBAR_ASSET_PATH);
277277 });
278278
279279 test("a client that refuses gzip with q=0 gets the page plain", async () => {
@@ -284,7 +284,7 @@ describe("the forge pill", () => {
284284 } as RequestInit);
285285 expect(res.headers.get("content-encoding")).toBeNull();
286286 expect(res.headers.get("vary")).toContain("accept-encoding");
287 expect(await res.text()).toContain(TOOLBAR_PATH);
287 expect(await res.text()).toContain(TOOLBAR_ASSET_PATH);
288288 });
289289
290290 test("a client that accepts br is sent brotli", async () => {
@@ -309,7 +309,7 @@ describe("the forge pill", () => {
309309 } as RequestInit);
310310 expect(res.headers.get("content-encoding")).toBeNull();
311311 expect(res.headers.get("vary")).toContain("accept-encoding");
312 expect(await res.text()).toContain(TOOLBAR_PATH);
312 expect(await res.text()).toContain(TOOLBAR_ASSET_PATH);
313313 });
314314
315315 test("an app with the pill off is sent the client's encoding and its gzip passes through", async () => {
@@ -375,7 +375,7 @@ describe("the forge pill", () => {
375375 const out = new TextDecoder().decode(
376376 withToolbar(new TextEncoder().encode("<BODY>x</BODY>"), "http://f.test"),
377377 );
378 expect(out).toBe(`<BODY>x<script src="http://f.test${TOOLBAR_PATH}" defer></script></BODY>`);
378 expect(out).toBe(`<BODY>x<script src="${TOOLBAR_ASSET_PATH}" data-forge="http://f.test" defer></script></BODY>`);
379379 });
380380
381381 test("forge.json toolbar is validated", () => {
@@ -435,7 +435,7 @@ describe("the pill's keyboard and touch behaviour", () => {
435435 return el;
436436 };
437437 const doc = {
438 currentScript: { src: "https://forge.test/assets/t.js", dataset: data },
438 currentScript: { src: "/__forge/t.js", dataset: { forge: "https://forge.test", ...data } },
439439 createElement: patched,
440440 body: { appendChild: () => undefined },
441441 listeners: {} as Record<string, ((e: unknown) => void)[]>,
@@ -612,7 +612,7 @@ describe("the opt-out, from forge.json to the proxy", () => {
612612 // As created: the pill is on, so the page below has something to lose.
613613 expect((await settled()).state).toBe("ok");
614614 expect(runtime.envStatus(record, "production")?.toolbar).toBe(true);
615 expect(await page()).toContain(TOOLBAR_PATH);
615 expect(await page()).toContain(TOOLBAR_ASSET_PATH);
616616
617617 const off = await commitToBranch(app, a.value, repo, {
618618 message: "Turn the pill off",
@@ -628,7 +628,7 @@ describe("the opt-out, from forge.json to the proxy", () => {
628628 expect(runtime.envStatus(record, "production")?.toolbar).toBe(false);
629629 const html = await page();
630630 expect(html).toContain("<");
631 expect(html).not.toContain(TOOLBAR_PATH);
631 expect(html).not.toContain(TOOLBAR_ASSET_PATH);
632632 }, 180_000);
633633 });
634634
test/never-lost.test.ts +2 −2
@@ -13,7 +13,7 @@ import { createApp, type App } from "../src/app.ts";
1313 import { newApp } from "../src/actions/apps.ts";
1414 import { signGrant, type AppTarget } from "../src/apps/proxy.ts";
1515 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
16 import { TOOLBAR_PATH } from "../src/apps/toolbar.ts";
16 import { TOOLBAR_ASSET_PATH } from "../src/apps/toolbar.ts";
1717 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
1818 import { createIssue } from "../src/core/issues.ts";
1919 import { createToken } from "../src/core/tokens.ts";
@@ -119,7 +119,7 @@ describe("never lost: the pill from production and from a preview", () => {
119119 const html = await res.text();
120120 const tag = /<script src="([^"]+)"([^>]*) defer><\/script>/.exec(html);
121121 expect(tag).not.toBeNull();
122 expect(tag![1]).toBe(`${servers.url}${TOOLBAR_PATH}`);
122 expect(tag![1]).toBe(TOOLBAR_ASSET_PATH);
123123 expect(tag![2]).toContain(' data-app="notes"');
124124 expect(tag![2]).toContain(' data-repo="alice/notes"');
125125 expect(tag![2]).toContain(` data-env="${env()}"`);
test/toolbar-body-close.test.ts +1 −1
@@ -2,7 +2,7 @@
22 import { expect, test } from "bun:test";
33 import { withToolbar } from "../src/apps/toolbar.ts";
44
5 const TAG = `<script src="http://f.test/`;
5 const TAG = `<script src="/__forge/toolbar`;
66 const put = (html: string): string =>
77 new TextDecoder().decode(withToolbar(new TextEncoder().encode(html), "http://f.test"));
88 /** Where the tag landed, as the text before it. */
test/toolbar-keyboard.test.ts +2 −2
@@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from "node:fs";
1111 import { tmpdir } from "node:os";
1212 import { join } from "node:path";
1313 import { childEnv } from "../src/apps/process.ts";
14 import { TOOLBAR_PATH, toolbarJs, toolbarTag } from "../src/apps/toolbar.ts";
14 import { TOOLBAR_ASSET_PATH, TOOLBAR_PATH, toolbarJs, toolbarTag } from "../src/apps/toolbar.ts";
1515 import { findChromium } from "../src/looks/capture.ts";
1616 import { chromiumRuns } from "./helpers/chrome.ts";
1717
@@ -70,7 +70,7 @@ describe.skipIf(chromium === undefined)("the pill by keyboard", () => {
7070 hostname: "127.0.0.1",
7171 fetch(req) {
7272 const url = new URL(req.url);
73 if (url.pathname === TOOLBAR_PATH)
73 if (url.pathname === TOOLBAR_PATH || url.pathname === TOOLBAR_ASSET_PATH)
7474 return new Response(toolbarJs, { headers: { "content-type": "text/javascript" } });
7575 if (url.pathname === "/__forge/notes") return Response.json({ notes: [] });
7676 if (url.pathname === "/__forge/crew") return Response.json({});

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs