Skip to content

Commit bdcd543

Browse files
committed
fix(gateway): bypass proxies for localhost control plane
1 parent af31fc9 commit bdcd543

6 files changed

Lines changed: 184 additions & 39 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
7979
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
8080
- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07.
8181
- Gateway/startup: return retryable `UNAVAILABLE` during the sidecar startup window and keep CLI/TUI/status clients retrying inside their existing timeout budget, so early connects no longer surface as terminal handshake failures. Fixes #73652. Thanks @spenceryang1996-dot.
82+
- Gateway/proxy: bypass inherited proxy environment for local Gateway control-plane WebSockets to `localhost` as well as loopback IPs, so Windows/WSL proxy settings cannot intercept local CLI/TUI Gateway connections. Supersedes #73474; refs #73602. Thanks @DhtIsCoding.
8283
- Doctor/Gateway: use a lightweight `status` RPC without channel summary work for doctor Gateway liveness, so slow health snapshots do not falsely drive service restart repair. Fixes #64400; supersedes #64511. Thanks @CHE10X and @EronFan.
8384
- Agents/auth: scope external CLI credential discovery to configured providers during model auth status and startup prewarm, so opencode-only and other single-provider gateways do not block on unrelated Claude CLI Keychain probes. Fixes #73908. Thanks @Ailuras.
8485
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.

docs/security/network-proxy.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ OpenClaw process
3636
WebSocket clients -> operator-managed filtering proxy -> public internet
3737
```
3838

39-
The public contract is the routing behavior, not the internal Node hooks used to implement it. OpenClaw Gateway control-plane WebSocket clients use a narrow direct path for local loopback Gateway RPC traffic when the Gateway URL uses a literal loopback IP such as `127.0.0.1` or `[::1]`. That control-plane path must be able to reach loopback Gateways even when the operator proxy blocks loopback destinations. Normal runtime HTTP and WebSocket requests still use the configured proxy.
39+
The public contract is the routing behavior, not the internal Node hooks used to implement it. OpenClaw Gateway control-plane WebSocket clients use a narrow direct path for local loopback Gateway RPC traffic when the Gateway URL uses `localhost` or a literal loopback IP such as `127.0.0.1` or `[::1]`. That control-plane path must be able to reach loopback Gateways even when the operator proxy blocks loopback destinations. Normal runtime HTTP and WebSocket requests still use the configured proxy.
4040

4141
The proxy URL itself must use `http://`. HTTPS destinations are still supported through the proxy with HTTP `CONNECT`; this only means OpenClaw expects a plain HTTP forward-proxy listener such as `http://127.0.0.1:3128`.
4242

@@ -150,6 +150,6 @@ proxy:
150150
- The proxy improves coverage for process-local JavaScript HTTP and WebSocket clients, but it does not replace application-level `fetchWithSsrFGuard`.
151151
- Raw `net`, `tls`, and `http2` sockets, native addons, and child processes may bypass Node-level proxy routing unless they inherit and respect proxy environment variables.
152152
- User local WebUIs and local model servers should be allowlisted in the operator proxy policy when needed; OpenClaw does not expose a general local-network bypass for them.
153-
- Gateway control-plane proxy bypass is intentionally limited to literal loopback IP URLs. Use `ws://127.0.0.1:18789` or `ws://[::1]:18789` for local direct Gateway control-plane connections; `localhost` hostnames route like ordinary hostname-based traffic.
153+
- Gateway control-plane proxy bypass is intentionally limited to `localhost` and literal loopback IP URLs. Use `ws://127.0.0.1:18789`, `ws://[::1]:18789`, or `ws://localhost:18789` for local direct Gateway control-plane connections; other hostnames route like ordinary hostname-based traffic.
154154
- OpenClaw does not inspect, test, or certify your proxy policy.
155155
- Treat proxy policy changes as security-sensitive operational changes.

src/gateway/client.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import { dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane } from "../
1717
import { normalizeFingerprint } from "../infra/tls/fingerprint.js";
1818
import { rawDataToString } from "../infra/ws.js";
1919
import { logDebug, logError } from "../logger.js";
20-
import { isLoopbackIpAddress } from "../shared/net/ip.js";
2120
import {
2221
normalizeLowercaseStringOrEmpty,
2322
normalizeOptionalString,
@@ -101,7 +100,7 @@ function createDirectGatewayAgent(url: string): http.Agent | https.Agent | undef
101100
} catch {
102101
return undefined;
103102
}
104-
if (!isLoopbackIpAddress(hostname)) {
103+
if (!isLoopbackHost(hostname)) {
105104
return undefined;
106105
}
107106
return url.startsWith("wss://") ? new https.Agent() : new http.Agent();

src/gateway/gateway-misc.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,12 @@ describe("GatewayClient", () => {
105105
expect(last?.opts.agent).toBeDefined();
106106
});
107107

108-
test("does not use the direct control-plane bypass for localhost hostnames", () => {
108+
test("uses the direct control-plane bypass for localhost hostnames", () => {
109109
const client = new GatewayClient({ url: "ws://localhost:1" });
110110
client.start();
111111
const last = wsMockState.last as { opts: { agent?: unknown } } | null;
112112

113-
expect(last?.opts.agent).toBeUndefined();
113+
expect(last?.opts.agent).toBeDefined();
114114
});
115115

116116
test("does not force a direct agent for remote Gateway WebSocket connections", () => {

src/infra/net/proxy/proxy-lifecycle.test.ts

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ describe("startProxy", () => {
3838
"https_proxy",
3939
"HTTP_PROXY",
4040
"HTTPS_PROXY",
41+
"all_proxy",
42+
"ALL_PROXY",
4143
"no_proxy",
4244
"NO_PROXY",
4345
"GLOBAL_AGENT_HTTP_PROXY",
@@ -378,7 +380,7 @@ describe("startProxy", () => {
378380
await stopProxy(handle);
379381
});
380382

381-
it("allows the Gateway control-plane bypass for literal loopback IPs only", () => {
383+
it("allows the Gateway control-plane bypass for literal loopback IPs and localhost", () => {
382384
expect(
383385
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
384386
"ws://127.0.0.1:18789",
@@ -388,12 +390,18 @@ describe("startProxy", () => {
388390
expect(
389391
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane("ws://[::1]:18789", () => "ok"),
390392
).toBe("ok");
391-
expect(() =>
393+
expect(
392394
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
393395
"ws://localhost:18789",
394-
() => undefined,
396+
() => "ok",
395397
),
396-
).toThrow("loopback-only");
398+
).toBe("ok");
399+
expect(
400+
dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
401+
"ws://localhost.:18789",
402+
() => "ok",
403+
),
404+
).toBe("ok");
397405
});
398406

399407
it("rejects dangerous Gateway control-plane bypass for non-loopback URLs", () => {
@@ -405,6 +413,92 @@ describe("startProxy", () => {
405413
).toThrow("loopback-only");
406414
});
407415

416+
it("temporarily clears inherited proxy env for Gateway control-plane setup", () => {
417+
process.env["http_proxy"] = "http://lower-http.example.com:8080";
418+
process.env["https_proxy"] = "http://lower-https.example.com:8080";
419+
process.env["HTTP_PROXY"] = "http://upper-http.example.com:8080";
420+
process.env["HTTPS_PROXY"] = "http://upper-https.example.com:8080";
421+
process.env["all_proxy"] = "http://lower-all.example.com:8080";
422+
process.env["ALL_PROXY"] = "http://upper-all.example.com:8080";
423+
process.env["NO_PROXY"] = "localhost";
424+
process.env["no_proxy"] = "127.0.0.1";
425+
process.env["GLOBAL_AGENT_HTTP_PROXY"] = "http://global-http.example.com:8080";
426+
process.env["GLOBAL_AGENT_HTTPS_PROXY"] = "http://global-https.example.com:8080";
427+
process.env["GLOBAL_AGENT_NO_PROXY"] = "localhost";
428+
process.env["GLOBAL_AGENT_FORCE_GLOBAL_AGENT"] = "true";
429+
process.env["OPENCLAW_PROXY_ACTIVE"] = "1";
430+
431+
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
432+
"ws://localhost:18789",
433+
() => ({
434+
httpProxy: process.env["HTTP_PROXY"],
435+
httpsProxy: process.env["HTTPS_PROXY"],
436+
allProxy: process.env["ALL_PROXY"],
437+
lowerAllProxy: process.env["all_proxy"],
438+
noProxy: process.env["NO_PROXY"],
439+
globalProxy: process.env["GLOBAL_AGENT_HTTP_PROXY"],
440+
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
441+
}),
442+
);
443+
444+
expect(during).toEqual({
445+
httpProxy: undefined,
446+
httpsProxy: undefined,
447+
allProxy: undefined,
448+
lowerAllProxy: undefined,
449+
noProxy: undefined,
450+
globalProxy: undefined,
451+
proxyActive: undefined,
452+
});
453+
expect(process.env["HTTP_PROXY"]).toBe("http://upper-http.example.com:8080");
454+
expect(process.env["HTTPS_PROXY"]).toBe("http://upper-https.example.com:8080");
455+
expect(process.env["ALL_PROXY"]).toBe("http://upper-all.example.com:8080");
456+
expect(process.env["all_proxy"]).toBe("http://lower-all.example.com:8080");
457+
expect(process.env["NO_PROXY"]).toBe("localhost");
458+
expect(process.env["GLOBAL_AGENT_HTTP_PROXY"]).toBe("http://global-http.example.com:8080");
459+
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
460+
});
461+
462+
it("temporarily clears managed proxy env while restoring the original HTTP stack", async () => {
463+
const patchedHttpRequest = vi.fn() as unknown as typeof http.request;
464+
mockBootstrapGlobalAgent.mockImplementationOnce(() => {
465+
http.request = patchedHttpRequest;
466+
(global as Record<string, unknown>)["GLOBAL_AGENT"] = {
467+
HTTP_PROXY: "",
468+
HTTPS_PROXY: "",
469+
};
470+
});
471+
472+
const handle = await startProxy({
473+
enabled: true,
474+
proxyUrl: "http://127.0.0.1:3128",
475+
});
476+
process.env["ALL_PROXY"] = "http://inherited-all.example.com:8080";
477+
478+
const during = dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane(
479+
"ws://127.0.0.1:18789",
480+
() => ({
481+
httpRequest: http.request,
482+
httpProxy: process.env["HTTP_PROXY"],
483+
allProxy: process.env["ALL_PROXY"],
484+
proxyActive: process.env["OPENCLAW_PROXY_ACTIVE"],
485+
}),
486+
);
487+
488+
expect(during).toEqual({
489+
httpRequest: originalHttpRequest,
490+
httpProxy: undefined,
491+
allProxy: undefined,
492+
proxyActive: undefined,
493+
});
494+
expect(http.request).toBe(patchedHttpRequest);
495+
expect(process.env["HTTP_PROXY"]).toBe("http://127.0.0.1:3128");
496+
expect(process.env["ALL_PROXY"]).toBe("http://inherited-all.example.com:8080");
497+
expect(process.env["OPENCLAW_PROXY_ACTIVE"]).toBe("1");
498+
499+
await stopProxy(handle);
500+
});
501+
408502
it("kill restores env synchronously during hard process exit", async () => {
409503
process.env["NO_PROXY"] = "corp.example.com";
410504
const handle = await startProxy({

src/infra/net/proxy/proxy-lifecycle.ts

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,19 @@ const ALL_PROXY_ENV_KEYS = [
4040
...NO_PROXY_ENV_KEYS,
4141
...PROXY_ACTIVE_KEYS,
4242
] as const;
43+
const GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS = [
44+
...ALL_PROXY_ENV_KEYS,
45+
"all_proxy",
46+
"ALL_PROXY",
47+
] as const;
4348
type ProxyEnvKey = (typeof ALL_PROXY_ENV_KEYS)[number];
4449
type ProxyEnvSnapshot = Record<ProxyEnvKey, string | undefined>;
50+
type GatewayControlPlaneProxyBypassEnvKey =
51+
(typeof GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS)[number];
52+
type GatewayControlPlaneProxyBypassEnvSnapshot = Record<
53+
GatewayControlPlaneProxyBypassEnvKey,
54+
string | undefined
55+
>;
4556
type NodeHttpStackSnapshot = {
4657
httpRequest: typeof http.request;
4758
httpGet: typeof http.get;
@@ -116,6 +127,39 @@ function restoreProxyEnv(snapshot: ProxyEnvSnapshot): void {
116127
}
117128
}
118129

130+
function captureGatewayControlPlaneProxyBypassEnv(): GatewayControlPlaneProxyBypassEnvSnapshot {
131+
const snapshot = {} as GatewayControlPlaneProxyBypassEnvSnapshot;
132+
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
133+
snapshot[key] = process.env[key];
134+
}
135+
return snapshot;
136+
}
137+
138+
function restoreGatewayControlPlaneProxyBypassEnv(
139+
snapshot: GatewayControlPlaneProxyBypassEnvSnapshot,
140+
): void {
141+
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
142+
const value = snapshot[key];
143+
if (value === undefined) {
144+
delete process.env[key];
145+
} else {
146+
process.env[key] = value;
147+
}
148+
}
149+
}
150+
151+
function withoutGatewayControlPlaneProxyEnv<T>(run: () => T): T {
152+
const snapshot = captureGatewayControlPlaneProxyBypassEnv();
153+
for (const key of GATEWAY_CONTROL_PLANE_PROXY_BYPASS_ENV_KEYS) {
154+
delete process.env[key];
155+
}
156+
try {
157+
return run();
158+
} finally {
159+
restoreGatewayControlPlaneProxyBypassEnv(snapshot);
160+
}
161+
}
162+
119163
function restoreGlobalAgentRuntime(snapshot: ProxyEnvSnapshot): void {
120164
if (
121165
typeof global === "undefined" ||
@@ -371,7 +415,12 @@ function isGatewayLoopbackControlPlaneUrl(value: string): boolean {
371415
) {
372416
return false;
373417
}
374-
return isLoopbackIpAddress(url.hostname);
418+
return isGatewayControlPlaneLoopbackHost(url.hostname);
419+
}
420+
421+
function isGatewayControlPlaneLoopbackHost(hostname: string): boolean {
422+
const normalizedHost = hostname.trim().toLowerCase().replace(/\.+$/, "");
423+
return normalizedHost === "localhost" || isLoopbackIpAddress(hostname);
375424
}
376425

377426
export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
@@ -384,38 +433,40 @@ export function dangerouslyBypassManagedProxyForGatewayLoopbackControlPlane<T>(
384433

385434
const snapshot = nodeHttpStackSnapshot;
386435
if (!snapshot) {
387-
return run();
436+
return withoutGatewayControlPlaneProxyEnv(run);
388437
}
389438

390439
// Security-sensitive: this temporarily removes managed proxy hooks for the
391440
// synchronous Gateway loopback WebSocket constructor only. Do not reuse this
392441
// helper for provider, plugin, user WebUI, model server, or arbitrary egress.
393-
const activeStack = captureNodeHttpStack();
394-
const globalRecord = global as Record<string, unknown>;
395-
try {
396-
http.request = snapshot.httpRequest;
397-
http.get = snapshot.httpGet;
398-
http.globalAgent = snapshot.httpGlobalAgent;
399-
https.request = snapshot.httpsRequest;
400-
https.get = snapshot.httpsGet;
401-
https.globalAgent = snapshot.httpsGlobalAgent;
402-
if (snapshot.hadGlobalAgent) {
403-
globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent;
404-
} else {
405-
delete globalRecord["GLOBAL_AGENT"];
406-
}
407-
return run();
408-
} finally {
409-
http.request = activeStack.httpRequest;
410-
http.get = activeStack.httpGet;
411-
http.globalAgent = activeStack.httpGlobalAgent;
412-
https.request = activeStack.httpsRequest;
413-
https.get = activeStack.httpsGet;
414-
https.globalAgent = activeStack.httpsGlobalAgent;
415-
if (activeStack.hadGlobalAgent) {
416-
globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent;
417-
} else {
418-
delete globalRecord["GLOBAL_AGENT"];
442+
return withoutGatewayControlPlaneProxyEnv(() => {
443+
const activeStack = captureNodeHttpStack();
444+
const globalRecord = global as Record<string, unknown>;
445+
try {
446+
http.request = snapshot.httpRequest;
447+
http.get = snapshot.httpGet;
448+
http.globalAgent = snapshot.httpGlobalAgent;
449+
https.request = snapshot.httpsRequest;
450+
https.get = snapshot.httpsGet;
451+
https.globalAgent = snapshot.httpsGlobalAgent;
452+
if (snapshot.hadGlobalAgent) {
453+
globalRecord["GLOBAL_AGENT"] = snapshot.globalAgent;
454+
} else {
455+
delete globalRecord["GLOBAL_AGENT"];
456+
}
457+
return run();
458+
} finally {
459+
http.request = activeStack.httpRequest;
460+
http.get = activeStack.httpGet;
461+
http.globalAgent = activeStack.httpGlobalAgent;
462+
https.request = activeStack.httpsRequest;
463+
https.get = activeStack.httpsGet;
464+
https.globalAgent = activeStack.httpsGlobalAgent;
465+
if (activeStack.hadGlobalAgent) {
466+
globalRecord["GLOBAL_AGENT"] = activeStack.globalAgent;
467+
} else {
468+
delete globalRecord["GLOBAL_AGENT"];
469+
}
419470
}
420-
}
471+
});
421472
}

0 commit comments

Comments
 (0)