From 08a7967936cfc0b2af6b27ec1f9272542648ad6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 14:36:39 +0100 Subject: [PATCH] fix(security): fail closed on gateway bind fallback and tighten canvas IP fallback --- src/gateway/server-http.ts | 39 ++++-- src/gateway/server-runtime-config.test.ts | 79 ++++++++++++ src/gateway/server-runtime-config.ts | 23 +++- src/gateway/server.canvas-auth.e2e.test.ts | 138 +++++++++++++++++---- 4 files changed, 247 insertions(+), 32 deletions(-) diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 3c8d1ec3d37..78d16d870e0 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -1,3 +1,5 @@ +import type { TlsOptions } from "node:tls"; +import type { WebSocketServer } from "ws"; import { createServer as createHttpServer, type Server as HttpServer, @@ -5,8 +7,10 @@ import { type ServerResponse, } from "node:http"; import { createServer as createHttpsServer } from "node:https"; -import type { TlsOptions } from "node:tls"; -import type { WebSocketServer } from "ws"; +import type { CanvasHostHandler } from "../canvas-host/server.js"; +import type { createSubsystemLogger } from "../logging/subsystem.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { A2UI_PATH, @@ -14,12 +18,9 @@ import { CANVAS_WS_PATH, handleA2uiHttpRequest, } from "../canvas-host/a2ui.js"; -import type { CanvasHostHandler } from "../canvas-host/server.js"; import { loadConfig } from "../config/config.js"; -import type { createSubsystemLogger } from "../logging/subsystem.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; -import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { authorizeGatewayConnect, isLocalDirectRequest, @@ -50,10 +51,14 @@ import { } from "./hooks.js"; import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; import { getBearerToken, getHeader } from "./http-utils.js"; -import { isPrivateOrLoopbackAddress, resolveGatewayClientIp } from "./net.js"; +import { + isPrivateOrLoopbackAddress, + isTrustedProxyAddress, + resolveGatewayClientIp, +} from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; -import type { GatewayWsClient } from "./server/ws-types.js"; +import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "./protocol/client-info.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; type SubsystemLogger = ReturnType; @@ -97,9 +102,16 @@ function isCanvasPath(pathname: string): boolean { ); } -function hasAuthorizedWsClientForIp(clients: Set, clientIp: string): boolean { +function isNodeWsClient(client: GatewayWsClient): boolean { + if (client.connect.role === "node") { + return true; + } + return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE; +} + +function hasAuthorizedNodeWsClientForIp(clients: Set, clientIp: string): boolean { for (const client of clients) { - if (client.clientIp && client.clientIp === clientIp) { + if (client.clientIp && client.clientIp === clientIp && isNodeWsClient(client)) { return true; } } @@ -118,6 +130,9 @@ async function authorizeCanvasRequest(params: { return { ok: true }; } + const hasProxyHeaders = Boolean(getHeader(req, "x-forwarded-for") || getHeader(req, "x-real-ip")); + const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); + let lastAuthFailure: GatewayAuthResult | null = null; const token = getBearerToken(req); if (token) { @@ -150,7 +165,11 @@ async function authorizeCanvasRequest(params: { if (!isPrivateOrLoopbackAddress(clientIp)) { return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; } - if (hasAuthorizedWsClientForIp(clients, clientIp)) { + // Ignore IP fallback when proxy headers come from an untrusted source. + if (hasProxyHeaders && !remoteIsTrustedProxy) { + return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; + } + if (hasAuthorizedNodeWsClientForIp(clients, clientIp)) { return { ok: true }; } return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 119c9cad9a7..360ad6f9ec0 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -152,5 +152,84 @@ describe("resolveGatewayRuntimeConfig", () => { }), ).rejects.toThrow("refusing to bind gateway"); }); + + it("should reject loopback mode if host resolves to non-loopback", async () => { + const cfg = { + gateway: { + bind: "loopback" as const, + auth: { + mode: "none" as const, + }, + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + host: "0.0.0.0", + }), + ).rejects.toThrow("gateway bind=loopback resolved to non-loopback host"); + }); + + it("should reject custom bind without customBindHost", async () => { + const cfg = { + gateway: { + bind: "custom" as const, + auth: { + mode: "token" as const, + token: "test-token-123", + }, + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow("gateway.bind=custom requires gateway.customBindHost"); + }); + + it("should reject custom bind with invalid customBindHost", async () => { + const cfg = { + gateway: { + bind: "custom" as const, + customBindHost: "192.168.001.100", + auth: { + mode: "token" as const, + token: "test-token-123", + }, + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow("gateway.bind=custom requires a valid IPv4 customBindHost"); + }); + + it("should reject custom bind if resolved host differs from configured host", async () => { + const cfg = { + gateway: { + bind: "custom" as const, + customBindHost: "192.168.1.100", + auth: { + mode: "token" as const, + token: "test-token-123", + }, + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + host: "0.0.0.0", + }), + ).rejects.toThrow("gateway bind=custom requested 192.168.1.100 but resolved 0.0.0.0"); + }); }); }); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 614b8c0b542..896faf4dada 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -11,7 +11,7 @@ import { } from "./auth.js"; import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; -import { isLoopbackHost, resolveGatewayBindHost } from "./net.js"; +import { isLoopbackHost, isValidIPv4, resolveGatewayBindHost } from "./net.js"; import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { @@ -44,6 +44,27 @@ export async function resolveGatewayRuntimeConfig(params: { const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback"; const customBindHost = params.cfg.gateway?.customBindHost; const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost)); + if (bindMode === "loopback" && !isLoopbackHost(bindHost)) { + throw new Error( + `gateway bind=loopback resolved to non-loopback host ${bindHost}; refusing fallback to a network bind`, + ); + } + if (bindMode === "custom") { + const configuredCustomBindHost = customBindHost?.trim(); + if (!configuredCustomBindHost) { + throw new Error("gateway.bind=custom requires gateway.customBindHost"); + } + if (!isValidIPv4(configuredCustomBindHost)) { + throw new Error( + `gateway.bind=custom requires a valid IPv4 customBindHost (got ${configuredCustomBindHost})`, + ); + } + if (bindHost !== configuredCustomBindHost) { + throw new Error( + `gateway bind=custom requested ${configuredCustomBindHost} but resolved ${bindHost}; refusing fallback`, + ); + } + } const controlUiEnabled = params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true; const openAiChatCompletionsEnabled = diff --git a/src/gateway/server.canvas-auth.e2e.test.ts b/src/gateway/server.canvas-auth.e2e.test.ts index b5e7a5946c8..a48f905311d 100644 --- a/src/gateway/server.canvas-auth.e2e.test.ts +++ b/src/gateway/server.canvas-auth.e2e.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from "vitest"; import { WebSocket, WebSocketServer } from "ws"; -import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; -import { createAuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; import type { GatewayWsClient } from "./server/ws-types.js"; +import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui.js"; +import { createAuthRateLimiter } from "./auth-rate-limit.js"; +import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; import { withTempConfig } from "./test-temp-config.js"; async function listen(server: ReturnType): Promise<{ @@ -50,6 +50,25 @@ async function expectWsRejected( }); } +function makeWsClient(params: { + connId: string; + clientIp: string; + role: "node" | "operator"; + mode: "node" | "backend"; +}): GatewayWsClient { + return { + socket: {} as unknown as WebSocket, + connect: { + role: params.role, + client: { + mode: params.mode, + }, + } as GatewayWsClient["connect"], + connId: params.connId, + clientIp: params.clientIp, + }; +} + async function withCanvasGatewayHarness(params: { resolvedAuth: ResolvedGatewayAuth; rateLimiter?: ReturnType; @@ -164,12 +183,31 @@ describe("gateway canvas host auth", () => { "x-forwarded-for": privateIpA, }); - clients.add({ - socket: {} as unknown as WebSocket, - connect: {} as never, - connId: "c1", - clientIp: privateIpA, - }); + clients.add( + makeWsClient({ + connId: "c-operator", + clientIp: privateIpA, + role: "operator", + mode: "backend", + }), + ); + + const operatorCanvasStillBlocked = await fetch( + `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, + { + headers: { "x-forwarded-for": privateIpA }, + }, + ); + expect(operatorCanvasStillBlocked.status).toBe(401); + + clients.add( + makeWsClient({ + connId: "c-node", + clientIp: privateIpA, + role: "node", + mode: "node", + }), + ); const authCanvas = await fetch( `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, @@ -188,12 +226,14 @@ describe("gateway canvas host auth", () => { ); expect(otherIpStillBlocked.status).toBe(401); - clients.add({ - socket: {} as unknown as WebSocket, - connect: {} as never, - connId: "c-public", - clientIp: publicIp, - }); + clients.add( + makeWsClient({ + connId: "c-public", + clientIp: publicIp, + role: "node", + mode: "node", + }), + ); const publicIpStillBlocked = await fetch( `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { @@ -205,12 +245,14 @@ describe("gateway canvas host auth", () => { "x-forwarded-for": publicIp, }); - clients.add({ - socket: {} as unknown as WebSocket, - connect: {} as never, - connId: "c-cgnat", - clientIp: cgnatIp, - }); + clients.add( + makeWsClient({ + connId: "c-cgnat", + clientIp: cgnatIp, + role: "node", + mode: "node", + }), + ); const cgnatAllowed = await fetch( `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { @@ -241,6 +283,60 @@ describe("gateway canvas host auth", () => { }); }, 60_000); + test("denies canvas IP fallback when proxy headers come from untrusted source", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { + gateway: { + trustedProxies: [], + }, + }, + run: async () => { + await withCanvasGatewayHarness({ + resolvedAuth, + handleHttpRequest: async (req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + if ( + url.pathname !== CANVAS_HOST_PATH && + !url.pathname.startsWith(`${CANVAS_HOST_PATH}/`) + ) { + return false; + } + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("ok"); + return true; + }, + run: async ({ listener, clients }) => { + clients.add( + makeWsClient({ + connId: "c-loopback-node", + clientIp: "127.0.0.1", + role: "node", + mode: "node", + }), + ); + + const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { + headers: { "x-forwarded-for": "192.168.1.10" }, + }); + expect(res.status).toBe(401); + + await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, { + "x-forwarded-for": "192.168.1.10", + }); + }, + }); + }, + }); + }, 60_000); + test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => { const resolvedAuth: ResolvedGatewayAuth = { mode: "token",