From be7f825006d2c3a2c2840e7647aa55cabc6516ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 13:32:25 +0100 Subject: [PATCH] refactor(gateway): harden proxy client ip resolution --- docs/gateway/configuration-reference.md | 3 + docs/gateway/security/index.md | 20 ++++- src/config/types.gateway.ts | 9 +- src/config/zod-schema.ts | 1 + src/gateway/auth.test.ts | 39 ++++++++ src/gateway/auth.ts | 36 ++++++-- src/gateway/http-auth-helpers.ts | 2 + src/gateway/http-endpoint-helpers.ts | 2 + src/gateway/net.test.ts | 89 ++++++++++++------- src/gateway/net.ts | 87 ++++++++++-------- src/gateway/openai-http.ts | 2 + src/gateway/openresponses-http.ts | 2 + src/gateway/server-http.ts | 24 ++++- .../server/ws-connection/message-handler.ts | 15 +++- src/gateway/tools-invoke-http.ts | 2 + 15 files changed, 246 insertions(+), 87 deletions(-) diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index e3a46d67795..b3bc9b77b61 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2043,6 +2043,8 @@ See [Plugins](/tools/plugin). // password: "your-password", }, trustedProxies: ["10.0.0.1"], + // Optional. Default false. + allowRealIpFallback: false, tools: { // Additional /tools/invoke HTTP denies deny: ["browser"], @@ -2068,6 +2070,7 @@ See [Plugins](/tools/plugin). - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth. - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. +- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior. - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list). - `gateway.tools.allow`: remove tool names from the default HTTP deny list. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index d30f5f8c708..188573ba650 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -168,18 +168,34 @@ keys so you can review them in one place (for example If you run the Gateway behind a reverse proxy (nginx, Caddy, Traefik, etc.), you should configure `gateway.trustedProxies` for proper client IP detection. -When the Gateway detects proxy headers (`X-Forwarded-For` or `X-Real-IP`) from an address that is **not** in `trustedProxies`, it will **not** treat connections as local clients. If gateway auth is disabled, those connections are rejected. This prevents authentication bypass where proxied connections would otherwise appear to come from localhost and receive automatic trust. +When the Gateway detects proxy headers from an address that is **not** in `trustedProxies`, it will **not** treat connections as local clients. If gateway auth is disabled, those connections are rejected. This prevents authentication bypass where proxied connections would otherwise appear to come from localhost and receive automatic trust. ```yaml gateway: trustedProxies: - "127.0.0.1" # if your proxy runs on localhost + # Optional. Default false. + # Only enable if your proxy cannot provide X-Forwarded-For. + allowRealIpFallback: false auth: mode: password password: ${OPENCLAW_GATEWAY_PASSWORD} ``` -When `trustedProxies` is configured, the Gateway will use `X-Forwarded-For` headers to determine the real client IP for local client detection. Make sure your proxy overwrites (not appends to) incoming `X-Forwarded-For` headers to prevent spoofing. +When `trustedProxies` is configured, the Gateway uses `X-Forwarded-For` to determine the client IP. `X-Real-IP` is ignored by default unless `gateway.allowRealIpFallback: true` is explicitly set. + +Good reverse proxy behavior (overwrite incoming forwarding headers): + +```nginx +proxy_set_header X-Forwarded-For $remote_addr; +proxy_set_header X-Real-IP $remote_addr; +``` + +Bad reverse proxy behavior (append/preserve untrusted forwarding headers): + +```nginx +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +``` ## Local session logs live on disk diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 1828063149e..13a36c7f4f7 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -310,10 +310,15 @@ export type GatewayConfig = { 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. + * arrives from one of these IPs, the Gateway trusts `x-forwarded-for` + * to determine the client IP for local pairing and HTTP checks. */ trustedProxies?: string[]; + /** + * Allow `x-real-ip` as a fallback only when `x-forwarded-for` is missing. + * Default: false (safer fail-closed behavior). + */ + allowRealIpFallback?: boolean; /** Tool access restrictions for HTTP /tools/invoke endpoint. */ tools?: GatewayToolsConfig; /** diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ce6f683122b..42c9207a9df 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -450,6 +450,7 @@ export const OpenClawSchema = z .strict() .optional(), trustedProxies: z.array(z.string()).optional(), + allowRealIpFallback: z.boolean().optional(), tools: z .object({ deny: z.array(z.string()).optional(), diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 1d96886fc17..bd075ddfd76 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -301,6 +301,45 @@ describe("gateway auth", () => { expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.10", "shared-secret"); }); + it("ignores X-Real-IP fallback by default for rate-limit checks", async () => { + const limiter = createLimiterSpy(); + const res = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: { token: "wrong" }, + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { "x-real-ip": "203.0.113.77" }, + } as never, + trustedProxies: ["127.0.0.1"], + rateLimiter: limiter, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_mismatch"); + expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret"); + expect(limiter.recordFailure).toHaveBeenCalledWith("127.0.0.1", "shared-secret"); + }); + + it("uses X-Real-IP when fallback is explicitly enabled", async () => { + const limiter = createLimiterSpy(); + const res = await authorizeGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: { token: "wrong" }, + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { "x-real-ip": "203.0.113.77" }, + } as never, + trustedProxies: ["127.0.0.1"], + allowRealIpFallback: true, + rateLimiter: limiter, + }); + + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_mismatch"); + expect(limiter.check).toHaveBeenCalledWith("203.0.113.77", "shared-secret"); + expect(limiter.recordFailure).toHaveBeenCalledWith("203.0.113.77", "shared-secret"); + }); + it("passes custom rate-limit scope to limiter operations", async () => { const limiter = createLimiterSpy(); const res = await authorizeGatewayConnect({ diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 14c05a81716..2c6492164c5 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -15,8 +15,7 @@ import { isLoopbackAddress, isTrustedProxyAddress, resolveHostName, - parseForwardedForClientIp, - resolveGatewayClientIp, + resolveClientIp, } from "./net.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; @@ -71,6 +70,8 @@ export type AuthorizeGatewayConnectParams = { clientIp?: string; /** Optional limiter scope; defaults to shared-secret auth scope. */ rateLimitScope?: string; + /** Trust X-Real-IP only when explicitly enabled. */ + allowRealIpFallback?: boolean; }; type TailscaleUser = { @@ -89,34 +90,45 @@ function headerValue(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } +const TAILSCALE_TRUSTED_PROXIES = ["127.0.0.1", "::1"] as const; + function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { if (!req) { return undefined; } - const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]); - return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined; + return resolveClientIp({ + remoteAddr: req.socket?.remoteAddress ?? "", + forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), + trustedProxies: [...TAILSCALE_TRUSTED_PROXIES], + }); } function resolveRequestClientIp( req?: IncomingMessage, trustedProxies?: string[], + allowRealIpFallback = false, ): string | undefined { if (!req) { return undefined; } - return resolveGatewayClientIp({ + return resolveClientIp({ remoteAddr: req.socket?.remoteAddress ?? "", forwardedFor: headerValue(req.headers?.["x-forwarded-for"]), realIp: headerValue(req.headers?.["x-real-ip"]), trustedProxies, + allowRealIpFallback, }); } -export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean { +export function isLocalDirectRequest( + req?: IncomingMessage, + trustedProxies?: string[], + allowRealIpFallback = false, +): boolean { if (!req) { return false; } - const clientIp = resolveRequestClientIp(req, trustedProxies) ?? ""; + const clientIp = resolveRequestClientIp(req, trustedProxies, allowRealIpFallback) ?? ""; if (!isLoopbackAddress(clientIp)) { return false; } @@ -351,7 +363,11 @@ export async function authorizeGatewayConnect( const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const authSurface = params.authSurface ?? "http"; const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface); - const localDirect = isLocalDirectRequest(req, trustedProxies); + const localDirect = isLocalDirectRequest( + req, + trustedProxies, + params.allowRealIpFallback === true, + ); if (auth.mode === "trusted-proxy") { if (!auth.trustedProxy) { @@ -379,7 +395,9 @@ export async function authorizeGatewayConnect( const limiter = params.rateLimiter; const ip = - params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress; + params.clientIp ?? + resolveRequestClientIp(req, trustedProxies, params.allowRealIpFallback === true) ?? + req?.socket?.remoteAddress; const rateLimitScope = params.rateLimitScope ?? AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET; if (limiter) { const rlCheck: RateLimitCheckResult = limiter.check(ip, rateLimitScope); diff --git a/src/gateway/http-auth-helpers.ts b/src/gateway/http-auth-helpers.ts index 36edb7c8d97..fac708c7f79 100644 --- a/src/gateway/http-auth-helpers.ts +++ b/src/gateway/http-auth-helpers.ts @@ -9,6 +9,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: { res: ServerResponse; auth: ResolvedGatewayAuth; trustedProxies?: string[]; + allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; }): Promise { const token = getBearerToken(params.req); @@ -17,6 +18,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: { connectAuth: token ? { token, password: token } : null, req: params.req, trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, rateLimiter: params.rateLimiter, }); if (!authResult.ok) { diff --git a/src/gateway/http-endpoint-helpers.ts b/src/gateway/http-endpoint-helpers.ts index b048641148f..2ea005956f4 100644 --- a/src/gateway/http-endpoint-helpers.ts +++ b/src/gateway/http-endpoint-helpers.ts @@ -12,6 +12,7 @@ export async function handleGatewayPostJsonEndpoint( auth: ResolvedGatewayAuth; maxBodyBytes: number; trustedProxies?: string[]; + allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; }, ): Promise { @@ -30,6 +31,7 @@ export async function handleGatewayPostJsonEndpoint( res, auth: opts.auth, trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); if (!authorized) { diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 3ac898fa568..8e1c1c70bcd 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -5,7 +5,7 @@ import { isSecureWebSocketUrl, isTrustedProxyAddress, pickPrimaryLanIPv4, - resolveGatewayClientIp, + resolveClientIp, resolveGatewayListenHosts, resolveHostName, } from "./net.js"; @@ -132,49 +132,74 @@ describe("isTrustedProxyAddress", () => { }); }); -describe("resolveGatewayClientIp", () => { - it("returns remote IP when the remote is not a trusted proxy", () => { - const ip = resolveGatewayClientIp({ +describe("resolveClientIp", () => { + it.each([ + { + name: "returns remote IP when remote is not trusted proxy", remoteAddr: "203.0.113.10", forwardedFor: "10.0.0.2", trustedProxies: ["127.0.0.1"], - }); - expect(ip).toBe("203.0.113.10"); - }); - - it("returns forwarded client IP when the remote is a trusted proxy", () => { - const ip = resolveGatewayClientIp({ - remoteAddr: "127.0.0.1", - forwardedFor: "127.0.0.1, 10.0.0.2", - trustedProxies: ["127.0.0.1"], - }); - expect(ip).toBe("10.0.0.2"); - }); - - it("does not trust the left-most X-Forwarded-For value when behind a trusted proxy", () => { - const ip = resolveGatewayClientIp({ + expected: "203.0.113.10", + }, + { + name: "uses right-most untrusted X-Forwarded-For hop", remoteAddr: "127.0.0.1", forwardedFor: "198.51.100.99, 10.0.0.9, 127.0.0.1", trustedProxies: ["127.0.0.1"], - }); - expect(ip).toBe("10.0.0.9"); - }); - - it("fails closed when trusted proxy headers are missing", () => { - const ip = resolveGatewayClientIp({ + expected: "10.0.0.9", + }, + { + name: "fails closed when all X-Forwarded-For hops are trusted proxies", + remoteAddr: "127.0.0.1", + forwardedFor: "127.0.0.1, ::1", + trustedProxies: ["127.0.0.1", "::1"], + expected: undefined, + }, + { + name: "fails closed when trusted proxy omits forwarding headers", remoteAddr: "127.0.0.1", trustedProxies: ["127.0.0.1"], - }); - expect(ip).toBeUndefined(); - }); - - it("supports IPv6 client IP forwarded by a trusted proxy", () => { - const ip = resolveGatewayClientIp({ + expected: undefined, + }, + { + name: "ignores invalid X-Forwarded-For entries", + remoteAddr: "127.0.0.1", + forwardedFor: "garbage, 10.0.0.999", + trustedProxies: ["127.0.0.1"], + expected: undefined, + }, + { + name: "does not trust X-Real-IP by default", remoteAddr: "127.0.0.1", realIp: "[2001:db8::5]", trustedProxies: ["127.0.0.1"], + expected: undefined, + }, + { + name: "uses X-Real-IP only when explicitly enabled", + remoteAddr: "127.0.0.1", + realIp: "[2001:db8::5]", + trustedProxies: ["127.0.0.1"], + allowRealIpFallback: true, + expected: "2001:db8::5", + }, + { + name: "ignores invalid X-Real-IP even when fallback enabled", + remoteAddr: "127.0.0.1", + realIp: "not-an-ip", + trustedProxies: ["127.0.0.1"], + allowRealIpFallback: true, + expected: undefined, + }, + ])("$name", (testCase) => { + const ip = resolveClientIp({ + remoteAddr: testCase.remoteAddr, + forwardedFor: testCase.forwardedFor, + realIp: testCase.realIp, + trustedProxies: testCase.trustedProxies, + allowRealIpFallback: testCase.allowRealIpFallback, }); - expect(ip).toBe("2001:db8::5"); + expect(ip).toBe(testCase.expected); }); }); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index ab43477963a..8094c67cc7e 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -146,45 +146,51 @@ function stripOptionalPort(ip: string): string { return ip; } -export function parseForwardedForClientIp( - forwardedFor?: string, - trustedProxies?: string[], -): string | undefined { - const entries = forwardedFor - ?.split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); - if (!entries?.length) { +function parseIpLiteral(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { return undefined; } - - if (!trustedProxies?.length) { - const raw = entries.at(-1); - if (!raw) { - return undefined; - } - return normalizeIp(stripOptionalPort(raw)); + const stripped = stripOptionalPort(trimmed); + const normalized = normalizeIp(stripped); + if (!normalized || net.isIP(normalized) === 0) { + return undefined; } - - for (let index = entries.length - 1; index >= 0; index -= 1) { - const normalized = normalizeIp(stripOptionalPort(entries[index])); - if (!normalized) { - continue; - } - if (!isTrustedProxyAddress(normalized, trustedProxies)) { - return normalized; - } - } - - return undefined; + return normalized; } function parseRealIp(realIp?: string): string | undefined { - const raw = realIp?.trim(); - if (!raw) { + return parseIpLiteral(realIp); +} + +function resolveForwardedClientIp(params: { + forwardedFor?: string; + trustedProxies?: string[]; +}): string | undefined { + const { forwardedFor, trustedProxies } = params; + if (!trustedProxies?.length) { return undefined; } - return normalizeIp(stripOptionalPort(raw)); + + const forwardedChain: string[] = []; + for (const entry of forwardedFor?.split(",") ?? []) { + const normalized = parseIpLiteral(entry); + if (normalized) { + forwardedChain.push(normalized); + } + } + if (forwardedChain.length === 0) { + return undefined; + } + + // Walk right-to-left and return the first untrusted hop. + for (let index = forwardedChain.length - 1; index >= 0; index -= 1) { + const hop = forwardedChain[index]; + if (!isTrustedProxyAddress(hop, trustedProxies)) { + return hop; + } + } + return undefined; } /** @@ -252,11 +258,13 @@ export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: s }); } -export function resolveGatewayClientIp(params: { +export function resolveClientIp(params: { remoteAddr?: string; forwardedFor?: string; realIp?: string; trustedProxies?: string[]; + /** Default false: only trust X-Real-IP when explicitly enabled. */ + allowRealIpFallback?: boolean; }): string | undefined { const remote = normalizeIp(params.remoteAddr); if (!remote) { @@ -268,10 +276,17 @@ export function resolveGatewayClientIp(params: { // Fail closed when traffic comes from a trusted proxy but client-origin headers // are missing or invalid. Falling back to the proxy's own IP can accidentally // treat unrelated requests as local/trusted. - return ( - parseForwardedForClientIp(params.forwardedFor, params.trustedProxies) ?? - parseRealIp(params.realIp) - ); + const forwardedIp = resolveForwardedClientIp({ + forwardedFor: params.forwardedFor, + trustedProxies: params.trustedProxies, + }); + if (forwardedIp) { + return forwardedIp; + } + if (params.allowRealIpFallback) { + return parseRealIp(params.realIp); + } + return undefined; } export function isLocalGatewayAddress(ip: string | undefined): boolean { diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index d9e98a0524c..354d389f73a 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -20,6 +20,7 @@ type OpenAiHttpOptions = { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[]; + allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; }; @@ -162,6 +163,7 @@ export async function handleOpenAiHttpRequest( pathname: "/v1/chat/completions", auth: opts.auth, trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter, maxBodyBytes: opts.maxBodyBytes ?? 1024 * 1024, }); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 3fe440d4c35..791fdb5e68f 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -55,6 +55,7 @@ type OpenResponsesHttpOptions = { maxBodyBytes?: number; config?: GatewayHttpResponsesConfig; trustedProxies?: string[]; + allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; }; @@ -343,6 +344,7 @@ export async function handleOpenResponsesHttpRequest( pathname: "/v1/responses", auth: opts.auth, trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter, maxBodyBytes, }); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index c1c29b3558d..1bf12bbf6b9 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -133,17 +133,26 @@ async function authorizeCanvasRequest(params: { req: IncomingMessage; auth: ResolvedGatewayAuth; trustedProxies: string[]; + allowRealIpFallback: boolean; clients: Set; canvasCapability?: string; malformedScopedPath?: boolean; rateLimiter?: AuthRateLimiter; }): Promise { - const { req, auth, trustedProxies, clients, canvasCapability, malformedScopedPath, rateLimiter } = - params; + const { + req, + auth, + trustedProxies, + allowRealIpFallback, + clients, + canvasCapability, + malformedScopedPath, + rateLimiter, + } = params; if (malformedScopedPath) { return { ok: false, reason: "unauthorized" }; } - if (isLocalDirectRequest(req, trustedProxies)) { + if (isLocalDirectRequest(req, trustedProxies, allowRealIpFallback)) { return { ok: true }; } @@ -155,6 +164,7 @@ async function authorizeCanvasRequest(params: { connectAuth: { token, password: token }, req, trustedProxies, + allowRealIpFallback, rateLimiter, }); if (authResult.ok) { @@ -497,6 +507,7 @@ export function createGatewayHttpServer(opts: { try { const configSnapshot = loadConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; + const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true; const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/"); if (scopedCanvas.malformedScopedPath) { sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" }); @@ -513,6 +524,7 @@ export function createGatewayHttpServer(opts: { await handleToolsInvokeHttpRequest(req, res, { auth: resolvedAuth, trustedProxies, + allowRealIpFallback, rateLimiter, }) ) { @@ -532,6 +544,7 @@ export function createGatewayHttpServer(opts: { connectAuth: token ? { token, password: token } : null, req, trustedProxies, + allowRealIpFallback, rateLimiter, }); if (!authResult.ok) { @@ -549,6 +562,7 @@ export function createGatewayHttpServer(opts: { auth: resolvedAuth, config: openResponsesConfig, trustedProxies, + allowRealIpFallback, rateLimiter, }) ) { @@ -560,6 +574,7 @@ export function createGatewayHttpServer(opts: { await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth, trustedProxies, + allowRealIpFallback, rateLimiter, }) ) { @@ -572,6 +587,7 @@ export function createGatewayHttpServer(opts: { req, auth: resolvedAuth, trustedProxies, + allowRealIpFallback, clients, canvasCapability: scopedCanvas.capability, malformedScopedPath: scopedCanvas.malformedScopedPath, @@ -648,10 +664,12 @@ export function attachGatewayUpgradeHandler(opts: { if (url.pathname === CANVAS_WS_PATH) { const configSnapshot = loadConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; + const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true; const ok = await authorizeCanvasRequest({ req, auth: resolvedAuth, trustedProxies, + allowRealIpFallback, clients, canvasCapability: scopedCanvas.capability, malformedScopedPath: scopedCanvas.malformedScopedPath, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 1be8b5778c7..0c94d5b05d7 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -41,7 +41,7 @@ import { mintCanvasCapabilityToken, } from "../../canvas-capability.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; +import { isLoopbackAddress, isTrustedProxyAddress, resolveClientIp } from "../../net.js"; import { resolveHostName } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { checkBrowserOrigin } from "../../origin-check.js"; @@ -176,7 +176,14 @@ export function attachGatewayWsMessageHandler(params: { const configSnapshot = loadConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; - const clientIp = resolveGatewayClientIp({ remoteAddr, forwardedFor, realIp, trustedProxies }); + const allowRealIpFallback = configSnapshot.gateway?.allowRealIpFallback === true; + const clientIp = resolveClientIp({ + remoteAddr, + forwardedFor, + realIp, + trustedProxies, + allowRealIpFallback, + }); // If proxy headers are present but the remote address isn't trusted, don't treat // the connection as local. This prevents auth bypass when running behind a reverse @@ -189,7 +196,7 @@ export function attachGatewayWsMessageHandler(params: { const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1"; const hostIsTailscaleServe = hostName.endsWith(".ts.net"); const hostIsLocalish = hostIsLocal || hostIsTailscaleServe; - const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies); + const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies, allowRealIpFallback); const reportedClientIp = isLocalClient || hasUntrustedProxyHeaders ? undefined @@ -389,6 +396,7 @@ export function attachGatewayWsMessageHandler(params: { connectAuth: connectParams.auth, req: upgradeReq, trustedProxies, + allowRealIpFallback, rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter, clientIp, rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, @@ -424,6 +432,7 @@ export function attachGatewayWsMessageHandler(params: { connectAuth: connectParams.auth, req: upgradeReq, trustedProxies, + allowRealIpFallback, // Shared-auth probe only; rate-limit side effects are handled in // the primary auth flow (or deferred for device-token candidates). rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 84f14457f9b..5e5c6db2c34 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -131,6 +131,7 @@ export async function handleToolsInvokeHttpRequest( auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[]; + allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; }, ): Promise { @@ -151,6 +152,7 @@ export async function handleToolsInvokeHttpRequest( connectAuth: token ? { token, password: token } : null, req, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); if (!authResult.ok) {