Medium severity6.3NVD Advisory· Published May 6, 2026· Updated May 7, 2026
CVE-2026-43582
CVE-2026-43582
Description
OpenClaw before 2026.4.10 contains a server-side request forgery vulnerability in browser navigation policy that allows attackers to bypass hostname validation through DNS rebinding attacks. Attackers can exploit inconsistent hostname resolution between validation and actual network requests to pivot to internal resources via unallowlisted hostname URLs.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.10 | 2026.4.10 |
Affected products
2Patches
1121c452d666dfix(browser): tighten strict browser hostname navigation (#64367)
14 files changed · +321 −163
CHANGELOG.md+1 −0 modified@@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai - Browser/security: apply SSRF navigation policy to subframe document navigations so iframe-targeted private-network hops are blocked without quarantining the parent page. (#64371) Thanks @eleqtrizit. - Hooks/security: mark agent hook system events as untrusted and sanitize hook display names before cron metadata reuse. (#64372) Thanks @eleqtrizit. - Media/security: honor sender-scoped `toolsBySender` policy for outbound host-media reads so denied senders cannot trigger host file disclosure via attachment hydration. (#64459) Thanks @eleqtrizit. +- Browser/security: reject strict-policy hostname navigation unless the hostname is an explicit allowlist exception or IP literal, and route CDP HTTP discovery through the pinned SSRF fetch path. (#64367) Thanks @eleqtrizit. ## 2026.4.9 ### Changes
extensions/browser/src/browser/cdp.helpers.test.ts+49 −37 modified@@ -1,53 +1,65 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { SsrFBlockedError } from "../infra/net/ssrf.js"; -vi.mock("./cdp-proxy-bypass.js", () => ({ - getDirectAgentForCdp: vi.fn(() => null), - withNoProxyForCdpUrl: vi.fn(async (_url: string, fn: () => Promise<unknown>) => await fn()), -})); +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); -const { assertCdpEndpointAllowed, fetchCdpChecked } = await import("./cdp.helpers.js"); -const { BrowserCdpEndpointBlockedError } = await import("./errors.js"); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(); + return { + ...actual, + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + }; +}); + +import { fetchJson, fetchOk } from "./cdp.helpers.js"; -describe("fetchCdpChecked", () => { +describe("cdp helpers", () => { afterEach(() => { - vi.unstubAllGlobals(); + fetchWithSsrFGuardMock.mockReset(); }); - it("disables automatic redirect following for CDP HTTP probes", async () => { - const fetchSpy = vi.fn().mockResolvedValue( - new Response(null, { - status: 302, - headers: { Location: "http://127.0.0.1:9222/json/version" }, - }), - ); - vi.stubGlobal("fetch", fetchSpy); + it("releases guarded CDP fetches after the response body is consumed", async () => { + const release = vi.fn(async () => {}); + const json = vi.fn(async () => { + expect(release).not.toHaveBeenCalled(); + return { ok: true }; + }); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { + ok: true, + status: 200, + json, + }, + release, + }); - await expect(fetchCdpChecked("https://example.com/json/version", 50)).rejects.toThrow( - "CDP endpoint redirects are not allowed", - ); + await expect( + fetchJson("http://127.0.0.1:9222/json/version", 250, undefined, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).resolves.toEqual({ ok: true }); - const init = fetchSpy.mock.calls[0]?.[1]; - expect(init?.redirect).toBe("manual"); + expect(json).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); }); -}); -describe("assertCdpEndpointAllowed", () => { - it("rethrows SSRF policy failures as BrowserCdpEndpointBlockedError so mapping can distinguish endpoint vs navigation", async () => { - await expect( - assertCdpEndpointAllowed("http://10.0.0.42:9222", { dangerouslyAllowPrivateNetwork: false }), - ).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); - }); + it("releases guarded CDP fetches for bodyless requests", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: { + ok: true, + status: 200, + }, + release, + }); - it("does not wrap non-SSRF failures", async () => { await expect( - assertCdpEndpointAllowed("file:///etc/passwd", { dangerouslyAllowPrivateNetwork: false }), - ).rejects.not.toBeInstanceOf(BrowserCdpEndpointBlockedError); - }); + fetchOk("http://127.0.0.1:9222/json/close/TARGET_1", 250, undefined, { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["127.0.0.1"], + }), + ).resolves.toBeUndefined(); - it("leaves navigation-target SsrFBlockedError alone for callers that never hit the endpoint helper", () => { - // Sanity check that raw SsrFBlockedError is still its own class and is not - // accidentally converted by the endpoint helper import. - expect(new SsrFBlockedError("blocked")).toBeInstanceOf(SsrFBlockedError); + expect(release).toHaveBeenCalledTimes(1); }); });
extensions/browser/src/browser/cdp.helpers.ts+47 −57 modified@@ -2,16 +2,11 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js"; -import { - SsrFBlockedError, - type SsrFPolicy, - resolvePinnedHostnameWithPolicy, -} from "../infra/net/ssrf.js"; +import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; import { redactSensitiveText } from "../logging/redact.js"; import { getDirectAgentForCdp, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; 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"; export { isLoopbackHost }; @@ -68,19 +63,9 @@ export async function assertCdpEndpointAllowed( if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); } - try { - await resolvePinnedHostnameWithPolicy(parsed.hostname, { - policy: ssrfPolicy, - }); - } catch (err) { - // Rethrow SSRF policy failures against the CDP endpoint itself as a - // browser-endpoint-scoped error so the route mapping does not confuse - // them with navigation-target policy blocks. - if (err instanceof SsrFBlockedError) { - throw new BrowserCdpEndpointBlockedError({ cause: err }); - } - throw err; - } + await resolvePinnedHostnameWithPolicy(parsed.hostname, { + policy: ssrfPolicy, + }); } export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined { @@ -168,6 +153,11 @@ export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string { } } +type CdpFetchResult = { + response: Response; + release: () => Promise<void>; +}; + function createCdpSender(ws: WebSocket) { let nextId = 1; const pending = new Map<number, Pending>(); @@ -233,72 +223,72 @@ export async function fetchJson<T>( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit, + ssrfPolicy?: SsrFPolicy, ): Promise<T> { - const res = await fetchCdpChecked(url, timeoutMs, init); - return (await res.json()) as T; + const { response, release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy); + try { + return (await response.json()) as T; + } finally { + await release(); + } } export async function fetchCdpChecked( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit, -): Promise<Response> { + ssrfPolicy?: SsrFPolicy, +): Promise<CdpFetchResult> { const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); - let release: (() => Promise<void>) | undefined; + let guardedRelease: (() => Promise<void>) | undefined; + let released = false; + const release = async () => { + if (released) { + return; + } + released = true; + clearTimeout(t); + await guardedRelease?.(); + }; try { const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {}); - // Block redirects on all CDP HTTP paths (not just probes) because a - // redirect to an internal host is an SSRF vector regardless of whether - // the call is /json/version, /json/list, /json/activate, or /json/close. - const guarded = await withNoProxyForCdpUrl(url, () => - fetchWithSsrFGuard({ - url, - init: { ...init, headers }, - maxRedirects: 0, - policy: { allowPrivateNetwork: true }, - signal: ctrl.signal, - auditContext: "browser-cdp", - }), - ); - release = guarded.release; - const res = guarded.response; - if (res.status >= 300 && res.status < 400) { - throw new Error("CDP endpoint redirects are not allowed"); - } + const res = await withNoProxyForCdpUrl(url, async () => { + if (ssrfPolicy) { + const guarded = await fetchWithSsrFGuard({ + url, + init: { ...init, headers }, + signal: ctrl.signal, + policy: ssrfPolicy, + auditContext: "browser-cdp", + }); + guardedRelease = guarded.release; + return guarded.response; + } + return await fetch(url, { ...init, headers, signal: ctrl.signal }); + }); if (!res.ok) { if (res.status === 429) { // Do not reflect upstream response text into the error surface (log/agent injection risk) throw new Error(`${resolveBrowserRateLimitMessage(url)} Do NOT retry the browser tool.`); } throw new Error(`HTTP ${res.status}`); } - if (typeof res.arrayBuffer !== "function") { - return res; - } - const body = await res.arrayBuffer(); - return new Response(body, { - headers: res.headers, - status: res.status, - statusText: res.statusText, - }); + return { response: res, release }; } catch (error) { - if (error instanceof Error && error.message.startsWith("Too many redirects")) { - throw new Error("CDP endpoint redirects are not allowed", { cause: error }); - } + await release(); throw error; - } finally { - clearTimeout(t); - await release?.(); } } export async function fetchOk( url: string, timeoutMs = CDP_HTTP_REQUEST_TIMEOUT_MS, init?: RequestInit, + ssrfPolicy?: SsrFPolicy, ): Promise<void> { - await fetchCdpChecked(url, timeoutMs, init); + const { release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy); + await release(); } export function openCdpWebSocket(
extensions/browser/src/browser/cdp.test.ts+19 −3 modified@@ -186,6 +186,22 @@ describe("cdp", () => { } }); + it("blocks hostname navigation targets when strict SSRF policy is configured", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + await expect( + createTargetViaCdp({ + cdpUrl: "http://127.0.0.1:9222", + url: "https://example.com", + ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("blocks unsupported non-network navigation URLs", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); try { @@ -236,7 +252,7 @@ describe("cdp", () => { await expect( createTargetViaCdp({ cdpUrl: `http://127.0.0.1:${httpPort}`, - url: "https://example.com", + url: "https://93.184.216.34", ssrfPolicy: { dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"], @@ -249,7 +265,7 @@ describe("cdp", () => { await expect( createTargetViaCdp({ cdpUrl: "http://169.254.169.254:9222", - url: "https://example.com", + url: "https://93.184.216.34", ssrfPolicy: { dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"], @@ -262,7 +278,7 @@ describe("cdp", () => { await expect( createTargetViaCdp({ cdpUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT", - url: "https://example.com", + url: "https://93.184.216.34", ssrfPolicy: { dangerouslyAllowPrivateNetwork: false, allowedHostnames: ["127.0.0.1"],
extensions/browser/src/browser/cdp.ts+2 −1 modified@@ -187,10 +187,11 @@ export async function createTargetViaCdp(opts: { wsUrl = opts.cdpUrl; } else { // Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version. - await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy); const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( appendCdpPath(opts.cdpUrl, "/json/version"), 1500, + undefined, + opts.ssrfPolicy, ); const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim(); wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
extensions/browser/src/browser/chrome.ts+14 −6 modified@@ -171,14 +171,22 @@ async function fetchChromeVersion( const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); try { - await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); const versionUrl = appendCdpPath(cdpUrl, "/json/version"); - const res = await fetchCdpChecked(versionUrl, timeoutMs, { signal: ctrl.signal }); - const data = (await res.json()) as ChromeVersion; - if (!data || typeof data !== "object") { - return null; + const { response, release } = await fetchCdpChecked( + versionUrl, + timeoutMs, + { signal: ctrl.signal }, + ssrfPolicy, + ); + try { + const data = (await response.json()) as ChromeVersion; + if (!data || typeof data !== "object") { + return null; + } + return data; + } finally { + await release(); } - return data; } catch { return null; } finally {
extensions/browser/src/browser/navigation-guard.test.ts+88 −0 modified@@ -116,6 +116,85 @@ describe("browser navigation guard", () => { expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true }); }); + it("blocks hostname navigation when strict SSRF policy is explicitly configured", async () => { + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://example.com", + lookupFn, + ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, + }), + ).rejects.toThrow(/dns rebinding protections are unavailable/i); + expect(lookupFn).not.toHaveBeenCalled(); + }); + + it("allows explicitly allowed hostnames in strict mode", async () => { + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://agent.internal", + lookupFn, + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + allowedHostnames: ["agent.internal"], + }, + }), + ).resolves.toBeUndefined(); + }); + + it("allows wildcard-allowlisted hostnames in strict mode", async () => { + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://sub.example.com", + lookupFn, + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com"], + }, + }), + ).resolves.toBeUndefined(); + }); + + it("does not treat the bare suffix as matching a wildcard allowlist entry", async () => { + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://example.com", + lookupFn, + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com"], + }, + }), + ).rejects.toThrow(/dns rebinding protections are unavailable/i); + expect(lookupFn).not.toHaveBeenCalled(); + }); + + it("does not match sibling domains against wildcard allowlist entries", async () => { + const lookupFn = createLookupFn("93.184.216.34"); + await expect( + assertBrowserNavigationAllowed({ + url: "https://evil-example.com", + lookupFn, + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com"], + }, + }), + ).rejects.toThrow(/dns rebinding protections are unavailable/i); + expect(lookupFn).not.toHaveBeenCalled(); + }); + + it("treats bracketed IPv6 URL hostnames as IP literals in strict mode", async () => { + await expect( + assertBrowserNavigationAllowed({ + url: "https://[2606:4700:4700::1111]/", + ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, + }), + ).resolves.toBeUndefined(); + }); + it("blocks strict policy navigation when env proxy is configured", async () => { vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); const lookupFn = createLookupFn("93.184.216.34"); @@ -165,6 +244,15 @@ describe("browser navigation guard", () => { ).resolves.toBeUndefined(); }); + it("blocks final hostname URLs in strict mode after navigation", async () => { + await expect( + assertBrowserNavigationResultAllowed({ + url: "https://example.com/final", + ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + }); + it("blocks private intermediate redirect hops", async () => { const publicLookup = createLookupFn("93.184.216.34"); const privateLookup = createLookupFn("127.0.0.1");
extensions/browser/src/browser/navigation-guard.ts+40 −1 modified@@ -1,3 +1,8 @@ +import { isIP } from "node:net"; +import { + matchesHostnameAllowlist, + normalizeHostname, +} from "openclaw/plugin-sdk/browser-security-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; import { @@ -41,6 +46,24 @@ export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFP return !isPrivateNetworkAllowedByPolicy(ssrfPolicy); } +function isIpLiteralHostname(hostname: string): boolean { + return isIP(normalizeHostname(hostname)) !== 0; +} + +function isExplicitlyAllowedBrowserHostname(hostname: string, ssrfPolicy?: SsrFPolicy): boolean { + const normalizedHostname = normalizeHostname(hostname); + const exactMatches = ssrfPolicy?.allowedHostnames ?? []; + if (exactMatches.some((value) => normalizeHostname(value) === normalizedHostname)) { + return true; + } + const hostnameAllowlist = (ssrfPolicy?.hostnameAllowlist ?? []) + .map((pattern) => normalizeHostname(pattern)) + .filter(Boolean); + return hostnameAllowlist.length > 0 + ? matchesHostnameAllowlist(normalizedHostname, hostnameAllowlist) + : false; +} + export async function assertBrowserNavigationAllowed( opts: { url: string; @@ -78,6 +101,21 @@ export async function assertBrowserNavigationAllowed( ); } + // Browser navigations happen in Chromium's network stack, not Node's. In + // strict mode, a hostname-based URL would be resolved twice by different + // resolvers, so Node-side pinning cannot guarantee the browser connects to + // the same address that passed policy checks. + if ( + opts.ssrfPolicy && + !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy) && + !isIpLiteralHostname(parsed.hostname) && + !isExplicitlyAllowedBrowserHostname(parsed.hostname, opts.ssrfPolicy) + ) { + throw new InvalidBrowserNavigationUrlError( + "Navigation blocked: strict browser SSRF policy requires an IP-literal URL because browser DNS rebinding protections are unavailable for hostname-based navigation", + ); + } + await resolvePinnedHostnameWithPolicy(parsed.hostname, { lookupFn: opts.lookupFn, policy: opts.ssrfPolicy, @@ -87,7 +125,8 @@ export async function assertBrowserNavigationAllowed( /** * Best-effort post-navigation guard for final page URLs. * Only validates network URLs (http/https) and about:blank to avoid false - * positives on browser-internal error pages (e.g. chrome-error://). + * positives on browser-internal error pages (e.g. chrome-error://). In strict + * mode this intentionally re-applies the hostname gate after redirects. */ export async function assertBrowserNavigationResultAllowed( opts: {
extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts+14 −0 modified@@ -202,6 +202,20 @@ describe("pw-session createPageViaPlaywright navigation guard", () => { expect(pageGoto).not.toHaveBeenCalled(); }); + it("blocks hostname navigation when strict SSRF policy is configured", async () => { + const { pageGoto } = installBrowserMocks(); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://example.com", + ssrfPolicy: { dangerouslyAllowPrivateNetwork: false }, + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + + expect(pageGoto).not.toHaveBeenCalled(); + }); + it("blocks private intermediate redirect hops", async () => { const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); mockBlockedRedirectNavigation({ pageGoto, getRouteHandler, mainFrame });
extensions/browser/src/browser/server-context.selection.ts+17 −31 modified@@ -1,27 +1,20 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { - assertCdpEndpointAllowed, - fetchOk, - normalizeCdpHttpBaseForJsonEndpoints, -} from "./cdp.helpers.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath } from "./cdp.js"; import { closeChromeMcpTab, focusChromeMcpTab } from "./chrome-mcp.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import type { PwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js"; -import type { - BrowserServerState, - BrowserTab, - ProfileRuntimeState, -} from "./server-context.types.js"; +import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js"; import { resolveTargetIdFromTabs } from "./target-id.js"; type SelectionDeps = { profile: ResolvedBrowserProfile; - state: () => BrowserServerState; getProfileState: () => ProfileRuntimeState; + getSsrFPolicy: () => SsrFPolicy | undefined; ensureBrowserAvailable: () => Promise<void>; listTabs: () => Promise<BrowserTab[]>; openTab: (url: string) => Promise<BrowserTab>; @@ -35,27 +28,17 @@ type SelectionOps = { export function createProfileSelectionOps({ profile, - state, getProfileState, + getSsrFPolicy, ensureBrowserAvailable, listTabs, openTab, }: SelectionDeps): SelectionOps { const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); const capabilities = getBrowserProfileCapabilities(profile); - const assertProfileCdpEndpointAllowed = async (): Promise<void> => { - await assertCdpEndpointAllowed(profile.cdpUrl, state().resolved.ssrfPolicy); - }; - const assertSelectableCdpEndpointAllowed = async (): Promise<void> => { - if (capabilities.usesChromeMcp || !profile.cdpUrl) { - return; - } - await assertProfileCdpEndpointAllowed(); - }; const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => { await ensureBrowserAvailable(); - await assertSelectableCdpEndpointAllowed(); const profileState = getProfileState(); const tabs1 = await listTabs(); if (tabs1.length === 0) { @@ -100,7 +83,6 @@ export function createProfileSelectionOps({ }; const resolveTargetIdOrThrow = async (targetId: string): Promise<string> => { - await assertSelectableCdpEndpointAllowed(); const tabs = await listTabs(); const resolved = resolveTargetIdFromTabs(targetId, tabs); if (!resolved.ok) { @@ -127,20 +109,22 @@ export function createProfileSelectionOps({ const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null) ?.focusPageByTargetIdViaPlaywright; if (typeof focusPageByTargetIdViaPlaywright === "function") { - // SSRF check runs inside connectBrowser on cache miss. await focusPageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolvedTargetId, - ssrfPolicy: state().resolved.ssrfPolicy, }); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; return; } } - await assertProfileCdpEndpointAllowed(); - await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`)); + await fetchOk( + appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`), + undefined, + undefined, + getSsrFPolicy(), + ); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; }; @@ -159,18 +143,20 @@ export function createProfileSelectionOps({ const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null) ?.closePageByTargetIdViaPlaywright; if (typeof closePageByTargetIdViaPlaywright === "function") { - // SSRF check runs inside connectBrowser on cache miss. await closePageByTargetIdViaPlaywright({ cdpUrl: profile.cdpUrl, targetId: resolvedTargetId, - ssrfPolicy: state().resolved.ssrfPolicy, }); return; } } - await assertProfileCdpEndpointAllowed(); - await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`)); + await fetchOk( + appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`), + undefined, + undefined, + getSsrFPolicy(), + ); }; return {
extensions/browser/src/browser/server-context.tab-ops.ts+24 −23 modified@@ -1,10 +1,5 @@ import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; -import { - assertCdpEndpointAllowed, - fetchJson, - fetchOk, - normalizeCdpHttpBaseForJsonEndpoints, -} from "./cdp.helpers.js"; +import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import { listChromeMcpTabs, openChromeMcpTab } from "./chrome-mcp.js"; import type { ResolvedBrowserProfile } from "./config.js"; @@ -69,9 +64,7 @@ export function createProfileTabOps({ }: TabOpsDeps): ProfileTabOps { const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl); const capabilities = getBrowserProfileCapabilities(profile); - const assertProfileCdpEndpointAllowed = async (): Promise<void> => { - await assertCdpEndpointAllowed(profile.cdpUrl, state().resolved.ssrfPolicy); - }; + const getSsrFPolicy = () => state().resolved.ssrfPolicy; const listTabs = async (): Promise<BrowserTab[]> => { if (capabilities.usesChromeMcp) { @@ -82,11 +75,7 @@ export function createProfileTabOps({ const mod = await getPwAiModule({ mode: "strict" }); const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") { - await assertProfileCdpEndpointAllowed(); - const pages = await listPagesViaPlaywright({ - cdpUrl: profile.cdpUrl, - ssrfPolicy: state().resolved.ssrfPolicy, - }); + const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl }); return pages.map((p) => ({ targetId: p.targetId, title: p.title, @@ -96,7 +85,6 @@ export function createProfileTabOps({ } } - await assertProfileCdpEndpointAllowed(); const raw = await fetchJson< Array<{ id?: string; @@ -105,7 +93,7 @@ export function createProfileTabOps({ webSocketDebuggerUrl?: string; type?: string; }> - >(appendCdpPath(cdpHttpBase, "/json/list")); + >(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getSsrFPolicy()); return raw .map((t) => ({ targetId: t.id ?? "", @@ -136,9 +124,13 @@ export function createProfileTabOps({ const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId); const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT; - await assertProfileCdpEndpointAllowed(); for (const tab of candidates.slice(0, excessCount)) { - void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => { + void fetchOk( + appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`), + undefined, + undefined, + getSsrFPolicy(), + ).catch(() => { // best-effort cleanup only }); } @@ -224,12 +216,21 @@ export function createProfileTabOps({ return endpointUrl.toString(); })() : `${endpointUrl.toString()}?${encoded}`; - await assertProfileCdpEndpointAllowed(); - const created = await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS, { - method: "PUT", - }).catch(async (err) => { + const created = await fetchJson<CdpTarget>( + endpoint, + CDP_JSON_NEW_TIMEOUT_MS, + { + method: "PUT", + }, + ssrfPolicyOpts.ssrfPolicy, + ).catch(async (err) => { if (String(err).includes("HTTP 405")) { - return await fetchJson<CdpTarget>(endpoint, CDP_JSON_NEW_TIMEOUT_MS); + return await fetchJson<CdpTarget>( + endpoint, + CDP_JSON_NEW_TIMEOUT_MS, + undefined, + ssrfPolicyOpts.ssrfPolicy, + ); } throw err; });
extensions/browser/src/browser/server-context.ts+1 −1 modified@@ -85,8 +85,8 @@ function createProfileContext( const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({ profile, - state, getProfileState, + getSsrFPolicy: () => state().resolved.ssrfPolicy, ensureBrowserAvailable, listTabs, openTab,
src/infra/net/ssrf.ts+3 −3 modified@@ -57,7 +57,7 @@ function normalizeHostnameSet(values?: string[]): Set<string> { return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean)); } -function normalizeHostnameAllowlist(values?: string[]): string[] { +export function normalizeHostnameAllowlist(values?: string[]): string[] { if (!values || values.length === 0) { return []; } @@ -87,7 +87,7 @@ function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseB }; } -function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { +export function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { if (pattern.startsWith("*.")) { const suffix = pattern.slice(2); if (!suffix || hostname === suffix) { @@ -98,7 +98,7 @@ function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean return hostname === pattern; } -function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolean { +export function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolean { if (allowlist.length === 0) { return true; }
src/plugin-sdk/browser-security-runtime.ts+2 −0 modified@@ -9,11 +9,13 @@ export { hasProxyEnvConfigured } from "../infra/net/proxy-env.js"; export { SsrFBlockedError, isBlockedHostnameOrIp, + matchesHostnameAllowlist, isPrivateNetworkAllowedByPolicy, resolvePinnedHostnameWithPolicy, type LookupFn, type SsrFPolicy, } from "../infra/net/ssrf.js"; +export { normalizeHostname } from "../infra/net/hostname.js"; export { isNotFoundPathError, isPathInside } from "../infra/path-guards.js"; export { ensurePortAvailable } from "../infra/ports.js"; export { generateSecureToken } from "../infra/secure-random.js";
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
6- github.com/openclaw/openclaw/commit/121c452d666d4749744dc2089287d0227aae2ed3nvdPatchWEB
- github.com/advisories/GHSA-xq94-r468-qwgjghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-xq94-r468-qwgjnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-43582ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-dns-rebinding-ssrf-via-hostname-validation-bypassnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/pull/64367ghsaWEB
News mentions
0No linked articles in our index yet.