fix(gateway): proxy browser server functions to workspace apps
Quality / quality (ubuntu-latest) (push) Failing after 10m40s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-18 23:30:42 +05:30
parent 701acd828c
commit 03d5cb6aa6
4 changed files with 63 additions and 16 deletions
+27 -8
View File
@@ -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<RunningGateway> {
const port = opts.port ?? 3000;
@@ -497,7 +515,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
);
// Loopback-only origins, computed up front (ports are assigned by index
// before any child spawns) so every child can reach every other child
// directly — bypassing the gateway, which 404s the RPC prefix by design.
// directly — bypassing the gateway, which 404s private nested RPC routes.
const internalOriginsEnv: Readonly<Record<string, string>> = 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<RunningGateway
}
// HTTP → reverse-proxy to the app, preserving method/headers/body.
const headers = stripUntrustedInternalHeaders(
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
);
const headers =
url.pathname === RPC_PATH_PREFIX
? gatewayBrowserRpcHeaders(req, url, ip, forwardedHeaders, target.origin)
: stripUntrustedInternalHeaders(gatewayProxyHeaders(req, url, ip, forwardedHeaders));
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;
+30 -2
View File
@@ -4,6 +4,7 @@ import {
defaultGatewayHostname,
forwardAuthFailure,
forwardAuthHeaders,
gatewayBrowserRpcHeaders,
gatewayProxyHeaders,
gatewayWebSocketBackendHeaders,
stripUntrustedInternalHeaders,
@@ -176,13 +177,40 @@ test("nested SSO proxy keeps the protected app's original request headers", () =
expect(proxied.get("x-original-uri")).toBe("/settings");
});
test("the reserved RPC prefix is refused at the gateway before any proxying", () => {
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,