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
|
* Private inter-app RPC routes are refused at the gateway edge. The exact
|
||||||
* any proxying — it is only ever mounted by a child app's own dev-server and
|
* prefix is the CSRF-protected browser-to-app server-function endpoint and is
|
||||||
* must never be reachable from outside the workspace.
|
* intentionally proxied to the selected child app.
|
||||||
*/
|
*/
|
||||||
export function isRpcGatewayPath(pathname: string): boolean {
|
export function isRpcGatewayPath(pathname: string): boolean {
|
||||||
return (
|
return (
|
||||||
pathname === RPC_PATH_PREFIX ||
|
|
||||||
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
|
||||||
pathname === RPC_STREAM_PATH_PREFIX ||
|
pathname === RPC_STREAM_PATH_PREFIX ||
|
||||||
pathname.startsWith(`${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. */
|
/** Boot every app as a child process, then route by Host on one gateway port. */
|
||||||
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
|
||||||
const port = opts.port ?? 3000;
|
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
|
// Loopback-only origins, computed up front (ports are assigned by index
|
||||||
// before any child spawns) so every child can reach every other child
|
// 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(
|
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
|
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.
|
// HTTP → reverse-proxy to the app, preserving method/headers/body.
|
||||||
const headers = stripUntrustedInternalHeaders(
|
const headers =
|
||||||
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
|
url.pathname === RPC_PATH_PREFIX
|
||||||
);
|
? gatewayBrowserRpcHeaders(req, url, ip, forwardedHeaders, target.origin)
|
||||||
|
: stripUntrustedInternalHeaders(gatewayProxyHeaders(req, url, ip, forwardedHeaders));
|
||||||
const body =
|
const body =
|
||||||
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
defaultGatewayHostname,
|
defaultGatewayHostname,
|
||||||
forwardAuthFailure,
|
forwardAuthFailure,
|
||||||
forwardAuthHeaders,
|
forwardAuthHeaders,
|
||||||
|
gatewayBrowserRpcHeaders,
|
||||||
gatewayProxyHeaders,
|
gatewayProxyHeaders,
|
||||||
gatewayWebSocketBackendHeaders,
|
gatewayWebSocketBackendHeaders,
|
||||||
stripUntrustedInternalHeaders,
|
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");
|
expect(proxied.get("x-original-uri")).toBe("/settings");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the reserved RPC prefix is refused at the gateway before any proxying", () => {
|
test("the gateway proxies browser server functions but refuses private RPC routes", () => {
|
||||||
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(true);
|
expect(isRpcGatewayPath(RPC_PATH_PREFIX)).toBe(false);
|
||||||
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
||||||
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
||||||
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).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", () => {
|
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
||||||
for (const name of [
|
for (const name of [
|
||||||
RPC_INTERNAL_HEADER,
|
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
|
* Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each
|
||||||
* child before spawning it) over `appOrigin`, which resolves the app's
|
* child before spawning it) over `appOrigin`, which resolves the app's
|
||||||
* PUBLIC origin. The public origin is the wrong target for RPC: the gateway
|
* PUBLIC origin. The public origin is the wrong target for inter-app RPC: the
|
||||||
* unconditionally 404s the reserved `/__wrnexus/rpc` prefix on anything that
|
* gateway 404s private `/__wrnexus/rpc/<service>/<procedure>` routes. (The
|
||||||
* arrives at a public origin — that block is the whole point, it is what
|
* exact prefix remains the CSRF-protected browser server-function endpoint.)
|
||||||
* keeps inter-app calls off the public internet. Falling back to `appOrigin`
|
* 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
|
* when no internal-origin map is present keeps single-app and test setups
|
||||||
* (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working.
|
* (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ describe("RPC integration", () => {
|
|||||||
const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
const originalInternal = process.env.WRNEXUS_INTERNAL_ORIGINS;
|
||||||
try {
|
try {
|
||||||
// The workspace (public) origin deliberately points somewhere that
|
// The workspace (public) origin deliberately points somewhere that
|
||||||
// cannot serve the RPC — the gateway 404s the RPC prefix on any
|
// cannot serve the RPC — the gateway 404s private nested RPC routes
|
||||||
// request that arrives at a public origin. Only the internal-origin
|
// that arrive at a public origin. Only the internal-origin
|
||||||
// map points at the real server. If httpTransport() ever falls back
|
// map points at the real server. If httpTransport() ever falls back
|
||||||
// to the public origin by default again, this call fails.
|
// to the public origin by default again, this call fails.
|
||||||
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
|
process.env.WRNEXUS_WORKSPACE_ORIGINS = JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user