fix(gateway): harden WS pairing locality

This commit is contained in:
Peter Steinberger
2026-04-22 14:55:51 +01:00
parent 95e430f670
commit dc2c3a4920
5 changed files with 68 additions and 12 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -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;

View File

@@ -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({

View File

@@ -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);