Skip to content

Commit a472f98

Browse files
committed
fix: harden remote cdp probes
1 parent 53462b9 commit a472f98

File tree

14 files changed

+212
-14
lines changed

14 files changed

+212
-14
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
2626
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
2727
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
2828
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
29+
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
2930
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
3031
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. Thanks @vincentkoc.
3132
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.

docs/gateway/configuration-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,6 +2370,7 @@ See [Plugins](/tools/plugin).
23702370
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
23712371
- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model).
23722372
- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation.
2373+
- In strict mode, remote CDP profile endpoints (`profiles.*.cdpUrl`) are subject to the same private-network blocking during reachability/discovery checks.
23732374
- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias.
23742375
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
23752376
- Remote profiles are attach-only (start/stop/reset disabled).

docs/tools/browser.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Notes:
114114
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
115115
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
116116
- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation.
117+
- In strict SSRF mode, remote CDP endpoint discovery/probes (`cdpUrl`, including `/json/version` lookups) are checked too.
117118
- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing.
118119
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
119120
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”

src/browser/cdp.helpers.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import WebSocket from "ws";
22
import { isLoopbackHost } from "../gateway/net.js";
3+
import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js";
34
import { rawDataToString } from "../infra/ws.js";
5+
import { redactSensitiveText } from "../logging/redact.js";
46
import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
57
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
68
import { resolveBrowserRateLimitMessage } from "./client-fetch.js";
@@ -22,6 +24,40 @@ export function isWebSocketUrl(url: string): boolean {
2224
}
2325
}
2426

27+
export async function assertCdpEndpointAllowed(
28+
cdpUrl: string,
29+
ssrfPolicy?: SsrFPolicy,
30+
): Promise<void> {
31+
if (!ssrfPolicy) {
32+
return;
33+
}
34+
const parsed = new URL(cdpUrl);
35+
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
36+
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
37+
}
38+
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
39+
policy: ssrfPolicy,
40+
});
41+
}
42+
43+
export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined {
44+
if (typeof cdpUrl !== "string") {
45+
return cdpUrl;
46+
}
47+
const trimmed = cdpUrl.trim();
48+
if (!trimmed) {
49+
return trimmed;
50+
}
51+
try {
52+
const parsed = new URL(trimmed);
53+
parsed.username = "";
54+
parsed.password = "";
55+
return redactSensitiveText(parsed.toString().replace(/\/$/, ""));
56+
} catch {
57+
return redactSensitiveText(trimmed);
58+
}
59+
}
60+
2561
type CdpResponse = {
2662
id: number;
2763
result?: unknown;

src/browser/chrome.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,24 @@ describe("browser chrome helpers", () => {
302302
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
303303
});
304304

305+
it("blocks private CDP probes when strict SSRF policy is enabled", async () => {
306+
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
307+
vi.stubGlobal("fetch", fetchSpy);
308+
309+
await expect(
310+
isChromeReachable("http://127.0.0.1:12345", 50, {
311+
dangerouslyAllowPrivateNetwork: false,
312+
}),
313+
).resolves.toBe(false);
314+
await expect(
315+
isChromeReachable("ws://127.0.0.1:19999", 50, {
316+
dangerouslyAllowPrivateNetwork: false,
317+
}),
318+
).resolves.toBe(false);
319+
320+
expect(fetchSpy).not.toHaveBeenCalled();
321+
});
322+
305323
it("reports cdpReady only when Browser.getVersion command succeeds", async () => {
306324
await withMockChromeCdpServer({
307325
wsPath: "/devtools/browser/health",

src/browser/chrome.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
22
import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
5+
import type { SsrFPolicy } from "../infra/net/ssrf.js";
56
import { ensurePortAvailable } from "../infra/ports.js";
67
import { rawDataToString } from "../infra/ws.js";
78
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -17,7 +18,13 @@ import {
1718
CHROME_STOP_TIMEOUT_MS,
1819
CHROME_WS_READY_TIMEOUT_MS,
1920
} from "./cdp-timeouts.js";
20-
import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
21+
import {
22+
appendCdpPath,
23+
assertCdpEndpointAllowed,
24+
fetchCdpChecked,
25+
isWebSocketUrl,
26+
openCdpWebSocket,
27+
} from "./cdp.helpers.js";
2128
import { normalizeCdpWsUrl } from "./cdp.js";
2229
import {
2330
type BrowserExecutable,
@@ -96,13 +103,19 @@ async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean
96103
export async function isChromeReachable(
97104
cdpUrl: string,
98105
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
106+
ssrfPolicy?: SsrFPolicy,
99107
): Promise<boolean> {
100-
if (isWebSocketUrl(cdpUrl)) {
101-
// Direct WebSocket endpoint — probe via WS handshake.
102-
return await canOpenWebSocket(cdpUrl, timeoutMs);
108+
try {
109+
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
110+
if (isWebSocketUrl(cdpUrl)) {
111+
// Direct WebSocket endpoint — probe via WS handshake.
112+
return await canOpenWebSocket(cdpUrl, timeoutMs);
113+
}
114+
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
115+
return Boolean(version);
116+
} catch {
117+
return false;
103118
}
104-
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
105-
return Boolean(version);
106119
}
107120

108121
type ChromeVersion = {
@@ -114,10 +127,12 @@ type ChromeVersion = {
114127
async function fetchChromeVersion(
115128
cdpUrl: string,
116129
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
130+
ssrfPolicy?: SsrFPolicy,
117131
): Promise<ChromeVersion | null> {
118132
const ctrl = new AbortController();
119133
const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs);
120134
try {
135+
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
121136
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
122137
const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal });
123138
const data = (await res.json()) as ChromeVersion;
@@ -135,12 +150,14 @@ async function fetchChromeVersion(
135150
export async function getChromeWebSocketUrl(
136151
cdpUrl: string,
137152
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
153+
ssrfPolicy?: SsrFPolicy,
138154
): Promise<string | null> {
155+
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
139156
if (isWebSocketUrl(cdpUrl)) {
140157
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
141158
return cdpUrl;
142159
}
143-
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
160+
const version = await fetchChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
144161
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
145162
if (!wsUrl) {
146163
return null;
@@ -227,8 +244,9 @@ export async function isChromeCdpReady(
227244
cdpUrl: string,
228245
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
229246
handshakeTimeoutMs = CHROME_WS_READY_TIMEOUT_MS,
247+
ssrfPolicy?: SsrFPolicy,
230248
): Promise<boolean> {
231-
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
249+
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy).catch(() => null);
232250
if (!wsUrl) {
233251
return false;
234252
}

src/browser/server-context.availability.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,20 @@ export function createProfileAvailability({
7171
return true;
7272
}
7373
const { httpTimeoutMs, wsTimeoutMs } = resolveTimeouts(timeoutMs);
74-
return await isChromeCdpReady(profile.cdpUrl, httpTimeoutMs, wsTimeoutMs);
74+
return await isChromeCdpReady(
75+
profile.cdpUrl,
76+
httpTimeoutMs,
77+
wsTimeoutMs,
78+
state().resolved.ssrfPolicy,
79+
);
7580
};
7681

7782
const isHttpReachable = async (timeoutMs?: number) => {
7883
if (capabilities.usesChromeMcp) {
7984
return await isReachable(timeoutMs);
8085
}
8186
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
82-
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs);
87+
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy);
8388
};
8489

8590
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {

src/browser/server-context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,11 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
187187
} else {
188188
// Check if something is listening on the port
189189
try {
190-
const reachable = await isChromeReachable(profile.cdpUrl, 200);
190+
const reachable = await isChromeReachable(
191+
profile.cdpUrl,
192+
200,
193+
current.resolved.ssrfPolicy,
194+
);
191195
if (reachable) {
192196
running = true;
193197
const tabs = await profileCtx.listTabs().catch(() => []);

src/cli/browser-cli-manage.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,42 @@ describe("browser manage output", () => {
148148
expect(output).toContain("transport: chrome-mcp");
149149
expect(output).not.toContain("port: 0");
150150
});
151+
152+
it("redacts sensitive remote cdpUrl details in status output", async () => {
153+
mocks.callBrowserRequest.mockImplementation(async (_opts: unknown, req: { path?: string }) =>
154+
req.path === "/"
155+
? {
156+
enabled: true,
157+
profile: "remote",
158+
driver: "openclaw",
159+
transport: "cdp",
160+
running: true,
161+
cdpReady: true,
162+
cdpHttp: true,
163+
pid: null,
164+
cdpPort: 9222,
165+
cdpUrl:
166+
"https://alice:supersecretpasswordvalue1234@example.com/chrome?token=supersecrettokenvalue1234567890",
167+
chosenBrowser: null,
168+
userDataDir: null,
169+
color: "#00AA00",
170+
headless: false,
171+
noSandbox: false,
172+
executablePath: null,
173+
attachOnly: true,
174+
}
175+
: {},
176+
);
177+
178+
const program = createProgram();
179+
await program.parseAsync(["browser", "--browser-profile", "remote", "status"], {
180+
from: "user",
181+
});
182+
183+
const output = mocks.runtimeLog.mock.calls.at(-1)?.[0] as string;
184+
expect(output).toContain("cdpUrl: https://example.com/chrome?token=supers…7890");
185+
expect(output).not.toContain("alice");
186+
expect(output).not.toContain("supersecretpasswordvalue1234");
187+
expect(output).not.toContain("supersecrettokenvalue1234567890");
188+
});
151189
});

src/cli/browser-cli-manage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Command } from "commander";
2+
import { redactCdpUrl } from "../browser/cdp.helpers.js";
23
import type {
34
BrowserTransport,
45
BrowserCreateProfileResult,
@@ -152,7 +153,7 @@ export function registerBrowserManageCommands(
152153
...(!usesChromeMcpTransport(status)
153154
? [
154155
`cdpPort: ${status.cdpPort ?? "(unset)"}`,
155-
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
156+
`cdpUrl: ${redactCdpUrl(status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`)}`,
156157
]
157158
: []),
158159
`browser: ${status.chosenBrowser ?? "unknown"}`,

0 commit comments

Comments
 (0)