mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:30:42 +00:00
fix(gateway): harden WS pairing locality
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user