fix: bypass proxy for CDP localhost connections (#31219)

When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set,
CDP connections to localhost/127.0.0.1 can be incorrectly routed through
the proxy (e.g. via global-agent or undici proxy dispatcher), causing
browser control to fail.

Fix:
- New cdp-proxy-bypass module with utilities for direct localhost connections
- WebSocket (ws) CDP connections: pass explicit http.Agent to bypass any
  global proxy agent patching
- fetch-based CDP probes: wrap in withNoProxyForLocalhost() to temporarily
  set NO_PROXY for the duration of the call
- Playwright connectOverCDP: wrap in withNoProxyForLocalhost() since
  Playwright reads env vars internally
- 13 new tests covering getDirectAgentForCdp, hasProxyEnv, and
  withNoProxyForLocalhost (env save/restore, error recovery)
This commit is contained in:
Marcus Widing
2026-03-02 09:03:29 +01:00
committed by Peter Steinberger
parent 1184d39e1d
commit c96234b51d
5 changed files with 275 additions and 6 deletions

View File

@@ -0,0 +1,159 @@
import http from "node:http";
import https from "node:https";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getDirectAgentForCdp, hasProxyEnv, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
describe("cdp-proxy-bypass", () => {
describe("getDirectAgentForCdp", () => {
it("returns http.Agent for http://localhost URLs", () => {
const agent = getDirectAgentForCdp("http://localhost:9222");
expect(agent).toBeInstanceOf(http.Agent);
});
it("returns http.Agent for http://127.0.0.1 URLs", () => {
const agent = getDirectAgentForCdp("http://127.0.0.1:9222/json/version");
expect(agent).toBeInstanceOf(http.Agent);
});
it("returns https.Agent for wss://localhost URLs", () => {
const agent = getDirectAgentForCdp("wss://localhost:9222");
expect(agent).toBeInstanceOf(https.Agent);
});
it("returns http.Agent for ws://[::1] URLs", () => {
const agent = getDirectAgentForCdp("ws://[::1]:9222");
expect(agent).toBeInstanceOf(http.Agent);
});
it("returns undefined for non-loopback URLs", () => {
expect(getDirectAgentForCdp("http://remote-host:9222")).toBeUndefined();
expect(getDirectAgentForCdp("https://example.com:9222")).toBeUndefined();
});
it("returns undefined for invalid URLs", () => {
expect(getDirectAgentForCdp("not-a-url")).toBeUndefined();
});
});
describe("hasProxyEnv", () => {
const proxyVars = [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
];
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
for (const v of proxyVars) {
saved[v] = process.env[v];
}
for (const v of proxyVars) {
delete process.env[v];
}
});
afterEach(() => {
for (const v of proxyVars) {
if (saved[v] !== undefined) {
process.env[v] = saved[v];
} else {
delete process.env[v];
}
}
});
it("returns false when no proxy vars set", () => {
expect(hasProxyEnv()).toBe(false);
});
it("returns true when HTTP_PROXY is set", () => {
process.env.HTTP_PROXY = "http://proxy:8080";
expect(hasProxyEnv()).toBe(true);
});
it("returns true when ALL_PROXY is set", () => {
process.env.ALL_PROXY = "socks5://proxy:1080";
expect(hasProxyEnv()).toBe(true);
});
});
describe("withNoProxyForLocalhost", () => {
const saved: Record<string, string | undefined> = {};
const vars = ["HTTP_PROXY", "NO_PROXY", "no_proxy"];
beforeEach(() => {
for (const v of vars) {
saved[v] = process.env[v];
}
});
afterEach(() => {
for (const v of vars) {
if (saved[v] !== undefined) {
process.env[v] = saved[v];
} else {
delete process.env[v];
}
}
});
it("sets NO_PROXY when proxy is configured", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
delete process.env.NO_PROXY;
delete process.env.no_proxy;
let capturedNoProxy: string | undefined;
await withNoProxyForLocalhost(async () => {
capturedNoProxy = process.env.NO_PROXY;
});
expect(capturedNoProxy).toContain("localhost");
expect(capturedNoProxy).toContain("127.0.0.1");
expect(capturedNoProxy).toContain("[::1]");
// Restored after
expect(process.env.NO_PROXY).toBeUndefined();
});
it("extends existing NO_PROXY", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
process.env.NO_PROXY = "internal.corp";
let capturedNoProxy: string | undefined;
await withNoProxyForLocalhost(async () => {
capturedNoProxy = process.env.NO_PROXY;
});
expect(capturedNoProxy).toContain("internal.corp");
expect(capturedNoProxy).toContain("localhost");
// Restored
expect(process.env.NO_PROXY).toBe("internal.corp");
});
it("skips when no proxy env is set", async () => {
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.ALL_PROXY;
delete process.env.NO_PROXY;
await withNoProxyForLocalhost(async () => {
expect(process.env.NO_PROXY).toBeUndefined();
});
});
it("restores env even on error", async () => {
process.env.HTTP_PROXY = "http://proxy:8080";
delete process.env.NO_PROXY;
await expect(
withNoProxyForLocalhost(async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(process.env.NO_PROXY).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,92 @@
/**
* Proxy bypass for CDP (Chrome DevTools Protocol) localhost connections.
*
* When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set,
* CDP connections to localhost/127.0.0.1 can be incorrectly routed through
* the proxy, causing browser control to fail.
*
* @see https://github.com/nicepkg/openclaw/issues/31219
*/
import http from "node:http";
import https from "node:https";
import { isLoopbackHost } from "../gateway/net.js";
/** HTTP agent that never uses a proxy — for localhost CDP connections. */
const directHttpAgent = new http.Agent();
const directHttpsAgent = new https.Agent();
/**
* Returns a plain (non-proxy) agent for WebSocket or HTTP connections
* when the target is a loopback address. Returns `undefined` otherwise
* so callers fall through to their default behaviour.
*/
export function getDirectAgentForCdp(url: string): http.Agent | https.Agent | undefined {
try {
const parsed = new URL(url);
if (isLoopbackHost(parsed.hostname)) {
return parsed.protocol === "https:" || parsed.protocol === "wss:"
? directHttpsAgent
: directHttpAgent;
}
} catch {
// not a valid URL — let caller handle it
}
return undefined;
}
/**
* Returns `true` when any proxy-related env var is set that could
* interfere with loopback connections.
*/
export function hasProxyEnv(): boolean {
const env = process.env;
return Boolean(
env.HTTP_PROXY ||
env.http_proxy ||
env.HTTPS_PROXY ||
env.https_proxy ||
env.ALL_PROXY ||
env.all_proxy,
);
}
/**
* Run an async function with NO_PROXY temporarily extended to include
* localhost and 127.0.0.1. Restores the original value afterwards.
*
* Used for third-party code (e.g. Playwright) that reads env vars
* internally and doesn't accept an explicit agent.
*/
export async function withNoProxyForLocalhost<T>(fn: () => Promise<T>): Promise<T> {
if (!hasProxyEnv()) {
return fn();
}
const origNoProxy = process.env.NO_PROXY;
const origNoProxyLower = process.env.no_proxy;
const loopbackEntries = "localhost,127.0.0.1,[::1]";
const current = origNoProxy || origNoProxyLower || "";
const alreadyCoversLocalhost = current.includes("localhost") && current.includes("127.0.0.1");
if (!alreadyCoversLocalhost) {
const extended = current ? `${current},${loopbackEntries}` : loopbackEntries;
process.env.NO_PROXY = extended;
process.env.no_proxy = extended;
}
try {
return await fn();
} finally {
if (origNoProxy !== undefined) {
process.env.NO_PROXY = origNoProxy;
} else {
delete process.env.NO_PROXY;
}
if (origNoProxyLower !== undefined) {
process.env.no_proxy = origNoProxyLower;
} else {
delete process.env.no_proxy;
}
}
}

View File

@@ -1,6 +1,7 @@
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
import { rawDataToString } from "../infra/ws.js";
import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
export { isLoopbackHost };
@@ -122,7 +123,10 @@ async function fetchChecked(url: string, timeoutMs = 1500, init?: RequestInit):
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
// Bypass proxy for loopback CDP connections (#31219)
const res = await withNoProxyForLocalhost(() =>
fetch(url, { ...init, headers, signal: ctrl.signal }),
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
@@ -146,9 +150,12 @@ export async function withCdpSocket<T>(
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: 5000;
// Bypass proxy for loopback CDP connections (#31219)
const agent = getDirectAgentForCdp(wsUrl);
const ws = new WebSocket(wsUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
...(agent ? { agent } : {}),
});
const { send, closeWithError } = createCdpSender(ws);

View File

@@ -6,6 +6,7 @@ import WebSocket from "ws";
import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { CONFIG_DIR } from "../utils.js";
import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
import { appendCdpPath } from "./cdp.helpers.js";
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
import {
@@ -83,10 +84,13 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<Chro
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
try {
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
const res = await fetch(versionUrl, {
signal: ctrl.signal,
headers: getHeadersWithAuth(versionUrl),
});
// Bypass proxy for loopback CDP connections (#31219)
const res = await withNoProxyForLocalhost(() =>
fetch(versionUrl, {
signal: ctrl.signal,
headers: getHeadersWithAuth(versionUrl),
}),
);
if (!res.ok) {
return null;
}
@@ -117,9 +121,12 @@ export async function getChromeWebSocketUrl(
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const headers = getHeadersWithAuth(wsUrl);
// Bypass proxy for loopback CDP connections (#31219)
const wsAgent = getDirectAgentForCdp(wsUrl);
const ws = new WebSocket(wsUrl, {
handshakeTimeout: timeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
...(wsAgent ? { agent: wsAgent } : {}),
});
const timer = setTimeout(
() => {

View File

@@ -9,6 +9,7 @@ import type {
import { chromium } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js";
@@ -336,7 +337,10 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
const endpoint = wsUrl ?? normalized;
const headers = getHeadersWithAuth(endpoint);
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
// Bypass proxy for loopback CDP connections (#31219)
const browser = await withNoProxyForLocalhost(() =>
chromium.connectOverCDP(endpoint, { timeout, headers }),
);
const onDisconnected = () => {
if (cached?.browser === browser) {
cached = null;