diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index 83a97644d19..900ef34b6b4 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -131,10 +131,11 @@ async function expectHelloOkServerVersion(port: number, expectedVersion: string) } async function createSignedDevice(params: { - token: string; + token?: string | null; scopes: string[]; clientId: string; clientMode: string; + role?: "operator" | "node"; identityPath?: string; nonce: string; signedAtMs?: number; @@ -149,10 +150,10 @@ async function createSignedDevice(params: { deviceId: identity.deviceId, clientId: params.clientId, clientMode: params.clientMode, - role: "operator", + role: params.role ?? "operator", scopes: params.scopes, signedAtMs, - token: params.token, + token: params.token ?? null, nonce: params.nonce, }); return { @@ -187,6 +188,23 @@ async function approvePendingPairingIfNeeded() { } } +async function configureTrustedProxyControlUiAuth() { + testState.gatewayAuth = { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + }, + }; + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + trustedProxies: ["127.0.0.1"], + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); +} + function isConnectResMessage(id: string) { return (o: unknown) => { if (!o || typeof o !== "object" || Array.isArray(o)) { @@ -776,6 +794,90 @@ describe("gateway server auth/connect", () => { }); }); + test("allows trusted-proxy control ui operator without device identity", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: "operator", + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(true); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message ?? "").toContain("missing scope"); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + ws.close(); + }); + }); + + test("rejects trusted-proxy control ui node role without device identity", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: "node", + device: null, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("control ui requires device identity"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + ); + ws.close(); + }); + }); + + test("requires pairing for trusted-proxy control ui node role with unpaired device", async () => { + await configureTrustedProxyControlUiAuth(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", + }); + const challengeNonce = await readConnectChallengeNonce(ws); + expect(challengeNonce).toBeTruthy(); + const { device } = await createSignedDevice({ + token: null, + role: "node", + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + nonce: String(challengeNonce), + }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + role: "node", + scopes: [], + device, + client: { ...CONTROL_UI_CLIENT }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ); + ws.close(); + }); + }); + test("allows localhost control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, prevToken } = await startServerWithClient("secret", { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 8feb385e8f9..30d288b651d 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -491,6 +491,7 @@ export function attachGatewayWsMessageHandler(params: { } const trustedProxyAuthOk = isControlUi && + role === "operator" && resolvedAuth.mode === "trusted-proxy" && authOk && authMethod === "trusted-proxy"; @@ -629,6 +630,7 @@ export function attachGatewayWsMessageHandler(params: { const trustedProxyAuthOk = isControlUi && + role === "operator" && resolvedAuth.mode === "trusted-proxy" && authOk && authMethod === "trusted-proxy";