From e6e71457e0c9d0d68de81274ab58859a205a40d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 01:51:31 +0000 Subject: [PATCH] fix: honor trusted proxy client IPs (PR #1654) Thanks @ndbroadbent. Co-authored-by: Nathan Broadbent --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 5 ++ docs/gateway/security.md | 5 ++ src/config/types.gateway.ts | 6 +++ src/config/zod-schema.ts | 1 + src/gateway/auth.test.ts | 15 ++++++ src/gateway/auth.ts | 30 +++++++++-- src/gateway/net.test.ts | 38 +++++++++++++- src/gateway/net.ts | 50 +++++++++++++++++++ src/gateway/openai-http.ts | 2 + src/gateway/openresponses-http.ts | 2 + src/gateway/server-http.ts | 23 +++++++-- src/gateway/server/ws-connection.ts | 2 + .../server/ws-connection/message-handler.ts | 24 ++++++--- src/gateway/tools-invoke-http.ts | 5 +- 15 files changed, 189 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84bd00bd4..6661a5a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.clawd.bot - Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. - Agents: use the active auth profile for auto-compaction recovery. - Models: default missing custom provider fields so minimal configs are accepted. +- Gateway: honor trusted proxy client IPs for local pairing + HTTP checks. (#1654) Thanks @ndbroadbent. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index d4fe5e12f..1d4e95cb0 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2846,6 +2846,11 @@ Related docs: - [Tailscale](/gateway/tailscale) - [Remote access](/gateway/remote) +Trusted proxies: +- `gateway.trustedProxies`: list of reverse proxy IPs that terminate TLS in front of the Gateway. +- When a connection comes from one of these IPs, Clawdbot uses `x-forwarded-for` (or `x-real-ip`) to determine the client IP for local pairing checks and HTTP auth/local checks. +- Only list proxies you fully control, and ensure they **overwrite** incoming `x-forwarded-for`. + Notes: - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). diff --git a/docs/gateway/security.md b/docs/gateway/security.md index d969ce3e6..dd679c29f 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -322,6 +322,11 @@ Tailscale. you terminate TLS or proxy in front of the gateway, disable `gateway.auth.allowTailscale` and use token/password auth instead. +Trusted proxies: +- If you terminate TLS in front of the Gateway, set `gateway.trustedProxies` to your proxy IPs. +- Clawdbot will trust `x-forwarded-for` (or `x-real-ip`) from those IPs to determine the client IP for local pairing checks and HTTP auth/local checks. +- Ensure your proxy **overwrites** `x-forwarded-for` and blocks direct access to the Gateway port. + See [Tailscale](/gateway/tailscale) and [Web overview](/web). ### 0.6.1) Browser control server over Tailscale (recommended) diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 9f5d787c2..61c0d6f06 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -218,4 +218,10 @@ export type GatewayConfig = { tls?: GatewayTlsConfig; http?: GatewayHttpConfig; nodes?: GatewayNodesConfig; + /** + * IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection + * arrives from one of these IPs, the Gateway trusts `x-forwarded-for` (or + * `x-real-ip`) to determine the client IP for local pairing and HTTP checks. + */ + trustedProxies?: string[]; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 1a4f9c5d7..a8ea7e70f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -324,6 +324,7 @@ export const ClawdbotSchema = z }) .strict() .optional(), + trustedProxies: z.array(z.string()).optional(), tailscale: z .object({ mode: z.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]).optional(), diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 2354c7a46..aa4d5e270 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -142,4 +142,19 @@ describe("gateway auth", () => { expect(res.method).toBe("tailscale"); expect(res.user).toBe("peter"); }); + + it("treats trusted proxy loopback clients as direct", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: true }, + connectAuth: null, + trustedProxies: ["10.0.0.2"], + req: { + socket: { remoteAddress: "10.0.0.2" }, + headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" }, + } as never, + }); + + expect(res.ok).toBe(true); + expect(res.method).toBe("none"); + }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index d2d850a24..cb4e868a2 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -1,6 +1,7 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; +import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password"; export type ResolvedGatewayAuth = { @@ -53,9 +54,26 @@ function getHostName(hostHeader?: string): string { return name ?? ""; } -function isLocalDirectRequest(req?: IncomingMessage): boolean { +function headerValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +function resolveRequestClientIp( + req?: IncomingMessage, + trustedProxies?: string[], +): string | undefined { + if (!req) return undefined; + return resolveGatewayClientIp({ + remoteAddr: req.socket?.remoteAddress ?? "", + forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), + realIp: headerValue(req.headers?.["x-real-ip"]), + trustedProxies, + }); +} + +function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean { if (!req) return false; - const clientIp = req.socket?.remoteAddress ?? ""; + const clientIp = resolveRequestClientIp(req, trustedProxies) ?? ""; if (!isLoopbackAddress(clientIp)) return false; const host = getHostName(req.headers?.host); @@ -68,7 +86,8 @@ function isLocalDirectRequest(req?: IncomingMessage): boolean { req.headers?.["x-forwarded-host"], ); - return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded; + const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); + return (hostIsLocal || hostIsTailscaleServe) && (!hasForwarded || remoteIsTrustedProxy); } function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { @@ -135,9 +154,10 @@ export async function authorizeGatewayConnect(params: { auth: ResolvedGatewayAuth; connectAuth?: ConnectAuth | null; req?: IncomingMessage; + trustedProxies?: string[]; }): Promise { - const { auth, connectAuth, req } = params; - const localDirect = isLocalDirectRequest(req); + const { auth, connectAuth, req, trustedProxies } = params; + const localDirect = isLocalDirectRequest(req, trustedProxies); if (auth.allowTailscale && !localDirect) { const tailscaleUser = getTailscaleUser(req); diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 24fdceed9..d580d4bc2 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -8,7 +8,7 @@ vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv6: () => testTailnetIPv6.value, })); -import { isLocalGatewayAddress } from "./net.js"; +import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js"; describe("gateway net", () => { beforeEach(() => { @@ -38,4 +38,40 @@ describe("gateway net", () => { testTailnetIPv6.value = "fd7a:115c:a1e0::123"; expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true); }); + + test("uses forwarded-for when remote is a trusted proxy", () => { + const clientIp = resolveGatewayClientIp({ + remoteAddr: "10.0.0.2", + forwardedFor: "203.0.113.9, 10.0.0.2", + trustedProxies: ["10.0.0.2"], + }); + expect(clientIp).toBe("203.0.113.9"); + }); + + test("ignores forwarded-for from untrusted proxies", () => { + const clientIp = resolveGatewayClientIp({ + remoteAddr: "10.0.0.3", + forwardedFor: "203.0.113.9", + trustedProxies: ["10.0.0.2"], + }); + expect(clientIp).toBe("10.0.0.3"); + }); + + test("normalizes trusted proxy IPs and strips forwarded ports", () => { + const clientIp = resolveGatewayClientIp({ + remoteAddr: "::ffff:10.0.0.2", + forwardedFor: "203.0.113.9:1234", + trustedProxies: ["10.0.0.2"], + }); + expect(clientIp).toBe("203.0.113.9"); + }); + + test("falls back to x-real-ip when forwarded-for is missing", () => { + const clientIp = resolveGatewayClientIp({ + remoteAddr: "10.0.0.2", + realIp: "203.0.113.10", + trustedProxies: ["10.0.0.2"], + }); + expect(clientIp).toBe("203.0.113.10"); + }); }); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 5e04e2c4c..4c7394979 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -16,6 +16,56 @@ function normalizeIPv4MappedAddress(ip: string): string { return ip; } +function normalizeIp(ip: string | undefined): string | undefined { + const trimmed = ip?.trim(); + if (!trimmed) return undefined; + return normalizeIPv4MappedAddress(trimmed.toLowerCase()); +} + +function stripOptionalPort(ip: string): string { + if (ip.startsWith("[")) { + const end = ip.indexOf("]"); + if (end !== -1) return ip.slice(1, end); + } + if (net.isIP(ip)) return ip; + const lastColon = ip.lastIndexOf(":"); + if (lastColon > -1 && ip.includes(".") && ip.indexOf(":") === lastColon) { + const candidate = ip.slice(0, lastColon); + if (net.isIP(candidate) === 4) return candidate; + } + return ip; +} + +function parseForwardedForClientIp(forwardedFor?: string): string | undefined { + const raw = forwardedFor?.split(",")[0]?.trim(); + if (!raw) return undefined; + return normalizeIp(stripOptionalPort(raw)); +} + +function parseRealIp(realIp?: string): string | undefined { + const raw = realIp?.trim(); + if (!raw) return undefined; + return normalizeIp(stripOptionalPort(raw)); +} + +export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean { + const normalized = normalizeIp(ip); + if (!normalized || !trustedProxies || trustedProxies.length === 0) return false; + return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized); +} + +export function resolveGatewayClientIp(params: { + remoteAddr?: string; + forwardedFor?: string; + realIp?: string; + trustedProxies?: string[]; +}): string | undefined { + const remote = normalizeIp(params.remoteAddr); + if (!remote) return undefined; + if (!isTrustedProxyAddress(remote, params.trustedProxies)) return remote; + return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote; +} + export function isLocalGatewayAddress(ip: string | undefined): boolean { if (isLoopbackAddress(ip)) return true; if (!ip) return false; diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 49c87231a..824e5c222 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -20,6 +20,7 @@ import { getBearerToken, resolveAgentIdForRequest, resolveSessionKey } from "./h type OpenAiHttpOptions = { auth: ResolvedGatewayAuth; maxBodyBytes?: number; + trustedProxies?: string[]; }; type OpenAiChatMessage = { @@ -168,6 +169,7 @@ export async function handleOpenAiHttpRequest( auth: opts.auth, connectAuth: { token, password: token }, req, + trustedProxies: opts.trustedProxies, }); if (!authResult.ok) { sendUnauthorized(res); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 110eee7e8..9d2630296 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -60,6 +60,7 @@ type OpenResponsesHttpOptions = { auth: ResolvedGatewayAuth; maxBodyBytes?: number; config?: GatewayHttpResponsesConfig; + trustedProxies?: string[]; }; const DEFAULT_BODY_BYTES = 20 * 1024 * 1024; @@ -331,6 +332,7 @@ export async function handleOpenResponsesHttpRequest( auth: opts.auth, connectAuth: { token, password: token }, req, + trustedProxies: opts.trustedProxies, }); if (!authResult.ok) { sendUnauthorized(res); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 0cfb1e540..f8a2dbb15 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -227,21 +227,36 @@ export function createGatewayHttpServer(opts: { if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return; try { + const configSnapshot = loadConfig(); + const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; if (await handleHooksRequest(req, res)) return; if (await handleSlackHttpRequest(req, res)) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return; - if (await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth })) return; + if ( + await handleToolsInvokeHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + }) + ) + return; if (openResponsesEnabled) { if ( await handleOpenResponsesHttpRequest(req, res, { auth: resolvedAuth, config: openResponsesConfig, + trustedProxies, }) ) return; } if (openAiChatCompletionsEnabled) { - if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return; + if ( + await handleOpenAiHttpRequest(req, res, { + auth: resolvedAuth, + trustedProxies, + }) + ) + return; } if (canvasHost) { if (await handleA2uiHttpRequest(req, res)) return; @@ -251,14 +266,14 @@ export function createGatewayHttpServer(opts: { if ( handleControlUiAvatarRequest(req, res, { basePath: controlUiBasePath, - resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId), + resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId), }) ) return; if ( handleControlUiHttpRequest(req, res, { basePath: controlUiBasePath, - config: loadConfig(), + config: configSnapshot, }) ) return; diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 2d15b1d01..c413b3cec 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -73,6 +73,7 @@ export function attachGatewayWsConnectionHandler(params: { const requestOrigin = headerValue(upgradeReq.headers.origin); const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]); const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]); + const realIp = headerValue(upgradeReq.headers["x-real-ip"]); const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined); const canvasHostOverride = @@ -228,6 +229,7 @@ export function attachGatewayWsConnectionHandler(params: { connId, remoteAddr, forwardedFor, + realIp, requestHost, requestOrigin, requestUserAgent, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 665a74d05..1d03dbcbf 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -26,7 +26,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLocalGatewayAddress } from "../../net.js"; +import { isLocalGatewayAddress, resolveGatewayClientIp } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, @@ -104,6 +104,7 @@ export function attachGatewayWsMessageHandler(params: { connId: string; remoteAddr?: string; forwardedFor?: string; + realIp?: string; requestHost?: string; requestOrigin?: string; requestUserAgent?: string; @@ -133,6 +134,7 @@ export function attachGatewayWsMessageHandler(params: { connId, remoteAddr, forwardedFor, + realIp, requestHost, requestOrigin, requestUserAgent, @@ -157,6 +159,11 @@ export function attachGatewayWsMessageHandler(params: { logWsControl, } = params; + const configSnapshot = loadConfig(); + const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; + const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies }); + const isLocalClient = isLocalGatewayAddress(clientIp); + const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); socket.on("message", async (data) => { @@ -300,7 +307,7 @@ export function attachGatewayWsMessageHandler(params: { if (!device) { const allowInsecureControlUi = - isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true; + isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; const canSkipDevice = isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth; @@ -380,7 +387,7 @@ export function attachGatewayWsMessageHandler(params: { close(1008, "device signature expired"); return; } - const nonceRequired = !isLocalGatewayAddress(remoteAddr); + const nonceRequired = !isLocalClient; const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; if (nonceRequired && !providedNonce) { setHandshakeState("failed"); @@ -495,6 +502,7 @@ export function attachGatewayWsMessageHandler(params: { auth: resolvedAuth, connectAuth: connectParams.auth, req: upgradeReq, + trustedProxies, }); let authOk = authResult.ok; let authMethod = authResult.method ?? "none"; @@ -556,8 +564,8 @@ export function attachGatewayWsMessageHandler(params: { clientMode: connectParams.client.mode, role, scopes, - remoteIp: remoteAddr, - silent: isLocalGatewayAddress(remoteAddr), + remoteIp: clientIp, + silent: isLocalClient, }); const context = buildRequestContext(); if (pairing.request.silent === true) { @@ -640,7 +648,7 @@ export function attachGatewayWsMessageHandler(params: { clientMode: connectParams.client.mode, role, scopes, - remoteIp: remoteAddr, + remoteIp: clientIp, }); } } @@ -689,7 +697,7 @@ export function attachGatewayWsMessageHandler(params: { if (presenceKey) { upsertPresence(presenceKey, { host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(), - ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr, + ip: isLocalClient ? undefined : clientIp, version: connectParams.client.version, platform: connectParams.client.platform, deviceFamily: connectParams.client.deviceFamily, @@ -748,7 +756,7 @@ export function attachGatewayWsMessageHandler(params: { setHandshakeState("connected"); if (role === "node") { const context = buildRequestContext(); - const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr }); + const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: clientIp }); const instanceIdRaw = connectParams.client.instanceId; const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : ""; const nodeIdsForPairing = new Set([nodeSession.nodeId]); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 72c9580ec..80e2f295e 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -70,7 +70,7 @@ function mergeActionIntoArgsIfSupported(params: { export async function handleToolsInvokeHttpRequest( req: IncomingMessage, res: ServerResponse, - opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number }, + opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[] }, ): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); if (url.pathname !== "/tools/invoke") return false; @@ -80,11 +80,13 @@ export async function handleToolsInvokeHttpRequest( return true; } + const cfg = loadConfig(); const token = getBearerToken(req); const authResult = await authorizeGatewayConnect({ auth: opts.auth, connectAuth: token ? { token, password: token } : null, req, + trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, }); if (!authResult.ok) { sendUnauthorized(res); @@ -110,7 +112,6 @@ export async function handleToolsInvokeHttpRequest( : {} ) as Record; - const cfg = loadConfig(); const rawSessionKey = resolveSessionKeyFromBody(body); const sessionKey = !rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;