fix(gateway): proxy browser server functions to workspace apps
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,10 +27,10 @@ function parseOriginMap(value: string | undefined): Record<string, string> {
|
||||
*
|
||||
* 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/<service>/<procedure>` 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.
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user