Skip to content

Commit dc2c3a4

Browse files
committed
fix(gateway): harden WS pairing locality
1 parent 95e430f commit dc2c3a4

5 files changed

Lines changed: 65 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
1818

1919
### Fixes
2020

21+
- 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.
2122
- 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.
2223
- 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.
2324
- 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.

src/gateway/auth.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
assertGatewayAuthConfigured,
55
authorizeGatewayConnect,
66
authorizeHttpGatewayConnect,
7+
hasForwardedRequestHeaders,
8+
isLocalDirectRequest,
79
resolveEffectiveSharedGatewayAuth,
810
authorizeWsControlUiGatewayConnect,
911
resolveGatewayAuth,
@@ -137,6 +139,32 @@ describe("gateway auth", () => {
137139
});
138140
});
139141

142+
it.each([
143+
{ name: "Forwarded", headers: { forwarded: "for=203.0.113.10;proto=https" } },
144+
{ name: "X-Forwarded-For", headers: { "x-forwarded-for": "203.0.113.10" } },
145+
{ name: "X-Forwarded-Proto", headers: { "x-forwarded-proto": "https" } },
146+
{ name: "X-Forwarded-Host", headers: { "x-forwarded-host": "gateway.example" } },
147+
{ name: "X-Real-IP", headers: { "x-real-ip": "203.0.113.10" } },
148+
])("treats $name as forwarded request evidence", ({ headers }) => {
149+
const req = {
150+
socket: { remoteAddress: "127.0.0.1" },
151+
headers,
152+
} as never;
153+
154+
expect(hasForwardedRequestHeaders(req)).toBe(true);
155+
expect(isLocalDirectRequest(req)).toBe(false);
156+
});
157+
158+
it("keeps clean loopback requests eligible for direct-local handling", () => {
159+
const req = {
160+
socket: { remoteAddress: "127.0.0.1" },
161+
headers: { host: "127.0.0.1:18789" },
162+
} as never;
163+
164+
expect(hasForwardedRequestHeaders(req)).toBe(false);
165+
expect(isLocalDirectRequest(req)).toBe(true);
166+
});
167+
140168
it("returns null for non-shared gateway auth modes", () => {
141169
expect(
142170
resolveEffectiveSharedGatewayAuth({

src/gateway/auth.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,24 +117,29 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
117117
});
118118
}
119119

120-
export function isLocalDirectRequest(
121-
req?: IncomingMessage,
122-
_trustedProxies?: string[],
123-
_allowRealIpFallback = false,
124-
): boolean {
120+
export function hasForwardedRequestHeaders(req?: IncomingMessage): boolean {
125121
if (!req) {
126122
return false;
127123
}
128124

129-
const hasForwarded = Boolean(
125+
return Boolean(
130126
req.headers?.forwarded ||
131127
req.headers?.["x-forwarded-for"] ||
132128
req.headers?.["x-forwarded-proto"] ||
133129
req.headers?.["x-real-ip"] ||
134130
req.headers?.["x-forwarded-host"],
135131
);
132+
}
136133

137-
if (!hasForwarded) {
134+
export function isLocalDirectRequest(
135+
req?: IncomingMessage,
136+
_trustedProxies?: string[],
137+
_allowRealIpFallback = false,
138+
): boolean {
139+
if (!req) {
140+
return false;
141+
}
142+
if (!hasForwardedRequestHeaders(req)) {
138143
return isLoopbackAddress(req.socket?.remoteAddress);
139144
}
140145
return false;

src/gateway/server/ws-connection/handshake-auth-helpers.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,28 @@ describe("handshake auth helpers", () => {
519519
).toBe("remote");
520520
});
521521

522+
it("keeps shared-secret loopback clients remote when forwarded headers were present", () => {
523+
const connectParams = {
524+
client: {
525+
id: GATEWAY_CLIENT_IDS.NODE_HOST,
526+
mode: GATEWAY_CLIENT_MODES.NODE,
527+
},
528+
} as ConnectParams;
529+
530+
expect(
531+
resolvePairingLocality({
532+
connectParams,
533+
isLocalClient: false,
534+
requestHost: "127.0.0.1:18789",
535+
remoteAddress: "127.0.0.1",
536+
hasProxyHeaders: true,
537+
hasBrowserOriginHeader: false,
538+
sharedAuthOk: true,
539+
authMethod: "token",
540+
}),
541+
).toBe("remote");
542+
});
543+
522544
it("allows silent scope-upgrade for shared_secret_loopback_local", () => {
523545
expect(
524546
shouldAllowSilentLocalPairing({

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { resolveRuntimeServiceVersion } from "../../../version.js";
5151
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
5252
import type { ResolvedGatewayAuth } from "../../auth.js";
5353
import type { GatewayAuthResult } from "../../auth.js";
54-
import { isLocalDirectRequest } from "../../auth.js";
54+
import { hasForwardedRequestHeaders, isLocalDirectRequest } from "../../auth.js";
5555
import {
5656
buildCanvasScopedHostUrl,
5757
CANVAS_CAPABILITY_TTL_MS,
@@ -267,7 +267,7 @@ export function attachGatewayWsMessageHandler(params: {
267267
// the connection as local. This prevents auth bypass when running behind a reverse
268268
// proxy without proper configuration - the proxy's loopback connection would otherwise
269269
// cause all external requests to be treated as trusted local clients.
270-
const hasProxyHeaders = Boolean(forwardedFor || realIp);
270+
const hasProxyHeaders = hasForwardedRequestHeaders(upgradeReq);
271271
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
272272
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
273273
const hostIsLocalish = isLocalishHost(requestHost);

0 commit comments

Comments
 (0)