diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index 9d4e60292be5..c5af51da731e 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -22,7 +22,7 @@ import { import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js"; import { BrowserCdpEndpointBlockedError } from "./errors.js"; import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js"; -import { withAllowedHostname } from "./ssrf-policy-helpers.js"; +import { withAllowedHostname, withExactHostnamePolicy } from "./ssrf-policy-helpers.js"; import { normalizeBrowserTimerDelayMs } from "./timer-delay.js"; const CDP_URL_IN_TEXT_RE = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/gi; @@ -75,6 +75,17 @@ export function isDirectCdpWebSocketEndpoint(url: string): boolean { /* c8 ignore stop */ } +/** Restricts discovered CDP endpoints to the configured control-plane host. */ +export function scopeCdpPolicyToConfiguredEndpoint( + cdpUrl: string, + ssrfPolicy?: SsrFPolicy, +): SsrFPolicy | undefined { + if (!ssrfPolicy) { + return undefined; + } + return withExactHostnamePolicy(ssrfPolicy, new URL(cdpUrl).hostname); +} + export async function assertCdpEndpointAllowed( cdpUrl: string, ssrfPolicy?: SsrFPolicy, @@ -116,6 +127,14 @@ export type CdpSendFn = ( sessionId?: string, ) => Promise; +function decodeUrlUserInfo(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + function rawCdpMessageToString(data: WebSocket.RawData): string { if (typeof data === "string") { return data; @@ -144,7 +163,9 @@ export function getHeadersWithAuth(url: string, headers: Record return mergedHeaders; } if (parsed.username || parsed.password) { - const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64"); + const username = decodeUrlUserInfo(parsed.username); + const password = decodeUrlUserInfo(parsed.password); + const auth = Buffer.from(`${username}:${password}`).toString("base64"); return { ...mergedHeaders, Authorization: `Basic ${auth}` }; } } catch { diff --git a/extensions/browser/src/browser/chrome.diagnostics.ts b/extensions/browser/src/browser/chrome.diagnostics.ts index 091ecaad3e5b..a88c1ded6181 100644 --- a/extensions/browser/src/browser/chrome.diagnostics.ts +++ b/extensions/browser/src/browser/chrome.diagnostics.ts @@ -363,6 +363,7 @@ export async function diagnoseChromeCdp( startedAt, }); } + const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(cdpUrl, ssrfPolicy); if (isDirectCdpWebSocketEndpoint(cdpUrl)) { return await diagnoseCdpWebSocketEndpoint({ diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 5b55999e03ac..d999a0b58706 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -34,7 +34,6 @@ import { isWebSocketUrl, normalizeCdpHttpBaseForJsonEndpoints, redactCdpErrorText, - scopeCdpPolicyToConfiguredEndpoint, stripCdpUrlCredentials, withCdpSocket, } from "./cdp.helpers.js"; diff --git a/extensions/browser/src/browser/routes/agent.shared.test.ts b/extensions/browser/src/browser/routes/agent.shared.test.ts index d6c352eca708..d89172ee582f 100644 --- a/extensions/browser/src/browser/routes/agent.shared.test.ts +++ b/extensions/browser/src/browser/routes/agent.shared.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { BrowserRouteContext, ProfileContext } from "../server-context.js"; import "../../test-support/browser-security.mock.js"; import { + handleRouteError, readBody, resolveSafeRouteTabUrl, resolveTargetIdFromBody, diff --git a/extensions/browser/src/browser/ssrf-policy-helpers.ts b/extensions/browser/src/browser/ssrf-policy-helpers.ts index f613f3822b61..b29767174c02 100644 --- a/extensions/browser/src/browser/ssrf-policy-helpers.ts +++ b/extensions/browser/src/browser/ssrf-policy-helpers.ts @@ -14,3 +14,16 @@ export function withAllowedHostname( allowedHostnames: uniqueStrings([...(ssrfPolicy?.allowedHostnames ?? []), hostname]), }; } + +/** Returns an SSRF policy restricted to one exact control-plane hostname. */ +export function withExactHostnamePolicy( + ssrfPolicy: SsrFPolicy | undefined, + hostname: string, +): SsrFPolicy { + const { allowedOrigins: _allowedOrigins, ...basePolicy } = ssrfPolicy ?? {}; + return { + ...basePolicy, + allowedHostnames: [hostname], + hostnameAllowlist: [hostname], + }; +} diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index 1bc3ed088aea..f2d6b0553d62 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -16,7 +16,6 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatCodexDisplayText } from "../command-formatters.js"; -import { resolveCodexToolAbortTerminalReason } from "./dynamic-tool-execution.js"; import { approvalRequestExplicitlyUnavailable, mapExecDecisionToOutcome, diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index cc9e9126eb79..80ad17692831 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -42,7 +42,6 @@ import { } from "./dynamic-tool-diagnostics.js"; import { handleDynamicToolCallWithTimeout, - resolveCodexToolAbortTerminalReason, resolveDynamicToolCallTimeoutMs, } from "./dynamic-tool-execution.js"; import {