diff --git a/packages/dev-server/src/gateway.ts b/packages/dev-server/src/gateway.ts index a2ef26c9..2cf4bb8b 100644 --- a/packages/dev-server/src/gateway.ts +++ b/packages/dev-server/src/gateway.ts @@ -470,19 +470,37 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers { } /** - * The reserved inter-app RPC namespace is refused at the gateway edge, before - * any proxying — it is only ever mounted by a child app's own dev-server and - * must never be reachable from outside the workspace. + * Private inter-app RPC routes are refused at the gateway edge. The exact + * prefix is the CSRF-protected browser-to-app server-function endpoint and is + * intentionally proxied to the selected child app. */ export function isRpcGatewayPath(pathname: string): boolean { return ( - pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`) || pathname === RPC_STREAM_PATH_PREFIX || pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`) ); } +/** Build the trusted internal hop for a browser server-function request. */ +export function gatewayBrowserRpcHeaders( + req: Request, + url: URL, + ip: string, + forwardedHeaders: boolean, + backendOrigin: string, +): Headers { + const headers = stripUntrustedInternalHeaders( + gatewayProxyHeaders(req, url, ip, forwardedHeaders), + ); + // The public request already passed the gateway's host and fetch-metadata + // checks. Present the internal proxy hop as same-origin to the child while + // retaining the double-submit CSRF cookie and header. + headers.set("origin", backendOrigin); + headers.delete("host"); + return headers; +} + /** Boot every app as a child process, then route by Host on one gateway port. */ export async function startGateway(opts: GatewayOptions): Promise { const port = opts.port ?? 3000; @@ -497,7 +515,7 @@ export async function startGateway(opts: GatewayOptions): Promise> = Object.freeze( Object.fromEntries( opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]), @@ -737,9 +755,10 @@ export async function startGateway(opts: GatewayOptions): Promise { - expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true); +test("the gateway proxies browser server functions but refuses private RPC routes", () => { + expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(false); expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true); expect(isRpcGatewayPath("/api/billing")).toBe(false); expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false); }); +test("browser RPC proxy preserves CSRF credentials and trusts only the internal hop", () => { + const request = new Request(`http://web.localhost:3000${RPC_PATH_PREFIX}`, { + method: "POST", + headers: { + host: "web.localhost:3000", + origin: "http://web.localhost:3000", + cookie: "wrn-csrf=token", + "x-csrf-token": "token", + [RPC_INTERNAL_HEADER]: "forged", + }, + }); + const headers = gatewayBrowserRpcHeaders( + request, + new URL(request.url), + "127.0.0.1", + true, + "http://127.0.0.1:3001", + ); + + expect(headers.get("origin")).toBe("http://127.0.0.1:3001"); + expect(headers.get("cookie")).toBe("wrn-csrf=token"); + expect(headers.get("x-csrf-token")).toBe("token"); + expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000"); + expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false); + expect(headers.has("host")).toBe(false); +}); + test("an inbound internal-marker header from outside is stripped regardless of casing", () => { for (const name of [ RPC_INTERNAL_HEADER, diff --git a/packages/rpc/src/http.ts b/packages/rpc/src/http.ts index a473975b..e7988d2f 100644 --- a/packages/rpc/src/http.ts +++ b/packages/rpc/src/http.ts @@ -27,10 +27,10 @@ function parseOriginMap(value: string | undefined): Record { * * Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each * child before spawning it) over `appOrigin`, which resolves the app's - * PUBLIC origin. The public origin is the wrong target for RPC: the gateway - * unconditionally 404s the reserved `/__wrnexus/rpc` prefix on anything that - * arrives at a public origin — that block is the whole point, it is what - * keeps inter-app calls off the public internet. Falling back to `appOrigin` + * PUBLIC origin. The public origin is the wrong target for inter-app RPC: the + * gateway 404s private `/__wrnexus/rpc//` routes. (The + * exact prefix remains the CSRF-protected browser server-function endpoint.) + * That block keeps inter-app calls off the public internet. Falling back to `appOrigin` * when no internal-origin map is present keeps single-app and test setups * (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working. */ diff --git a/packages/rpc/test/integration.test.ts b/packages/rpc/test/integration.test.ts index 055244b1..a546ab42 100644 --- a/packages/rpc/test/integration.test.ts +++ b/packages/rpc/test/integration.test.ts @@ -184,8 +184,8 @@ describe("RPC integration", () => { const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS; try { // The workspace (public) origin deliberately points somewhere that - // cannot serve the RPC — the gateway 404s the RPC prefix on any - // request that arrives at a public origin. Only the internal-origin + // cannot serve the RPC — the gateway 404s private nested RPC routes + // that arrive at a public origin. Only the internal-origin // map points at the real server. If httpTransport() ever falls back // to the public origin by default again, this call fails. process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({