Public: anyone can read this; the crew can change it.
Apps never receive the forge's or the edge's credentials: the app proxy strips x-plat-*, cookie and authorization #2957
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2919-apps-never-receive-the-forge-s-or-the-ed → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +108
−0
src/apps/proxy.ts +26
| @@ -321,6 +321,31 @@ export function grantUrl( | ||
| 321 | 321 | return `${originFor(runtime, target, host)}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(path)}`; |
| 322 | 322 | } |
| 323 | 323 | |
| 324 | /** | |
| 325 | * The forge's and the edge's credentials never reach tenant code: every | |
| 326 | * `x-plat-*` header, `authorization`, and this forge's grant cookie for | |
| 327 | * this app. Cookies are named, not swept by prefix: a forge hosted here | |
| 328 | * authenticates its own people with `forge_session` and `forge_grant_*` | |
| 329 | * on its own hostnames, and those are not ours to take. Our own session | |
| 330 | * cookie is host-only on the forge's host, so no app host ever receives it. | |
| 331 | */ | |
| 332 | export function stripForgeCredentials( | |
| 333 | headers: Headers, | |
| 334 | grantCookie: string, | |
| 335 | ): void { | |
| 336 | for (const name of [...headers.keys()]) | |
| 337 | if (name.startsWith("x-plat-")) headers.delete(name); | |
| 338 | headers.delete("authorization"); | |
| 339 | const cookie = headers.get("cookie"); | |
| 340 | if (cookie === null) return; | |
| 341 | const kept = cookie | |
| 342 | .split(";") | |
| 343 | .map((part) => part.trim()) | |
| 344 | .filter((part) => part !== "" && part.split("=", 1)[0]!.trim() !== grantCookie); | |
| 345 | if (kept.length === 0) headers.delete("cookie"); | |
| 346 | else headers.set("cookie", kept.join("; ")); | |
| 347 | } | |
| 348 | ||
| 324 | 349 | const HOP_BY_HOP = [ |
| 325 | 350 | "connection", |
| 326 | 351 | "keep-alive", |
| @@ -615,6 +640,7 @@ export async function proxyApp( | ||
| 615 | 640 | const headers = new Headers(request.headers); |
| 616 | 641 | for (const name of HOP_BY_HOP) headers.delete(name); |
| 617 | 642 | for (const name of IDENTITY_HEADERS) headers.delete(name); |
| 643 | stripForgeCredentials(headers, grantCookieName(target)); | |
| 618 | 644 | headers.set("x-forwarded-host", request.headers.get("host") ?? ""); |
| 619 | 645 | headers.set("x-forwarded-proto", url.protocol.replace(":", "")); |
| 620 | 646 | for (const [name, value] of Object.entries( |
test/apps-proxy-credentials.test.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** The app proxy never forwards the forge's or the edge's credentials. */ | |
| 2 | ||
| 3 | import { expect, test } from "bun:test"; | |
| 4 | import { stripForgeCredentials } from "../src/apps/proxy.ts"; | |
| 5 | import { edgeIdentity } from "../src/http/edge.ts"; | |
The tests call the helper alone. Add one through |
||
| 6 | ||
| 7 | test("x-plat-*, authorization and this app's grant cookie are removed; the app's and a hosted forge's cookies stay", () => { | |
| 8 | const h = new Headers({ | |
| 9 | "x-plat-edge-token": "secret", | |
| 10 | "x-plat-user": "admin", | |
| 11 | authorization: "Bearer t", | |
| 12 | cookie: "forge_grant_aaaaaaaa=ours; theme=dark; forge_session=hosted; forge_grant_bbbbbbbb=theirs", | |
| 13 | accept: "text/html", | |
| 14 | }); | |
| 15 | stripForgeCredentials(h, "forge_grant_aaaaaaaa"); | |
| 16 | expect([...h.keys()].filter((k) => k.startsWith("x-plat-"))).toEqual([]); | |
| 17 | expect(h.get("authorization")).toBeNull(); | |
| 18 | expect(h.get("cookie")).toBe("theme=dark; forge_session=hosted; forge_grant_bbbbbbbb=theirs"); | |
| 19 | expect(h.get("accept")).toBe("text/html"); | |
| 20 | }); | |
| 21 | ||
| 22 | test("a cookie header holding only our grant disappears", () => { | |
| 23 | const h = new Headers({ cookie: "forge_grant_aaaaaaaa=ours" }); | |
| 24 | stripForgeCredentials(h, "forge_grant_aaaaaaaa"); | |
| 25 | expect(h.has("cookie")).toBe(false); | |
| 26 | }); | |
| 27 | ||
| 28 | test("a stranger's x-plat-user without the edge token names nobody", () => { | |
| 29 | const r = new Request("http://x/", { headers: { "x-plat-user": "admin" } }); | |
| 30 | expect(edgeIdentity(r, "secret")).toBeUndefined(); | |
| 31 | const wrong = new Request("http://x/", { | |
| 32 | headers: { "x-plat-user": "admin", "x-plat-edge-token": "guess" }, | |
| 33 | }); | |
| 34 | expect(edgeIdentity(wrong, "secret")).toBeUndefined(); | |
| 35 | }); |
test/apps-ws.test.ts +47
| @@ -44,6 +44,12 @@ export const server = Bun.serve<{ seen: string }>({ | ||
| 44 | 44 | port, |
| 45 | 45 | fetch(request, server) { |
| 46 | 46 | const url = new URL(request.url); |
| 47 | if (url.pathname === "/headers") | |
| 48 | return Response.json({ | |
| 49 | plat: [...request.headers.keys()].filter((k) => k.startsWith("x-plat-")), | |
| 50 | authorization: request.headers.get("authorization"), | |
| 51 | cookie: request.headers.get("cookie"), | |
| 52 | }); | |
| 47 | 53 | if (url.pathname === "/last-close") return new Response(lastClose); |
| 48 | 54 | if (url.pathname === "/ws") { |
| 49 | 55 | const offered = (request.headers.get("sec-websocket-protocol") ?? "") |
| @@ -54,6 +60,8 @@ export const server = Bun.serve<{ seen: string }>({ | ||
| 54 | 60 | path: url.pathname + url.search, |
| 55 | 61 | host: request.headers.get("host"), |
| 56 | 62 | cookie: request.headers.get("cookie"), |
| 63 | plat: [...request.headers.keys()].filter((k) => k.startsWith("x-plat-")), | |
| 64 | authorization: request.headers.get("authorization"), | |
| 57 | 65 | identity: request.headers.get("x-forge-identity"), |
| 58 | 66 | forwardedHost: request.headers.get("x-forwarded-host"), |
| 59 | 67 | forwardedProto: request.headers.get("x-forwarded-proto"), |
| @@ -111,6 +119,7 @@ describe("apps: websockets through the proxy", () => { | ||
| 111 | 119 | let runtime: AppsRuntime; |
| 112 | 120 | let alice: User; |
| 113 | 121 | let repo: Repo; |
| 122 | let grantName = ""; | |
| 114 | 123 | const DOMAIN = "apps.test"; |
| 115 | 124 | const HOST = `wsecho.${DOMAIN}`; |
| 116 | 125 | |
| @@ -235,6 +244,7 @@ describe("apps: websockets through the proxy", () => { | ||
| 235 | 244 | }); |
| 236 | 245 | if (!made.ok) throw new Error(made.error); |
| 237 | 246 | repo = made.value.repo; |
| 247 | grantName = `forge_grant_${made.value.app.id.slice(-8)}`; | |
| 238 | 248 | await settled((run) => run.env === "production"); |
| 239 | 249 | // The scaffold becomes the echo app; the push redeploys production. |
| 240 | 250 | const committed = await commitToBranch(app, alice, repo, { |
| @@ -313,6 +323,43 @@ describe("apps: websockets through the proxy", () => { | ||
| 313 | 323 | expect((await client.closed).code).toBe(1000); |
| 314 | 324 | }); |
| 315 | 325 | |
| 326 | const forgeHeaders = () => ({ | |
| 327 | "x-plat-edge-token": "edge-secret", | |
| 328 | "x-plat-user": "admin", | |
| 329 | authorization: "Bearer forge-token", | |
| 330 | cookie: "sid=abc; forge_session=hosted-forge-login; forge_grant_zzzzzzzz=hosted;", | |
| 331 | }); | |
| 332 | ||
| 333 | test("a request through the proxy reaches the app without the forge's or the edge's credentials, and a hosted forge's own cookies survive", async () => { | |
| 334 | const res = await viaHost(HOST, "/headers", { | |
| 335 | headers: { | |
| 336 | ...forgeHeaders(), | |
| 337 | cookie: `sid=abc; ${grantName}=ours; forge_session=hosted; forge_grant_zzzzzzzz=hosted`, | |
| 338 | }, | |
| 339 | }); | |
| 340 | const seen = (await res.json()) as Record<string, unknown>; | |
| 341 | expect(seen["plat"]).toEqual([]); | |
| 342 | expect(seen["authorization"]).toBeNull(); | |
| 343 | expect(seen["cookie"]).toBe( | |
| 344 | "sid=abc; forge_session=hosted; forge_grant_zzzzzzzz=hosted", | |
| 345 | ); | |
| 346 | }); | |
| 347 | ||
| 348 | test("a websocket handshake reaches the app without the forge's or the edge's credentials", async () => { | |
| 349 | const client = connect("/ws", { | |
| 350 | headers: { | |
| 351 | ...forgeHeaders(), | |
| 352 | cookie: `sid=abc; ${grantName}=ours; forge_session=hosted`, | |
| 353 | }, | |
| 354 | }); | |
| 355 | expect(await client.opened).toBe(true); | |
| 356 | const seen = await seenBy(client.next); | |
| 357 | expect(seen["plat"]).toEqual([]); | |
| 358 | expect(seen["authorization"]).toBeNull(); | |
| 359 | expect(seen["cookie"]).toBe("sid=abc; forge_session=hosted"); | |
| 360 | client.ws.close(); | |
| 361 | }); | |
| 362 | ||
| 316 | 363 | test("a close on either side reaches the other, code and reason intact", async () => { |
| 317 | 364 | // From the app. |
| 318 | 365 | const fromApp = connect("/ws"); |
new above
offline
Strips every
forge_grant_*andforge_session. A hosted forge uses those exact names for its own users, so it would never see its own login. Strip only this forge's cookies:grantCookieName(target)and this forge's session name.