mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
Peter Steinberger
parent
1184d39e1d
commit
c96234b51d
159
src/browser/cdp-proxy-bypass.test.ts
Normal file
159
src/browser/cdp-proxy-bypass.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
92
src/browser/cdp-proxy-bypass.ts
Normal file
92
src/browser/cdp-proxy-bypass.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user