fix(rpc): close the service-collision fail-open and the fix-wave gaps
Critical: - router: fail loudly (WRN-SERVICE-COLLISION) when two app/services files scan to the same service name, instead of silently letting directory-walk order pick a winner. Important: - server.ts: wrap a throwing input schema so its raw message cannot escape invoke(); returns RPC_INVALID and logs server-side instead. - client.ts: race timeoutMs against transport.call so a stalled transport cannot hang the caller; rejects with a ServiceError(RPC_TRANSPORT). - client.ts: the proxy returns undefined for undeclared properties (incl. then/catch/finally) instead of a function that throws, closing the await-client thenable trap. - gateway.ts / rpc-dispatch.ts: import RPC_PATH_PREFIX / RPC_INTERNAL_HEADER from @wrnexus/rpc instead of hardcoding local copies. - gateway.test.ts: cover the RPC-prefix edge block and internal-header stripping across casing variants. - http.test.ts / client.test.ts: cover anonymous-call header omission, the internal marker, the retryable-status sweep, network/malformed/HTML failures, AbortSignal propagation, the timeout path, and timer cleanup. Minor: - transport.ts: Object.hasOwn for handler lookup; note the entry-only abort check. - client.ts: wrap a missing/invalid WRNEXUS_RPC_SECRET as a ServiceError (RPC_IDENTITY) instead of a bare Error. - rpc/package.json: drop the unused @wrnexus/authz dependency. - server.ts: implement() now throws at construction time if a declared procedure has no own handler. Verified: reverting the service-collision check and the client timeout race each make their new test fail, then restore green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,11 +13,9 @@ import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc";
|
||||
import { RESTART_EXIT_CODE } from "./restart.ts";
|
||||
|
||||
const RPC_PATH_PREFIX = "/__wrnexus/rpc";
|
||||
const RPC_INTERNAL_HEADER = "x-wrnexus-internal";
|
||||
|
||||
export type GatewayForwardAuth = (
|
||||
| {
|
||||
url: string;
|
||||
@@ -442,6 +440,15 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function isRpcGatewayPath(pathname: string): boolean {
|
||||
return pathname === RPC_PATH_PREFIX || pathname.startsWith(`${RPC_PATH_PREFIX}/`);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -625,7 +632,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === RPC_PATH_PREFIX || url.pathname.startsWith(`${RPC_PATH_PREFIX}/`)) {
|
||||
if (isRpcGatewayPath(url.pathname)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { RPC_IDENTITY_HEADER, RPC_PATH_PREFIX, type ServiceImplementation } from "@wrnexus/rpc";
|
||||
import {
|
||||
RPC_IDENTITY_HEADER,
|
||||
RPC_INTERNAL_HEADER,
|
||||
RPC_PATH_PREFIX,
|
||||
type ServiceImplementation,
|
||||
} from "@wrnexus/rpc";
|
||||
|
||||
export const RPC_INTERNAL_HEADER = "x-wrnexus-internal";
|
||||
export { RPC_INTERNAL_HEADER };
|
||||
const EDGE_HEADERS = ["x-forwarded-for", "x-forwarded-host", "x-forwarded-proto", "forwarded"];
|
||||
|
||||
export function isRpcPath(pathname: string): boolean {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc";
|
||||
import {
|
||||
defaultGatewayHostname,
|
||||
forwardAuthFailure,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
stripUntrustedInternalHeaders,
|
||||
gatewayRestartDelay,
|
||||
internalError,
|
||||
isRpcGatewayPath,
|
||||
stripInternalError,
|
||||
} from "../src/gateway.ts";
|
||||
import { resolveProductionHostname } from "../src/prod.ts";
|
||||
@@ -144,6 +146,29 @@ 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);
|
||||
expect(isRpcGatewayPath(`${RPC_PATH_PREFIX}/billing/createInvoice`)).toBe(true);
|
||||
expect(isRpcGatewayPath("/api/billing")).toBe(false);
|
||||
expect(isRpcGatewayPath("/__wrnexus/rpcfoo")).toBe(false);
|
||||
});
|
||||
|
||||
test("an inbound internal-marker header from outside is stripped regardless of casing", () => {
|
||||
for (const name of [
|
||||
RPC_INTERNAL_HEADER,
|
||||
RPC_INTERNAL_HEADER.toUpperCase(),
|
||||
"X-WrNexus-Internal",
|
||||
]) {
|
||||
const request = new Request("http://localhost:3000/path", {
|
||||
headers: { [name]: "1" },
|
||||
});
|
||||
const headers = stripUntrustedInternalHeaders(
|
||||
gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true),
|
||||
);
|
||||
expect(headers.has(RPC_INTERNAL_HEADER)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("gateway respawns development apps after an HMR restart exit", () => {
|
||||
expect(gatewayRestartDelay("development", 97, null)).toBe(0);
|
||||
expect(gatewayRestartDelay("development", 1, null)).toBe(1200);
|
||||
|
||||
Reference in New Issue
Block a user