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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8268-the-forge-pill-loads-on-an-app-whose-csp → main 1 commit fork
8 files
changed +127
−25
src/apps/toolbar.ts +10 −8
| @@ -20,8 +20,10 @@ export const toolbarJs = `(function () { | ||
| 20 | 20 | if (window.__forgeToolbar) return; |
| 21 | 21 | window.__forgeToolbar = true; |
| 22 | 22 | 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 + "/" : "/"; | |
| 25 | 27 | var host = document.createElement("div"); |
| 26 | 28 | host.id = "forge-toolbar"; |
| 27 | 29 | host.style.cssText = "all:initial;position:fixed;z-index:2147483647"; |
| @@ -74,7 +76,7 @@ export const toolbarJs = `(function () { | ||
| 74 | 76 | var pullHref = ""; |
| 75 | 77 | // The pull this preview shows, one tap away: built from the script's own origin, never from page text. |
| 76 | 78 | 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) {} | |
| 78 | 80 | } |
| 79 | 81 | if (d.env === "production") {} |
| 80 | 82 | else if (pullHref) { |
| @@ -92,13 +94,12 @@ export const toolbarJs = `(function () { | ||
| 92 | 94 | link.style.cssText = LS; |
| 93 | 95 | try { |
| 94 | 96 | 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; | |
| 96 | 98 | } catch (e) {} |
| 97 | 99 | // Tell the builders: one box, one button, no account. The note goes to |
| 98 | 100 | // this app's own host (same-origin, so the sender's cookie goes along) and |
| 99 | 101 | // 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; | |
| 102 | 103 | var tellBox = document.createElement("form"); |
| 103 | 104 | tellBox.setAttribute("aria-label", "Tell the builders"); |
| 104 | 105 | tellBox.style.cssText = "display:flex;flex-direction:column;gap:6px;margin:6px 0"; |
| @@ -320,7 +321,7 @@ export const toolbarJs = `(function () { | ||
| 320 | 321 | .then(function (r) { return r.ok ? r.json() : {}; }) |
| 321 | 322 | .then(function (c) { |
| 322 | 323 | if (!c) return; |
| 323 | var forge = new URL(me.src).origin; | |
| 324 | var forge = fo; | |
| 324 | 325 | // Signed in, the crew can be told from here. The form is the forge's |
| 325 | 326 | // own page, on its own origin, with its own CSRF check: the app's |
| 326 | 327 | // page only carries the address and the pull to it. |
| @@ -591,7 +592,8 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string): | ||
| 591 | 592 | .map(([k, v]) => ` data-${k}="${escapeAttr(v)}"`) |
| 592 | 593 | .join(""); |
| 593 | 594 | 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>`; | |
| 595 | 597 | } |
| 596 | 598 | |
| 597 | 599 | /** |
test/apps-pill-where.test.ts +1 −1
| @@ -168,6 +168,6 @@ describe("the pill's script", () => { | ||
| 168 | 168 | test("puts text in with textContent and checks the home href against the forge origin", () => { |
| 169 | 169 | expect(toolbarJs).toContain("textContent = text"); |
| 170 | 170 | 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"); | |
| 172 | 172 | }); |
| 173 | 173 | }); |
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", () => { | ||
| 95 | 95 | const make = (tag: string) => { const e = el(); e.tag = tag; if (tag === "a") links.push(e); return e; }; |
| 96 | 96 | const run = async (dataset: Record<string, string>, tell: unknown, page: string) => { |
| 97 | 97 | links.length = 0; |
| 98 | const me = { src: `${forge}/assets/toolbar-x.js`, dataset }; | |
| 98 | const me = { src: "/__forge/toolbar-x.js", dataset: { forge, ...dataset } }; | |
| 99 | 99 | const doc = { |
| 100 | 100 | currentScript: me, |
| 101 | 101 | createElement: make, |
test/apps-toolbar.test.ts +10 −10
| @@ -17,7 +17,7 @@ import { getAppByRepo } from "../src/core/apps.ts"; | ||
| 17 | 17 | import { listRuns } from "../src/core/runs.ts"; |
| 18 | 18 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; |
| 19 | 19 | 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"; | |
| 21 | 21 | import { createUser } from "../src/core/users.ts"; |
| 22 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | 23 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -187,7 +187,7 @@ describe("the forge pill", () => { | ||
| 187 | 187 | redirect: "manual", |
| 188 | 188 | }); |
| 189 | 189 | 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>`; | |
| 191 | 191 | |
| 192 | 192 | test("HTML gets one script tag before </body>, with its length fixed", async () => { |
| 193 | 193 | toolbar = undefined; |
| @@ -227,7 +227,7 @@ describe("the forge pill", () => { | ||
| 227 | 227 | const got = Buffer.from(await res.arrayBuffer()).toString(); |
| 228 | 228 | const piece = "<p>" + "b".repeat(1024 * 1024) + "</p>"; |
| 229 | 229 | 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); | |
| 231 | 231 | }); |
| 232 | 232 | |
| 233 | 233 | test("a streamed page still open after the wait arrives whole with no pill", async () => { |
| @@ -273,7 +273,7 @@ describe("the forge pill", () => { | ||
| 273 | 273 | }); |
| 274 | 274 | expect(plain.headers.get("content-encoding")).toBeNull(); |
| 275 | 275 | 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); | |
| 277 | 277 | }); |
| 278 | 278 | |
| 279 | 279 | test("a client that refuses gzip with q=0 gets the page plain", async () => { |
| @@ -284,7 +284,7 @@ describe("the forge pill", () => { | ||
| 284 | 284 | } as RequestInit); |
| 285 | 285 | expect(res.headers.get("content-encoding")).toBeNull(); |
| 286 | 286 | 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); | |
| 288 | 288 | }); |
| 289 | 289 | |
| 290 | 290 | test("a client that accepts br is sent brotli", async () => { |
| @@ -309,7 +309,7 @@ describe("the forge pill", () => { | ||
| 309 | 309 | } as RequestInit); |
| 310 | 310 | expect(res.headers.get("content-encoding")).toBeNull(); |
| 311 | 311 | 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); | |
| 313 | 313 | }); |
| 314 | 314 | |
| 315 | 315 | 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", () => { | ||
| 375 | 375 | const out = new TextDecoder().decode( |
| 376 | 376 | withToolbar(new TextEncoder().encode("<BODY>x</BODY>"), "http://f.test"), |
| 377 | 377 | ); |
| 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>`); | |
| 379 | 379 | }); |
| 380 | 380 | |
| 381 | 381 | test("forge.json toolbar is validated", () => { |
| @@ -435,7 +435,7 @@ describe("the pill's keyboard and touch behaviour", () => { | ||
| 435 | 435 | return el; |
| 436 | 436 | }; |
| 437 | 437 | 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 } }, | |
| 439 | 439 | createElement: patched, |
| 440 | 440 | body: { appendChild: () => undefined }, |
| 441 | 441 | listeners: {} as Record<string, ((e: unknown) => void)[]>, |
| @@ -612,7 +612,7 @@ describe("the opt-out, from forge.json to the proxy", () => { | ||
| 612 | 612 | // As created: the pill is on, so the page below has something to lose. |
| 613 | 613 | expect((await settled()).state).toBe("ok"); |
| 614 | 614 | expect(runtime.envStatus(record, "production")?.toolbar).toBe(true); |
| 615 | expect(await page()).toContain(TOOLBAR_PATH); | |
| 615 | expect(await page()).toContain(TOOLBAR_ASSET_PATH); | |
| 616 | 616 | |
| 617 | 617 | const off = await commitToBranch(app, a.value, repo, { |
| 618 | 618 | message: "Turn the pill off", |
| @@ -628,7 +628,7 @@ describe("the opt-out, from forge.json to the proxy", () => { | ||
| 628 | 628 | expect(runtime.envStatus(record, "production")?.toolbar).toBe(false); |
| 629 | 629 | const html = await page(); |
| 630 | 630 | expect(html).toContain("<"); |
| 631 | expect(html).not.toContain(TOOLBAR_PATH); | |
| 631 | expect(html).not.toContain(TOOLBAR_ASSET_PATH); | |
| 632 | 632 | }, 180_000); |
| 633 | 633 | }); |
| 634 | 634 |
test/never-lost.test.ts +2 −2
| @@ -13,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 13 | 13 | import { newApp } from "../src/actions/apps.ts"; |
| 14 | 14 | import { signGrant, type AppTarget } from "../src/apps/proxy.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 18 | 18 | import { createIssue } from "../src/core/issues.ts"; |
| 19 | 19 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -119,7 +119,7 @@ describe("never lost: the pill from production and from a preview", () => { | ||
| 119 | 119 | const html = await res.text(); |
| 120 | 120 | const tag = /<script src="([^"]+)"([^>]*) defer><\/script>/.exec(html); |
| 121 | 121 | expect(tag).not.toBeNull(); |
| 122 | expect(tag![1]).toBe(`${servers.url}${TOOLBAR_PATH}`); | |
| 122 | expect(tag![1]).toBe(TOOLBAR_ASSET_PATH); | |
| 123 | 123 | expect(tag![2]).toContain(' data-app="notes"'); |
| 124 | 124 | expect(tag![2]).toContain(' data-repo="alice/notes"'); |
| 125 | 125 | expect(tag![2]).toContain(` data-env="${env()}"`); |
test/toolbar-body-close.test.ts +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import { expect, test } from "bun:test"; |
| 3 | 3 | import { withToolbar } from "../src/apps/toolbar.ts"; |
| 4 | 4 | |
| 5 | const TAG = `<script src="http://f.test/`; | |
| 5 | const TAG = `<script src="/__forge/toolbar`; | |
| 6 | 6 | const put = (html: string): string => |
| 7 | 7 | new TextDecoder().decode(withToolbar(new TextEncoder().encode(html), "http://f.test")); |
| 8 | 8 | /** 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"; | ||
| 11 | 11 | import { tmpdir } from "node:os"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { findChromium } from "../src/looks/capture.ts"; |
| 16 | 16 | import { chromiumRuns } from "./helpers/chrome.ts"; |
| 17 | 17 | |
| @@ -70,7 +70,7 @@ describe.skipIf(chromium === undefined)("the pill by keyboard", () => { | ||
| 70 | 70 | hostname: "127.0.0.1", |
| 71 | 71 | fetch(req) { |
| 72 | 72 | const url = new URL(req.url); |
| 73 | if (url.pathname === TOOLBAR_PATH) | |
| 73 | if (url.pathname === TOOLBAR_PATH || url.pathname === TOOLBAR_ASSET_PATH) | |
| 74 | 74 | return new Response(toolbarJs, { headers: { "content-type": "text/javascript" } }); |
| 75 | 75 | if (url.pathname === "/__forge/notes") return Response.json({ notes: [] }); |
| 76 | 76 | if (url.pathname === "/__forge/crew") return Response.json({}); |
new above
offline