From dc2c3a4920a276e01d3ebf84011ec36754059913 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 14:55:51 +0100 Subject: [PATCH] fix(gateway): harden WS pairing locality --- CHANGELOG.md | 1 + src/gateway/auth.test.ts | 28 +++++++++++++++++++ src/gateway/auth.ts | 25 ++++++++++------- .../handshake-auth-helpers.test.ts | 22 +++++++++++++++ .../server/ws-connection/message-handler.ts | 4 +-- 5 files changed, 68 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8825ef1e61d..6564330d5a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/pairing: treat any forwarded-header evidence (`Forwarded`, `X-Forwarded-*`, or `X-Real-IP`) as proxied WebSocket traffic before pairing locality checks, so reverse-proxy topologies cannot use the loopback shared-secret helper auto-pairing path. - Gateway/pairing webchat: render `/pair qr` replies as structured media instead of raw markdown text, preserve inline reply threading and silent-control handling on media replies, avoid persisting sensitive QR images into transcript history, and keep local webchat media embedding behind internal-only trust markers. (#70047) Thanks @BunsDev. - Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox. - Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so `codex/gpt-5.x` sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs. diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 0d08abacdf2..290f42bc5dc 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -4,6 +4,8 @@ import { assertGatewayAuthConfigured, authorizeGatewayConnect, authorizeHttpGatewayConnect, + hasForwardedRequestHeaders, + isLocalDirectRequest, resolveEffectiveSharedGatewayAuth, authorizeWsControlUiGatewayConnect, resolveGatewayAuth, @@ -137,6 +139,32 @@ describe("gateway auth", () => { }); }); + it.each([ + { name: "Forwarded", headers: { forwarded: "for=203.0.113.10;proto=https" } }, + { name: "X-Forwarded-For", headers: { "x-forwarded-for": "203.0.113.10" } }, + { name: "X-Forwarded-Proto", headers: { "x-forwarded-proto": "https" } }, + { name: "X-Forwarded-Host", headers: { "x-forwarded-host": "gateway.example" } }, + { name: "X-Real-IP", headers: { "x-real-ip": "203.0.113.10" } }, + ])("treats $name as forwarded request evidence", ({ headers }) => { + const req = { + socket: { remoteAddress: "127.0.0.1" }, + headers, + } as never; + + expect(hasForwardedRequestHeaders(req)).toBe(true); + expect(isLocalDirectRequest(req)).toBe(false); + }); + + it("keeps clean loopback requests eligible for direct-local handling", () => { + const req = { + socket: { remoteAddress: "127.0.0.1" }, + headers: { host: "127.0.0.1:18789" }, + } as never; + + expect(hasForwardedRequestHeaders(req)).toBe(false); + expect(isLocalDirectRequest(req)).toBe(true); + }); + it("returns null for non-shared gateway auth modes", () => { expect( resolveEffectiveSharedGatewayAuth({ diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 7c4e28d919e..2ba4fbedc49 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -117,6 +117,20 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined { }); } +export function hasForwardedRequestHeaders(req?: IncomingMessage): boolean { + if (!req) { + return false; + } + + return Boolean( + req.headers?.forwarded || + req.headers?.["x-forwarded-for"] || + req.headers?.["x-forwarded-proto"] || + req.headers?.["x-real-ip"] || + req.headers?.["x-forwarded-host"], + ); +} + export function isLocalDirectRequest( req?: IncomingMessage, _trustedProxies?: string[], @@ -125,16 +139,7 @@ export function isLocalDirectRequest( if (!req) { return false; } - - const hasForwarded = Boolean( - req.headers?.forwarded || - req.headers?.["x-forwarded-for"] || - req.headers?.["x-forwarded-proto"] || - req.headers?.["x-real-ip"] || - req.headers?.["x-forwarded-host"], - ); - - if (!hasForwarded) { + if (!hasForwardedRequestHeaders(req)) { return isLoopbackAddress(req.socket?.remoteAddress); } return false; diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index bcb111f5e41..5dde255af58 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -519,6 +519,28 @@ describe("handshake auth helpers", () => { ).toBe("remote"); }); + it("keeps shared-secret loopback clients remote when forwarded headers were present", () => { + const connectParams = { + client: { + id: GATEWAY_CLIENT_IDS.NODE_HOST, + mode: GATEWAY_CLIENT_MODES.NODE, + }, + } as ConnectParams; + + expect( + resolvePairingLocality({ + connectParams, + isLocalClient: false, + requestHost: "127.0.0.1:18789", + remoteAddress: "127.0.0.1", + hasProxyHeaders: true, + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe("remote"); + }); + it("allows silent scope-upgrade for shared_secret_loopback_local", () => { expect( shouldAllowSilentLocalPairing({ diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 07bc5398563..967d07e33f5 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -51,7 +51,7 @@ import { resolveRuntimeServiceVersion } from "../../../version.js"; import type { AuthRateLimiter } from "../../auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "../../auth.js"; import type { GatewayAuthResult } from "../../auth.js"; -import { isLocalDirectRequest } from "../../auth.js"; +import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js"; import { buildCanvasScopedHostUrl, CANVAS_CAPABILITY_TTL_MS, @@ -267,7 +267,7 @@ export function attachGatewayWsMessageHandler(params: { // the connection as local. This prevents auth bypass when running behind a reverse // proxy without proper configuration - the proxy's loopback connection would otherwise // cause all external requests to be treated as trusted local clients. - const hasProxyHeaders = Boolean(forwardedFor || realIp); + const hasProxyHeaders = hasForwardedRequestHeaders(upgradeReq); const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies); const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy; const hostIsLocalish = isLocalishHost(requestHost);