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

Merged opened this

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

Merged

mason/forge:mason/8267-every-app-host-answers-get-forge-toolbar → main 1 commit fork

  • Merged. mason/8267-every-app-host-answers-get-forge-toolbar was merged into main as f8b47e3.

Review

Sign in to review this change.
3 files changed +125 −1
src/apps/proxy.ts +11 −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, withToolbar, type PillWhere } from "./toolbar.ts";
56 import { 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) ||
@@ -816,6 +816,16 @@ export async function proxyApp(
816816 server: Bun.Server<AppSocket>,
817817 ): Promise<Response | undefined> {
818818 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 }
819829 // A grant with this forge's own token is answered here. On a nested
820830 // hostname a grant this forge did not sign is the guest forge's, and
821831 // goes through to it like any other request.
src/apps/toolbar.ts +5
@@ -508,6 +508,11 @@ const name = `toolbar-${hash.slice(0, 16)}`;
508508 export const TOOLBAR_PATH = `/assets/${name}.js`;
509509 export const TOOLBAR_NAME = name;
510510
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
511516 /** The forge's answer for the script: immutable, because its name is its hash. */
512517 export function toolbarResponse(request: Request): Response {
513518 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs