237 lines
8.4 KiB
TypeScript
237 lines
8.4 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { RPC_INTERNAL_HEADER, RPC_PATH_PREFIX } from "@wrnexus/rpc";
|
|
import {
|
|
defaultGatewayHostname,
|
|
forwardAuthFailure,
|
|
forwardAuthHeaders,
|
|
gatewayBrowserRpcHeaders,
|
|
gatewayProxyHeaders,
|
|
gatewayWebSocketBackendHeaders,
|
|
stripUntrustedInternalHeaders,
|
|
gatewayRestartDelay,
|
|
internalError,
|
|
isRpcGatewayPath,
|
|
stripInternalError,
|
|
} from "../src/gateway.ts";
|
|
import { resolveProductionHostname } from "../src/prod.ts";
|
|
|
|
test("gateway-managed production apps bind to loopback", () => {
|
|
expect(resolveProductionHostname(undefined, "127.0.0.1")).toBe("127.0.0.1");
|
|
expect(resolveProductionHostname("0.0.0.0", "127.0.0.1")).toBe("127.0.0.1");
|
|
expect(resolveProductionHostname("10.0.0.5", "")).toBe("10.0.0.5");
|
|
expect(resolveProductionHostname(undefined, "")).toBe("0.0.0.0");
|
|
});
|
|
|
|
test("gateway uses platform-safe hostname defaults", () => {
|
|
expect(defaultGatewayHostname("development")).toBe("127.0.0.1");
|
|
expect(defaultGatewayHostname("production")).toBe("0.0.0.0");
|
|
});
|
|
|
|
test("gateway disables compression for its internal proxy hop", () => {
|
|
const request = new Request("http://localhost:3000/path", {
|
|
headers: { host: "web.localhost:3000", "accept-encoding": "gzip, deflate" },
|
|
});
|
|
const headers = gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true);
|
|
|
|
expect(headers.get("accept-encoding")).toBe("identity");
|
|
expect(headers.get("x-forwarded-host")).toBe("web.localhost:3000");
|
|
expect(headers.get("x-forwarded-proto")).toBe("http");
|
|
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
|
|
});
|
|
|
|
test("gateway WebSocket bridge forwards validated application identity", () => {
|
|
const request = new Request("http://web.localhost:3000/__wrnexus/hmr", {
|
|
headers: {
|
|
host: "web.localhost:3000",
|
|
origin: "http://web.localhost:3000",
|
|
cookie: "session=abc",
|
|
connection: "Upgrade",
|
|
upgrade: "websocket",
|
|
"sec-websocket-key": "test-key",
|
|
},
|
|
});
|
|
const headers = gatewayWebSocketBackendHeaders(
|
|
request,
|
|
new URL(request.url),
|
|
"127.0.0.1",
|
|
true,
|
|
"http://127.0.0.1:3001",
|
|
);
|
|
|
|
expect(headers.origin).toBe("http://127.0.0.1:3001");
|
|
expect(headers.cookie).toBe("session=abc");
|
|
expect(headers["x-forwarded-host"]).toBe("web.localhost:3000");
|
|
expect(headers["x-forwarded-proto"]).toBe("http");
|
|
expect(headers.host).toBeUndefined();
|
|
expect(headers.connection).toBeUndefined();
|
|
expect(headers.upgrade).toBeUndefined();
|
|
expect(headers["sec-websocket-key"]).toBeUndefined();
|
|
});
|
|
|
|
test("gateway proxy headers do not preserve the RPC internal marker", () => {
|
|
const request = new Request("http://localhost:3000/path", {
|
|
headers: { "x-wrnexus-internal": "1" },
|
|
});
|
|
const headers = stripUntrustedInternalHeaders(
|
|
gatewayProxyHeaders(request, new URL(request.url), "127.0.0.1", true),
|
|
);
|
|
expect(headers.has("x-wrnexus-internal")).toBe(false);
|
|
});
|
|
|
|
test("forward auth preserves intentional verifier redirects", () => {
|
|
const redirected = forwardAuthFailure(
|
|
new Response(null, { status: 302, headers: { location: "/login?returnTo=%2Fadmin" } }),
|
|
"http://sso.localhost:3000/api/verify",
|
|
);
|
|
const denied = forwardAuthFailure(new Response(null, { status: 401 }), "http://sso.localhost");
|
|
|
|
expect(redirected.status).toBe(302);
|
|
expect(redirected.headers.get("location")).toBe(
|
|
"http://sso.localhost:3000/login?returnTo=%2Fadmin",
|
|
);
|
|
expect(denied.status).toBe(401);
|
|
expect(denied.headers.has("location")).toBe(false);
|
|
});
|
|
|
|
test("forward auth exposes the verifier public origin instead of its internal app port", () => {
|
|
const redirected = forwardAuthFailure(
|
|
new Response(null, {
|
|
status: 302,
|
|
headers: { location: "/sign-in?returnTo=%2Fadmin" },
|
|
}),
|
|
"http://127.0.0.1:85/api/verify",
|
|
"http://sso.localhost",
|
|
);
|
|
|
|
expect(redirected.headers.get("location")).toBe("http://sso.localhost/sign-in?returnTo=%2Fadmin");
|
|
});
|
|
|
|
test("gateway reads and strips internal app diagnostics", async () => {
|
|
const response = new Response("safe public error", {
|
|
status: 500,
|
|
headers: {
|
|
"x-wrnexus-internal-error": encodeURIComponent('Error: Unknown workspace app "admin"'),
|
|
},
|
|
});
|
|
|
|
expect(internalError(response)).toBe('Error: Unknown workspace app "admin"');
|
|
const stripped = stripInternalError(response);
|
|
expect(stripped.status).toBe(500);
|
|
expect(stripped.headers.has("x-wrnexus-internal-error")).toBe(false);
|
|
expect(await stripped.text()).toBe("safe public error");
|
|
});
|
|
|
|
test("forward auth describes the original gateway request", () => {
|
|
const headers = forwardAuthHeaders(
|
|
new Request("https://admin.example.test/settings?tab=security", {
|
|
headers: {
|
|
host: "admin.example.test",
|
|
cookie: "session=abc",
|
|
authorization: "Bearer token",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(headers.get("x-forwarded-host")).toBe("admin.example.test");
|
|
expect(headers.get("x-forwarded-proto")).toBe("https");
|
|
expect(headers.get("x-original-host")).toBe("admin.example.test");
|
|
expect(headers.get("x-original-proto")).toBe("https");
|
|
expect(headers.get("x-original-method")).toBe("GET");
|
|
expect(headers.get("x-original-uri")).toBe("/settings?tab=security");
|
|
expect(headers.get("cookie")).toBe("session=abc");
|
|
expect(headers.get("authorization")).toBe("Bearer token");
|
|
});
|
|
|
|
test("forward auth uses the protected app public protocol", () => {
|
|
const headers = forwardAuthHeaders(
|
|
new Request("http://127.0.0.1:10050/settings", {
|
|
headers: { host: "admin.wrnx.in" },
|
|
}),
|
|
"https://admin.wrnx.in",
|
|
);
|
|
|
|
expect(headers.get("x-original-host")).toBe("admin.wrnx.in");
|
|
expect(headers.get("x-original-proto")).toBe("https");
|
|
});
|
|
|
|
test("nested SSO proxy keeps the protected app's original request headers", () => {
|
|
const authHeaders = forwardAuthHeaders(
|
|
new Request("http://admin.localhost:3000/settings", {
|
|
headers: { host: "admin.localhost:3000" },
|
|
}),
|
|
);
|
|
authHeaders.set("host", "sso.localhost:3000");
|
|
const verifierRequest = new Request("http://sso.localhost:3000/api/verify", {
|
|
headers: authHeaders,
|
|
});
|
|
|
|
const proxied = gatewayProxyHeaders(
|
|
verifierRequest,
|
|
new URL(verifierRequest.url),
|
|
"127.0.0.1",
|
|
true,
|
|
);
|
|
|
|
expect(proxied.get("x-forwarded-host")).toBe("sso.localhost:3000");
|
|
expect(proxied.get("x-original-host")).toBe("admin.localhost:3000");
|
|
expect(proxied.get("x-original-uri")).toBe("/settings");
|
|
});
|
|
|
|
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,
|
|
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);
|
|
expect(gatewayRestartDelay("development", 0, null)).toBeNull();
|
|
expect(gatewayRestartDelay("development", 97, "SIGTERM")).toBeNull();
|
|
expect(gatewayRestartDelay("production", 97, null)).toBeNull();
|
|
});
|