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

Merged opened this in answer to

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

Merged

scout/forge:scout/2919-apps-never-receive-the-forge-s-or-the-ed → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2919-apps-never-receive-the-forge-s-or-the-ed was merged into main as 79fb8f0.

Review

Sign in to review this change.
3 files changed +108 −0
src/apps/proxy.ts +26
@@ -321,6 +321,31 @@ export function grantUrl(
321321 return `${originFor(runtime, target, host)}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(path)}`;
322322 }
323323
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())
outdated

Strips every forge_grant_* and forge_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.

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
324349 const HOP_BY_HOP = [
325350 "connection",
326351 "keep-alive",
@@ -615,6 +640,7 @@ export async function proxyApp(
615640 const headers = new Headers(request.headers);
616641 for (const name of HOP_BY_HOP) headers.delete(name);
617642 for (const name of IDENTITY_HEADERS) headers.delete(name);
643 stripForgeCredentials(headers, grantCookieName(target));
618644 headers.set("x-forwarded-host", request.headers.get("host") ?? "");
619645 headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
620646 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 proxyApp (and a websocket handshake) with a tenant that echoes its headers, so removing the call at proxy.ts:640 fails a test.

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 }>({
4444 port,
4545 fetch(request, server) {
4646 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 });
4753 if (url.pathname === "/last-close") return new Response(lastClose);
4854 if (url.pathname === "/ws") {
4955 const offered = (request.headers.get("sec-websocket-protocol") ?? "")
@@ -54,6 +60,8 @@ export const server = Bun.serve<{ seen: string }>({
5460 path: url.pathname + url.search,
5561 host: request.headers.get("host"),
5662 cookie: request.headers.get("cookie"),
63 plat: [...request.headers.keys()].filter((k) => k.startsWith("x-plat-")),
64 authorization: request.headers.get("authorization"),
5765 identity: request.headers.get("x-forge-identity"),
5866 forwardedHost: request.headers.get("x-forwarded-host"),
5967 forwardedProto: request.headers.get("x-forwarded-proto"),
@@ -111,6 +119,7 @@ describe("apps: websockets through the proxy", () => {
111119 let runtime: AppsRuntime;
112120 let alice: User;
113121 let repo: Repo;
122 let grantName = "";
114123 const DOMAIN = "apps.test";
115124 const HOST = `wsecho.${DOMAIN}`;
116125
@@ -235,6 +244,7 @@ describe("apps: websockets through the proxy", () => {
235244 });
236245 if (!made.ok) throw new Error(made.error);
237246 repo = made.value.repo;
247 grantName = `forge_grant_${made.value.app.id.slice(-8)}`;
238248 await settled((run) => run.env === "production");
239249 // The scaffold becomes the echo app; the push redeploys production.
240250 const committed = await commitToBranch(app, alice, repo, {
@@ -313,6 +323,43 @@ describe("apps: websockets through the proxy", () => {
313323 expect((await client.closed).code).toBe(1000);
314324 });
315325
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
316363 test("a close on either side reaches the other, code and reason intact", async () => {
317364 // From the app.
318365 const fromApp = connect("/ws");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs