Public: anyone can read this; the crew can change it.
Every app host answers GET /__forge/toolbar-<hash>.js with the forge pill's script #8285
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8267-every-app-host-answers-get-forge-toolbar → main 1 commit fork
3 files
changed +125
−1
src/apps/proxy.ts +11 −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, withToolbar, type PillWhere } from "./toolbar.ts"; | |
| 56 | import { 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) || |
| @@ -816,6 +816,16 @@ export async function proxyApp( | ||
| 816 | 816 | server: Bun.Server<AppSocket>, |
| 817 | 817 | ): Promise<Response | undefined> { |
| 818 | 818 | const url = new URL(request.url); |
| 819 | // The pill's script, from the app's own host: only the exact hashed path | |
| 820 | // is it; any other /__forge/toolbar… path is the forge's 404, never the app's. | |
| 821 | if (url.pathname.startsWith(TOOLBAR_ASSET_PREFIX) && !target.nested) { | |
| 822 | return securityFloor( | |
| 823 | url.pathname === TOOLBAR_ASSET_PATH && | |
| 824 | (request.method === "GET" || request.method === "HEAD") | |
| 825 | ? toolbarResponse(request) | |
| 826 | : notFound(), | |
| 827 | ); | |
| 828 | } | |
| 819 | 829 | // A grant with this forge's own token is answered here. On a nested |
| 820 | 830 | // hostname a grant this forge did not sign is the guest forge's, and |
| 821 | 831 | // goes through to it like any other request. |
src/apps/toolbar.ts +5
| @@ -508,6 +508,11 @@ const name = `toolbar-${hash.slice(0, 16)}`; | ||
| 508 | 508 | export const TOOLBAR_PATH = `/assets/${name}.js`; |
| 509 | 509 | export const TOOLBAR_NAME = name; |
| 510 | 510 | |
| 511 | /** The same script on an app's own host, where a strict `script-src 'self'` allows it. */ | |
| 512 | export const TOOLBAR_ASSET_PATH = `/__forge/${name}.js`; | |
| 513 | /** Every path under this prefix on an app host is the forge's, whether or not it names the script. */ | |
| 514 | export const TOOLBAR_ASSET_PREFIX = "/__forge/toolbar"; | |
| 515 | ||
| 511 | 516 | /** The forge's answer for the script: immutable, because its name is its hash. */ |
| 512 | 517 | export function toolbarResponse(request: Request): Response { |
| 513 | 518 | return staticResponse(request, toolbarJs, "text/javascript; charset=utf-8", `"${hash}"`); |
test/apps-toolbar-asset.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * Every app host answers the forge pill's script from its own origin (#8267): | |
| 3 | * the exact hashed path gets the bytes the forge serves, immutable, with the | |
| 4 | * security headers; any other /__forge/toolbar… path is a 404 the app never sees. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 14 | import { TOOLBAR_ASSET_PATH, toolbarJs } from "../src/apps/toolbar.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const DOMAIN = "apps.test"; | |
| 22 | let reached = 0; | |
| 23 | ||
| 24 | let dataDir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let runtime: AppsRuntime; | |
| 28 | let backend: ReturnType<typeof Bun.serve>; | |
| 29 | let status: ReturnType<typeof spyOn>; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | backend = Bun.serve({ | |
| 33 | port: 0, | |
| 34 | fetch: () => { | |
| 35 | reached++; | |
| 36 | return new Response("the app"); | |
| 37 | }, | |
| 38 | }); | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-toolbar-asset-")); | |
| 40 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 41 | servers = await serve(app); | |
| 42 | runtime = new AppsRuntime(app, { | |
| 43 | domain: DOMAIN, | |
| 44 | scheme: "http", | |
| 45 | publicPort: servers.port, | |
| 46 | forgeUrl: servers.url, | |
| 47 | concurrency: 1, | |
| 48 | }); | |
| 49 | await runtime.start(); | |
| 50 | status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 51 | () => | |
| 52 | ({ | |
| 53 | env: "production", | |
| 54 | state: "running", | |
| 55 | port: backend.port, | |
| 56 | commitOid: "a".repeat(40), | |
| 57 | }) as EnvStatus, | |
| 58 | ); | |
| 59 | const a = await createUser(app.db, { | |
| 60 | origin: "seed", | |
| 61 | trusted: true, | |
| 62 | username: "alice", | |
| 63 | email: "[email protected]", | |
| 64 | password: "a-good-passphrase", | |
| 65 | adminIfFirst: true, | |
| 66 | }); | |
| 67 | if (!a.ok) throw new Error(a.error); | |
| 68 | const made = await newApp(app, a.value, { | |
| 69 | name: "notes", | |
| 70 | description: "A place for words", | |
| 71 | visibility: "public", | |
| 72 | }); | |
| 73 | if (!made.ok) throw new Error(String(made.error)); | |
| 74 | }, 120_000); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | status.mockRestore(); | |
| 78 | await runtime.stop(); | |
| 79 | servers.stop(); | |
| 80 | backend.stop(true); | |
| 81 | app.close(); | |
| 82 | await rm(dataDir, { recursive: true, force: true }); | |
| 83 | }); | |
| 84 | ||
| 85 | const get = (path: string) => | |
| 86 | fetch(`${servers.url}${path}`, { headers: { host: `notes.${DOMAIN}` }, redirect: "manual" }); | |
| 87 | ||
| 88 | test("the app's own host serves the pill's script, immutable, with the security headers", async () => { | |
| 89 | const res = await get(TOOLBAR_ASSET_PATH); | |
| 90 | expect(res.status).toBe(200); | |
| 91 | expect(await res.text()).toBe(toolbarJs); | |
| 92 | expect(res.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); | |
| 93 | expect(res.headers.get("cache-control")).toBe("public, max-age=31536000, immutable"); | |
| 94 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 95 | expect(res.headers.get("referrer-policy")).toBeTruthy(); | |
| 96 | expect(reached).toBe(0); | |
| 97 | }); | |
| 98 | ||
| 99 | test("a wrong hash, or any other /__forge/toolbar path, is a 404 the app never sees", async () => { | |
| 100 | for (const path of [ | |
| 101 | "/__forge/toolbar-0000000000000000.js", | |
| 102 | "/__forge/toolbar.js", | |
| 103 | "/__forge/toolbar", | |
| 104 | `${TOOLBAR_ASSET_PATH}x`, | |
| 105 | "/__forge/toolbar/x", | |
| 106 | ]) | |
| 107 | expect((await get(path)).status).toBe(404); | |
| 108 | expect(reached).toBe(0); | |
| 109 | }); |
new above
offline