Public: anyone can read this; the crew can change it.
A stranger reading an app sees only the app's own Server-Timing; the forge's entries (forge-lag, forge-route and the rest) go to the app's writers #11113
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11112-a-stranger-reading-an-app-sees-only-the → main 3 commits fork
7 files
changed +133
−24
src/apps/proxy.ts +43 −8
| @@ -424,8 +424,8 @@ function sealedPull(runtime: AppsRuntime, target: AppTarget): Issue | undefined | ||
| 424 | 424 | |
| 425 | 425 | /** |
| 426 | 426 | * The response with the phases the request named, as `Server-Timing`: the |
| 427 | * one writer for the proxy, on the forge's own `/__forge/*` pages and on a | |
| 428 | * relayed app answer alike. It appends, so an app's own entries stay first; | |
| 427 | * one writer for the proxy, on the forge's own `/__forge/*` pages and, for | |
| 428 | * a reader who may see them (`relayAnswer`), on a relayed app answer. It appends, so an app's own entries stay first; | |
| 429 | 429 | * on an app answer the forge's phases are `forge-` named (`relayPhase`) so |
| 430 | 430 | * none can collide with the app's or with the server's `route`. |
| 431 | 431 | */ |
| @@ -447,6 +447,40 @@ function withPhases(request: Request, res: Response): Response { | ||
| 447 | 447 | return res; |
| 448 | 448 | } |
| 449 | 449 | |
| 450 | /** | |
| 451 | * A relayed app answer, with the forge's phases only for a reader who may | |
| 452 | * see them (#11112): someone who may write the app's repository, or a forge | |
| 453 | * admin. Everyone else gets the app's own Server-Timing exactly as it came; | |
| 454 | * how loaded the forge is and how long its lookups take are not a | |
| 455 | * stranger's to read. The app's speed card and /system read the same | |
| 456 | * numbers on the server side (`noteRelayWait`, the loop watch), never here. | |
| 457 | */ | |
| 458 | function relayAnswer(request: Request, res: Response): Response { | |
| 459 | if (forgeReaders.has(request)) return withPhases(request, res); | |
| 460 | takePhases(request); | |
| 461 | stallFor.delete(request); | |
| 462 | return res; | |
| 463 | } | |
| 464 | ||
| 465 | /** The requests whose reader is told the forge's phases (`relayAnswer`). */ | |
| 466 | const forgeReaders = new WeakSet<Request>(); | |
| 467 | ||
| 468 | /** | |
| 469 | * Whether the grant names someone told the forge's phases on this app: an | |
| 470 | * active person or agent who may write its repository, or a forge admin. | |
| 471 | * A visitor with no grant is never asked about, so a stranger costs no read. | |
| 472 | */ | |
| 473 | function readsForgeTiming(runtime: AppsRuntime, target: AppTarget, who: GrantWho | undefined): boolean { | |
| 474 | if (who === undefined) return false; | |
| 475 | const db = runtime.app.db; | |
| 476 | const viewer = getUserById(db, who.id); | |
| 477 | if (viewer === undefined || !isActive(viewer)) return false; | |
| 478 | // A forge admin, as `forgeAdmin` reads one: an agent never is. | |
| 479 | if (viewer.isAdmin && viewer.kind !== "agent") return true; | |
| 480 | const repo = getRepoById(db, target.app.repoId); | |
| 481 | return repo !== undefined && canWrite(db, repo, viewer); | |
| 482 | } | |
| 483 | ||
| 450 | 484 | /** |
| 451 | 485 | * The forge's phases of relaying an app answer, `forge-` named. Durations |
| 452 | 486 | * only, under these fixed names: `route` is `lookup` + `state` + `session`. |
| @@ -1179,6 +1213,7 @@ export async function proxyApp( | ||
| 1179 | 1213 | : admitted.error; |
| 1180 | 1214 | const { status, port, grant: vouched } = admitted.value; |
| 1181 | 1215 | runtime.touch(target.app, target.env); |
| 1216 | if (readsForgeTiming(runtime, target, vouched?.who)) forgeReaders.add(request); | |
| 1182 | 1217 | const began = performance.now(); |
| 1183 | 1218 | // `route` split where it goes: the server finding the app (`lookup`), the |
| 1184 | 1219 | // doors and the environment's state (`state`), the grant read (`session`). |
| @@ -1259,7 +1294,7 @@ export async function proxyApp( | ||
| 1259 | 1294 | } catch { |
| 1260 | 1295 | answered(); |
| 1261 | 1296 | relayUpstream(runtime, request, target, performance.now() - sent, vouched?.who); |
| 1262 | return withPhases(request, unavailable(runtime, target, status, "the app stopped answering")); | |
| 1297 | return relayAnswer(request, unavailable(runtime, target, status, "the app stopped answering")); | |
| 1263 | 1298 | } |
| 1264 | 1299 | relayUpstream(runtime, request, target, performance.now() - sent, vouched?.who); |
| 1265 | 1300 | const out = new Headers(response.headers); |
| @@ -1302,7 +1337,7 @@ export async function proxyApp( | ||
| 1302 | 1337 | // past that it goes on as it came, what was read first. |
| 1303 | 1338 | const read = await readPage(response.body); |
| 1304 | 1339 | if (!read.whole) |
| 1305 | return withPhases( | |
| 1340 | return relayAnswer( | |
| 1306 | 1341 | request, |
| 1307 | 1342 | new Response(read.rest, { |
| 1308 | 1343 | status: response.status, |
| @@ -1314,7 +1349,7 @@ export async function proxyApp( | ||
| 1314 | 1349 | } |
| 1315 | 1350 | relayPhase(request, "body", performance.now() - rewriting); |
| 1316 | 1351 | } catch { |
| 1317 | return withPhases(request, unavailable(runtime, target, status, "the app stopped answering")); | |
| 1352 | return relayAnswer(request, unavailable(runtime, target, status, "the app stopped answering")); | |
| 1318 | 1353 | } |
| 1319 | 1354 | // A signed-in person's page load gets the panel's one-use proof, and so |
| 1320 | 1355 | // is theirs alone: never stored or shared. A page the app's own script |
| @@ -1369,11 +1404,11 @@ export async function proxyApp( | ||
| 1369 | 1404 | out.delete("content-length"); |
| 1370 | 1405 | out.delete("content-encoding"); |
| 1371 | 1406 | relayPhase(request, "rewrite", performance.now() - rewriting); |
| 1372 | return withPhases(request, new Response(null, { status: 304, headers: out })); | |
| 1407 | return relayAnswer(request, new Response(null, { status: 304, headers: out })); | |
| 1373 | 1408 | } |
| 1374 | 1409 | } |
| 1375 | 1410 | relayPhase(request, "rewrite", performance.now() - rewriting); |
| 1376 | return withPhases( | |
| 1411 | return relayAnswer( | |
| 1377 | 1412 | request, |
| 1378 | 1413 | new Response(sent, { |
| 1379 | 1414 | status: response.status, |
| @@ -1382,7 +1417,7 @@ export async function proxyApp( | ||
| 1382 | 1417 | }), |
| 1383 | 1418 | ); |
| 1384 | 1419 | } |
| 1385 | return withPhases( | |
| 1420 | return relayAnswer( | |
| 1386 | 1421 | request, |
| 1387 | 1422 | new Response(body, { |
| 1388 | 1423 | status: response.status, |
src/apps/relay_wait.ts +4
| @@ -14,6 +14,10 @@ | ||
| 14 | 14 | * owners, on the speed card and the terminal line; a signed-in forge admin |
| 15 | 15 | * also gets them on the relayed answer as `forge-stall` (`stallTiming`). |
| 16 | 16 | * |
| 17 | * Every reading here is taken on the server side, whoever asked: the | |
| 18 | * relayed answer's `forge-*` entries go only to the app's writers and the | |
| 19 | * forge's admins (#11112), and nothing here reads them back from a header. | |
| 20 | * | |
| 17 | 21 | * Process memory, not the database: one O(1) compare per relay, at most |
| 18 | 22 | * `KEEP_APPS` apps (the oldest reading goes first), gone on restart. The |
| 19 | 23 | * stalls are read only when the forge waited, and at most `HELD_NAMES` kept. |
src/http/server.ts +4 −2
| @@ -2456,8 +2456,10 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2456 | 2456 | finishedStrangerDeep(owner, request); |
| 2457 | 2457 | try { |
| 2458 | 2458 | const header = serverTiming(ms, takeDbTime(request), takePhases(request)); |
| 2459 | // `lag` is the loop's, not this request's: kept out of the record's phases. | |
| 2460 | response.headers.set("server-timing", withLag(header, currentLoopLag())); | |
| 2459 | // `lag` is the loop's, not this request's: kept out of the record's | |
| 2460 | // phases, and told only to an admin (#11112): how loaded the forge is | |
| 2461 | // is not a stranger's to read. /system reads it server-side. | |
| 2462 | response.headers.set("server-timing", tag.role === "admin" ? withLag(header, currentLoopLag()) : header); | |
| 2461 | 2463 | const end = performance.now(); |
| 2462 | 2464 | const family = familyOf(request.headers.get("user-agent") ?? ""); |
| 2463 | 2465 | perfRecord.add( |
test/apps-proxy-timing.test.ts +51 −4
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | * A proxied answer keeps the app's Server-Timing and appends the forge's own | |
| 3 | * phases, `forge-` named: where a slow request spent its time. | |
| 2 | * A proxied answer keeps the app's Server-Timing and, for a reader who may | |
| 3 | * write the app's repository, appends the forge's own phases, `forge-` | |
| 4 | * named: where a slow request spent its time. Anyone else gets the app's | |
| 5 | * own entries untouched (#11112). | |
| 4 | 6 | */ |
| 5 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 6 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -9,6 +11,8 @@ import { join } from "node:path"; | ||
| 9 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 12 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; |
| 11 | 13 | import { appVisitors, newApp } from "../src/actions/apps.ts"; |
| 14 | import { signGrant } from "../src/apps/proxy.ts"; | |
| 15 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 12 | 16 | import { clearRelayWaits } from "../src/apps/relay_wait.ts"; |
| 13 | 17 | import { visitorsLine } from "../src/apps/vitals.ts"; |
| 14 | 18 | import { commitToBranch } from "../src/actions/repos.ts"; |
| @@ -59,10 +63,25 @@ describe("a proxied answer's Server-Timing", () => { | ||
| 59 | 63 | let servers: Servers; |
| 60 | 64 | let runtime: AppsRuntime; |
| 61 | 65 | let alice: User; |
| 66 | let bob: User; | |
| 62 | 67 | let repo: Repo; |
| 63 | 68 | |
| 64 | const viaHost = (path = "/") => | |
| 65 | fetch(`${servers.url}${path}`, { headers: { host: `hello.${DOMAIN}`, accept: "text/html" }, redirect: "manual" }); | |
| 69 | /** A request to the app as `who` (a grant cookie), or as a visitor with none. */ | |
| 70 | const asViewer = (who: User | undefined, path = "/") => { | |
| 71 | const headers: Record<string, string> = { host: `hello.${DOMAIN}`, accept: "text/html" }; | |
| 72 | if (who !== undefined) { | |
| 73 | const record = getAppByRepo(app.db, repo.id)!; | |
| 74 | const grant = signGrant(app.config.dataDir, { app: record, env: "production" }, 60_000, Date.now(), { | |
| 75 | id: who.id, | |
| 76 | name: who.username, | |
| 77 | role: who.isAdmin ? "admin" : "user", | |
| 78 | }); | |
| 79 | headers.cookie = `forge_grant_${record.id.slice(-8)}=${grant}`; | |
| 80 | } | |
| 81 | return fetch(`${servers.url}${path}`, { headers, redirect: "manual" }); | |
| 82 | }; | |
| 83 | /** The app's owner reading it: the one who is told the forge's phases. */ | |
| 84 | const viaHost = (path = "/") => asViewer(alice, path); | |
| 66 | 85 | |
| 67 | 86 | beforeAll(async () => { |
| 68 | 87 | dataDir = await mkdtemp(join(tmpdir(), "forge-ptiming-")); |
| @@ -83,6 +102,13 @@ describe("a proxied answer's Server-Timing", () => { | ||
| 83 | 102 | }); |
| 84 | 103 | if (!created.ok) throw new Error(created.error); |
| 85 | 104 | alice = created.value; |
| 105 | const other = await createUser(app.db, { origin: "seed", trusted: true, | |
| 106 | username: "bob", | |
| 107 | email: "[email protected]", | |
| 108 | password: "a-good-passphrase", | |
| 109 | }); | |
| 110 | if (!other.ok) throw new Error(other.error); | |
| 111 | bob = other.value; | |
| 86 | 112 | const made = await newApp(app, alice, { name: "hello", description: "d", visibility: "public" }); |
| 87 | 113 | if (!made.ok) throw new Error(String(made.error)); |
| 88 | 114 | repo = made.value.repo; |
| @@ -125,6 +151,27 @@ describe("a proxied answer's Server-Timing", () => { | ||
| 125 | 151 | expect(header).not.toContain("hello"); |
| 126 | 152 | }); |
| 127 | 153 | |
| 154 | test("a visitor and a signed-in reader who may not write get the app's entries untouched", async () => { | |
| 155 | for (const who of [undefined, bob]) { | |
| 156 | for (const path of ["/healthz", "/"]) { | |
| 157 | const res = await asViewer(who, path); | |
| 158 | expect(res.status).toBe(200); | |
| 159 | await res.text(); | |
| 160 | const header = res.headers.get("server-timing"); | |
| 161 | // Exactly what the app wrote: no forge- entry, and none of the forge's unnamed ones. | |
| 162 | expect(header).toBe(path === "/healthz" ? "db;dur=0.1, app;dur=1.1" : null); | |
| 163 | } | |
| 164 | } | |
| 165 | }); | |
| 166 | ||
| 167 | test("the writer's copy keeps the app's entries first, as the app wrote them", async () => { | |
| 168 | const header = (await viaHost("/healthz")).headers.get("server-timing")!; | |
| 169 | expect(header.split(", ").slice(0, 2)).toEqual(["db;dur=0.1", "app;dur=1.1"]); | |
| 170 | expect(header.split(", ").slice(2).every((e) => e.startsWith("forge-"))).toBe(true); | |
| 171 | for (const name of ["forge-route", "forge-lookup", "forge-session", "forge-lag", "forge-ttfb"]) | |
| 172 | expect(dur(header, name)).toBeDefined(); | |
| 173 | }); | |
| 174 | ||
| 128 | 175 | test("an app slow to its headers shows in forge-ttfb, which is forge-upstream", async () => { |
| 129 | 176 | const res = await viaHost("/ttfb"); |
| 130 | 177 | expect(res.status).toBe(200); |
test/loop-worst.test.ts +24 −4
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The worst event-loop lag of the last 10 s (#10288): the window by hand, |
| 3 | * then a real 150 ms block seen as `lag;dur` on the next response and as | |
| 4 | * `loopLagMs` on `/healthz`, and gone once the injected clock steps 10 s. | |
| 3 | * then a real 150 ms block seen as `lag;dur` on an admin's next response and | |
| 4 | * as `loopLagMs` on `/healthz`, and gone once the injected clock steps 10 s. | |
| 5 | * A stranger's response and a signed-in non-admin's carry no `lag` (#11112). | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| @@ -11,6 +12,9 @@ import { join } from "node:path"; | ||
| 11 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 13 | import { withLag } from "../src/core/route_timing.ts"; |
| 13 | 14 | import { serve, type Servers } from "../src/http/server.ts"; |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { adminCookie } from "./helpers/admin.ts"; | |
| 14 | 18 | import { createLoopWorst, startLoopWorst, stopLoopWorst } from "../src/perf/loop_worst.ts"; |
| 15 | 19 | |
| 16 | 20 | function blockFor(ms: number): void { |
| @@ -82,6 +86,11 @@ describe("on every response", () => { | ||
| 82 | 86 | let app: App; |
| 83 | 87 | let servers: Servers; |
| 84 | 88 | let skew = 0; |
| 89 | let admin: string; | |
| 90 | let member: string; | |
| 91 | /** A matched route (the session is read there), as the cookie's holder. */ | |
| 92 | const page = (cookie?: string) => | |
| 93 | fetch(`${servers.url}/nobody/nothing`, cookie === undefined ? {} : { headers: { cookie } }); | |
| 85 | 94 | |
| 86 | 95 | beforeAll(async () => { |
| 87 | 96 | dataDir = await mkdtemp(join(tmpdir(), "forge-loop-worst-")); |
| @@ -91,6 +100,10 @@ describe("on every response", () => { | ||
| 91 | 100 | stopLoopWorst(); |
| 92 | 101 | startLoopWorst({ clock: () => performance.now() + skew, everyMs: 5 }); |
| 93 | 102 | servers = await serve(app); |
| 103 | admin = await adminCookie(app, { username: "alice", email: "[email protected]" }); | |
| 104 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" }); | |
| 105 | if (!made.ok) throw new Error(made.error); | |
| 106 | member = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 94 | 107 | }); |
| 95 | 108 | |
| 96 | 109 | afterAll(async () => { |
| @@ -104,11 +117,18 @@ describe("on every response", () => { | ||
| 104 | 117 | blockFor(150); |
| 105 | 118 | // The overdue wake runs before this one: timers fire in due order. |
| 106 | 119 | await new Promise((r) => setTimeout(r, 0)); |
| 107 | const res = await fetch(`${servers.url}/nope`); | |
| 120 | const res = await page(admin); | |
| 108 | 121 | await res.text(); |
| 109 | 122 | const header = res.headers.get("server-timing"); |
| 110 | 123 | expect(header).toContain("app;dur="); |
| 111 | 124 | expect(lagOf(header)).toBeGreaterThanOrEqual(140); |
| 125 | // The same lag, untold to a stranger and to a signed-in non-admin. | |
| 126 | for (const cookie of [undefined, member]) { | |
| 127 | const other = await page(cookie); | |
| 128 | await other.text(); | |
| 129 | expect(other.headers.get("server-timing")).toContain("app;dur="); | |
| 130 | expect(lagOf(other.headers.get("server-timing"))).toBeNull(); | |
| 131 | } | |
| 112 | 132 | |
| 113 | 133 | const health = await fetch(`${servers.url}/healthz`, { headers: { accept: "application/json" } }); |
| 114 | 134 | const body = (await health.json()) as { ok: boolean; loopLagMs: number }; |
| @@ -121,7 +141,7 @@ describe("on every response", () => { | ||
| 121 | 141 | stopLoopWorst(); |
| 122 | 142 | startLoopWorst({ clock: () => performance.now() + skew, everyMs: 60_000 }); |
| 123 | 143 | skew += 10_000; |
| 124 | const later = await fetch(`${servers.url}/nope`); | |
| 144 | const later = await page(admin); | |
| 125 | 145 | await later.text(); |
| 126 | 146 | expect(lagOf(later.headers.get("server-timing"))).toBe(0); |
| 127 | 147 | }); |
test/relay-stall-name.test.ts +4 −4
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * A relayed wait the forge held says what held it (#11000): the stall's |
| 3 | 3 | * route pattern or `job:<name>`, the longest first, on the app's speed card |
| 4 | 4 | * and terminal line; a signed-in forge admin also gets `forge-stall` on the |
| 5 | * relayed answer, and nobody else does. | |
| 5 | * relayed answer, and nobody else does: a visitor and a reader who may not | |
| 6 | * write the app get no `forge-` entry at all (#11112). | |
| 6 | 7 | */ |
| 7 | 8 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; |
| 8 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -199,9 +200,8 @@ describe("forge-stall on the relayed answer", () => { | ||
| 199 | 200 | const res = await asViewer(who); |
| 200 | 201 | expect(res.status).toBe(200); |
| 201 | 202 | const header = res.headers.get("server-timing") ?? ""; |
| 202 | // The wait was the forge's (forge-lag is there) but the stall is not told. | |
| 203 | expect(header).toContain("forge-lag;dur="); | |
| 204 | expect(header).not.toContain("forge-stall"); | |
| 203 | // The wait was the forge's, but neither it nor the stall is told (#11112). | |
| 204 | expect(header).not.toContain("forge-"); | |
| 205 | 205 | expect(header).not.toContain("job:"); |
| 206 | 206 | } |
| 207 | 207 | } |
test/route-timing.test.ts +3 −2
| @@ -46,8 +46,9 @@ test("a response carries Server-Timing, and /alice/x and /bob/y share one route | ||
| 46 | 46 | const a = await fetch(`${servers.url}/alice/x`); |
| 47 | 47 | await fetch(`${servers.url}/bob/y`); |
| 48 | 48 | const health = await fetch(`${servers.url}/healthz`); |
| 49 | expect(a.headers.get("server-timing")).toMatch(/^app;dur=\d+\.\d, lag;dur=\d+\.\d$/); | |
| 50 | expect(health.headers.get("server-timing")).toMatch(/^db;dur=\d+\.\d, app;dur=\d+\.\d, lag;dur=\d+\.\d$/); | |
| 49 | // A stranger reads no `lag`: how loaded the forge is is an admin's to know (#11112). | |
| 50 | expect(a.headers.get("server-timing")).toMatch(/^app;dur=\d+\.\d$/); | |
| 51 | expect(health.headers.get("server-timing")).toMatch(/^db;dur=\d+\.\d, app;dur=\d+\.\d$/); | |
| 51 | 52 | servers.stop(); |
| 52 | 53 | |
| 53 | 54 | const rows = readTiming(app.db, ""); |