mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 21:14:31 +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 WebSocket from "ws";
|
||||||
import { isLoopbackHost } from "../gateway/net.js";
|
import { isLoopbackHost } from "../gateway/net.js";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
|
import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
|
||||||
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
|
import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
|
||||||
|
|
||||||
export { isLoopbackHost };
|
export { isLoopbackHost };
|
||||||
@@ -122,7 +123,10 @@ async function fetchChecked(url: string, timeoutMs = 1500, init?: RequestInit):
|
|||||||
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
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) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}`);
|
throw new Error(`HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -146,9 +150,12 @@ export async function withCdpSocket<T>(
|
|||||||
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
|
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
|
||||||
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
|
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
|
||||||
: 5000;
|
: 5000;
|
||||||
|
// Bypass proxy for loopback CDP connections (#31219)
|
||||||
|
const agent = getDirectAgentForCdp(wsUrl);
|
||||||
const ws = new WebSocket(wsUrl, {
|
const ws = new WebSocket(wsUrl, {
|
||||||
handshakeTimeout: handshakeTimeoutMs,
|
handshakeTimeout: handshakeTimeoutMs,
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
...(agent ? { agent } : {}),
|
||||||
});
|
});
|
||||||
const { send, closeWithError } = createCdpSender(ws);
|
const { send, closeWithError } = createCdpSender(ws);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import WebSocket from "ws";
|
|||||||
import { ensurePortAvailable } from "../infra/ports.js";
|
import { ensurePortAvailable } from "../infra/ports.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { CONFIG_DIR } from "../utils.js";
|
import { CONFIG_DIR } from "../utils.js";
|
||||||
|
import { getDirectAgentForCdp, withNoProxyForLocalhost } from "./cdp-proxy-bypass.js";
|
||||||
import { appendCdpPath } from "./cdp.helpers.js";
|
import { appendCdpPath } from "./cdp.helpers.js";
|
||||||
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import {
|
import {
|
||||||
@@ -83,10 +84,13 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<Chro
|
|||||||
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
|
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
|
||||||
const res = await fetch(versionUrl, {
|
// Bypass proxy for loopback CDP connections (#31219)
|
||||||
signal: ctrl.signal,
|
const res = await withNoProxyForLocalhost(() =>
|
||||||
headers: getHeadersWithAuth(versionUrl),
|
fetch(versionUrl, {
|
||||||
});
|
signal: ctrl.signal,
|
||||||
|
headers: getHeadersWithAuth(versionUrl),
|
||||||
|
}),
|
||||||
|
);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -117,9 +121,12 @@ export async function getChromeWebSocketUrl(
|
|||||||
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
|
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
|
||||||
return await new Promise<boolean>((resolve) => {
|
return await new Promise<boolean>((resolve) => {
|
||||||
const headers = getHeadersWithAuth(wsUrl);
|
const headers = getHeadersWithAuth(wsUrl);
|
||||||
|
// Bypass proxy for loopback CDP connections (#31219)
|
||||||
|
const wsAgent = getDirectAgentForCdp(wsUrl);
|
||||||
const ws = new WebSocket(wsUrl, {
|
const ws = new WebSocket(wsUrl, {
|
||||||
handshakeTimeout: timeoutMs,
|
handshakeTimeout: timeoutMs,
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
...(Object.keys(headers).length ? { headers } : {}),
|
||||||
|
...(wsAgent ? { agent: wsAgent } : {}),
|
||||||
});
|
});
|
||||||
const timer = setTimeout(
|
const timer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
import { chromium } from "playwright-core";
|
import { chromium } from "playwright-core";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import type { SsrFPolicy } from "../infra/net/ssrf.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 { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
|
||||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||||
import { getChromeWebSocketUrl } from "./chrome.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 wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
|
||||||
const endpoint = wsUrl ?? normalized;
|
const endpoint = wsUrl ?? normalized;
|
||||||
const headers = getHeadersWithAuth(endpoint);
|
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 = () => {
|
const onDisconnected = () => {
|
||||||
if (cached?.browser === browser) {
|
if (cached?.browser === browser) {
|
||||||
cached = null;
|
cached = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user