High severity8.5GHSA Advisory· Published May 5, 2026· Updated May 7, 2026
CVE-2026-42439
CVE-2026-42439
Description
OpenClaw before 2026.4.10 contains a server-side request forgery policy bypass vulnerability in the browser tabs action select and close routes. Attackers can bypass configured browser SSRF policy protections by exploiting the /tabs/action endpoint to perform unauthorized tab navigation operations.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.10 | 2026.4.10 |
Affected products
2Patches
248c034792117fix: in the browser extension s tabs action route the (#310) (#63332)
14 files changed · +378 −36
CHANGELOG.md+1 −0 modified@@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai - Assistant text: strip Qwen-style XML tool call payloads from visible replies so web and channel messages no longer show raw `<tool_call><function=...>` output. (#64214) Thanks @MoerAI. - Daemon/gateway: prevent systemd restart storms on configuration errors by exiting with `EX_CONFIG` and adding generated unit restart-prevention guards. (#63913) Thanks @neo1027144-creator. - Agents/exec: prevent gateway crash ("Agent listener invoked outside active run") when a subagent exec tool produces stdout/stderr after the agent run has ended or been aborted. (#62821) Thanks @openperf. +- Browser/tabs: route `/tabs/action` close/select through the same browser endpoint reachability and policy checks as list/new (including Playwright-backed remote tab operations), reject CDP HTTP redirects on probe requests, and sanitize blocked-endpoint error responses so tab list/focus/close flows fail closed without echoing raw policy details back to callers. (#63332) ## 2026.4.9
extensions/browser/src/browser/cdp.helpers.test.ts+53 −0 added@@ -0,0 +1,53 @@ +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 { assertCdpEndpointAllowed, fetchCdpChecked } = await import("./cdp.helpers.js"); +const { BrowserCdpEndpointBlockedError } = await import("./errors.js"); + +describe("fetchCdpChecked", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + 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); + + await expect(fetchCdpChecked("https://browser.example/json/version", 50)).rejects.toThrow( + "CDP endpoint redirects are not allowed", + ); + + const init = fetchSpy.mock.calls[0]?.[1]; + expect(init?.redirect).toBe("manual"); + }); +}); + +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("does not wrap non-SSRF failures", async () => { + await expect( + assertCdpEndpointAllowed("file:///etc/passwd", { dangerouslyAllowPrivateNetwork: false }), + ).rejects.not.toBeInstanceOf(BrowserCdpEndpointBlockedError); + }); + + 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); + }); +});
extensions/browser/src/browser/cdp.helpers.ts+26 −5 modified@@ -1,11 +1,16 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js"; -import { type SsrFPolicy, resolvePinnedHostnameWithPolicy } from "../infra/net/ssrf.js"; +import { + SsrFBlockedError, + 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 }; @@ -62,9 +67,19 @@ export async function assertCdpEndpointAllowed( if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) { throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`); } - await resolvePinnedHostnameWithPolicy(parsed.hostname, { - policy: ssrfPolicy, - }); + 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; + } } export function redactCdpUrl(cdpUrl: string | null | undefined): string | null | undefined { @@ -231,9 +246,15 @@ export async function fetchCdpChecked( const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); 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 res = await withNoProxyForCdpUrl(url, () => - fetch(url, { ...init, headers, signal: ctrl.signal }), + fetch(url, { ...init, headers, redirect: "manual", signal: ctrl.signal }), ); + if (res.status >= 300 && res.status < 400) { + throw new Error("CDP endpoint redirects are not allowed"); + } if (!res.ok) { if (res.status === 429) { // Do not reflect upstream response text into the error surface (log/agent injection risk)
extensions/browser/src/browser/errors.test.ts+26 −1 modified@@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { BrowserValidationError, toBrowserErrorResponse } from "./errors.js"; +import { SsrFBlockedError } from "../infra/net/ssrf.js"; +import { + BROWSER_ENDPOINT_BLOCKED_MESSAGE, + BROWSER_NAVIGATION_BLOCKED_MESSAGE, + BrowserCdpEndpointBlockedError, + BrowserValidationError, + toBrowserErrorResponse, +} from "./errors.js"; describe("browser error mapping", () => { it("maps blocked browser targets to conflict responses", () => { @@ -20,4 +27,22 @@ describe("browser error mapping", () => { message: "bad input", }); }); + + it("sanitizes navigation-target SSRF policy errors without leaking raw policy details", () => { + expect( + toBrowserErrorResponse( + new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"), + ), + ).toEqual({ + status: 400, + message: BROWSER_NAVIGATION_BLOCKED_MESSAGE, + }); + }); + + it("maps CDP endpoint policy blocks to a distinct endpoint-scoped message", () => { + expect(toBrowserErrorResponse(new BrowserCdpEndpointBlockedError())).toEqual({ + status: 400, + message: BROWSER_ENDPOINT_BLOCKED_MESSAGE, + }); + }); });
extensions/browser/src/browser/errors.ts+21 −1 modified@@ -1,6 +1,9 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; +export const BROWSER_ENDPOINT_BLOCKED_MESSAGE = "browser endpoint blocked by policy"; +export const BROWSER_NAVIGATION_BLOCKED_MESSAGE = "browser navigation blocked by policy"; + export class BrowserError extends Error { status: number; @@ -11,6 +14,18 @@ export class BrowserError extends Error { } } +/** + * Raised when a browser CDP endpoint (the cdpUrl itself) fails the + * configured SSRF policy. Distinct from a blocked navigation target so + * callers see "fix your browser endpoint config" rather than "fix your + * navigation URL". + */ +export class BrowserCdpEndpointBlockedError extends BrowserError { + constructor(options?: ErrorOptions) { + super(BROWSER_ENDPOINT_BLOCKED_MESSAGE, 400, options); + } +} + export class BrowserValidationError extends BrowserError { constructor(message: string, options?: ErrorOptions) { super(message, 400, options); @@ -76,7 +91,12 @@ export function toBrowserErrorResponse(err: unknown): { return { status: 409, message: err.message }; } if (err instanceof SsrFBlockedError) { - return { status: 400, message: err.message }; + // SsrFBlockedError from this point is from a navigation-target check + // (assertBrowserNavigationAllowed / resolvePinnedHostnameWithPolicy on a + // requested URL). CDP endpoint blocks are rethrown as + // BrowserCdpEndpointBlockedError by assertCdpEndpointAllowed and handled + // by the BrowserError branch above. + return { status: 400, message: BROWSER_NAVIGATION_BLOCKED_MESSAGE }; } if ( err instanceof InvalidBrowserNavigationUrlError ||
extensions/browser/src/browser/pw-session.ts+30 −11 modified@@ -14,6 +14,7 @@ import { SsrFBlockedError, type SsrFPolicy } from "../infra/net/ssrf.js"; import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { appendCdpPath, + assertCdpEndpointAllowed, fetchJson, getHeadersWithAuth, normalizeCdpHttpBaseForJsonEndpoints, @@ -424,12 +425,15 @@ function observeBrowser(browser: Browser) { } } -async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> { +async function connectBrowser(cdpUrl: string, ssrfPolicy?: SsrFPolicy): Promise<ConnectedBrowser> { const normalized = normalizeCdpUrl(cdpUrl); const cached = cachedByCdpUrl.get(normalized); if (cached) { return cached; } + // Run SSRF policy check only on cache miss so transient DNS failures + // do not break active sessions that already hold a live CDP connection. + await assertCdpEndpointAllowed(normalized, ssrfPolicy); const connecting = connectingByCdpUrl.get(normalized); if (connecting) { return await connecting; @@ -440,7 +444,9 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> { for (let attempt = 0; attempt < 3; attempt += 1) { try { const timeout = 5000 + attempt * 2000; - const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null); + const wsUrl = await getChromeWebSocketUrl(normalized, timeout, ssrfPolicy).catch( + () => null, + ); const endpoint = wsUrl ?? normalized; const headers = getHeadersWithAuth(endpoint); // Bypass proxy for loopback CDP connections (#31219) @@ -562,8 +568,10 @@ async function findPageByTargetIdViaTargetList( pages: Page[], targetId: string, cdpUrl: string, + ssrfPolicy?: SsrFPolicy, ): Promise<Page | null> { const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl); + await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy); const targets = await fetchJson< Array<{ id: string; @@ -578,6 +586,7 @@ async function findPageByTargetId( browser: Browser, targetId: string, cdpUrl?: string, + ssrfPolicy?: SsrFPolicy, ): Promise<Page | null> { const pages = await getAllPages(browser); let resolvedViaCdp = false; @@ -595,7 +604,7 @@ async function findPageByTargetId( } if (cdpUrl) { try { - return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl); + return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl, ssrfPolicy); } catch { // Ignore fetch errors and fall through to return null. } @@ -609,12 +618,13 @@ async function findPageByTargetId( async function resolvePageByTargetIdOrThrow(opts: { cdpUrl: string; targetId: string; + ssrfPolicy?: SsrFPolicy; }): Promise<Page> { if (isBlockedTarget(opts.cdpUrl, opts.targetId)) { throw new BlockedBrowserTargetError(); } - const { browser } = await connectBrowser(opts.cdpUrl); - const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); + const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy); + const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl, opts.ssrfPolicy); if (!page) { throw new BrowserTabNotFoundError(); } @@ -624,11 +634,12 @@ async function resolvePageByTargetIdOrThrow(opts: { export async function getPageForTargetId(opts: { cdpUrl: string; targetId?: string; + ssrfPolicy?: SsrFPolicy; }): Promise<Page> { if (opts.targetId && isBlockedTarget(opts.cdpUrl, opts.targetId)) { throw new BlockedBrowserTargetError(); } - const { browser } = await connectBrowser(opts.cdpUrl); + const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy); const pages = await getAllPages(browser); if (!pages.length) { throw new Error("No pages available in the connected browser."); @@ -648,7 +659,7 @@ export async function getPageForTargetId(opts: { if (!opts.targetId) { return first; } - const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); + const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl, opts.ssrfPolicy); if (found) { if (isBlockedPageRef(opts.cdpUrl, found)) { throw new BlockedBrowserTargetError(); @@ -887,7 +898,9 @@ function cdpSocketNeedsAttach(wsUrl: string): boolean { async function tryTerminateExecutionViaCdp(opts: { cdpUrl: string; targetId: string; + ssrfPolicy?: SsrFPolicy; }): Promise<void> { + await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy); const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(opts.cdpUrl); const listUrl = appendCdpPath(cdpHttpBase, "/json/list"); @@ -976,6 +989,7 @@ export async function forceDisconnectPlaywrightForTarget(opts: { cdpUrl: string; targetId?: string; reason?: string; + ssrfPolicy?: SsrFPolicy; }): Promise<void> { const normalized = normalizeCdpUrl(opts.cdpUrl); const cur = cachedByCdpUrl.get(normalized); @@ -996,7 +1010,7 @@ export async function forceDisconnectPlaywrightForTarget(opts: { // disconnect Playwright's CDP connection. const targetId = normalizeOptionalString(opts.targetId) ?? ""; if (targetId) { - await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId }).catch(() => {}); + await tryTerminateExecutionViaCdp({ cdpUrl: normalized, targetId, ssrfPolicy: opts.ssrfPolicy }).catch(() => {}); } // Fire-and-forget: don't await because browser.close() may hang on the stuck CDP pipe. @@ -1007,15 +1021,18 @@ export async function forceDisconnectPlaywrightForTarget(opts: { * List all pages/tabs from the persistent Playwright connection. * Used for remote profiles where HTTP-based /json/list is ephemeral. */ -export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise< +export async function listPagesViaPlaywright(opts: { + cdpUrl: string; + ssrfPolicy?: SsrFPolicy; +}): Promise< Array<{ targetId: string; title: string; url: string; type: string; }> > { - const { browser } = await connectBrowser(opts.cdpUrl); + const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy); const pages = await getAllPages(browser); const results: Array<{ targetId: string; @@ -1056,7 +1073,7 @@ export async function createPageViaPlaywright(opts: { url: string; type: string; }> { - const { browser } = await connectBrowser(opts.cdpUrl); + const { browser } = await connectBrowser(opts.cdpUrl, opts.ssrfPolicy); const context = browser.contexts()[0] ?? (await browser.newContext()); ensureContextState(context); @@ -1119,6 +1136,7 @@ export async function createPageViaPlaywright(opts: { export async function closePageByTargetIdViaPlaywright(opts: { cdpUrl: string; targetId: string; + ssrfPolicy?: SsrFPolicy; }): Promise<void> { const page = await resolvePageByTargetIdOrThrow(opts); await page.close(); @@ -1131,6 +1149,7 @@ export async function closePageByTargetIdViaPlaywright(opts: { export async function focusPageByTargetIdViaPlaywright(opts: { cdpUrl: string; targetId: string; + ssrfPolicy?: SsrFPolicy; }): Promise<void> { const page = await resolvePageByTargetIdOrThrow(opts); try {
extensions/browser/src/browser/routes/tabs.test.ts+112 −0 added@@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; +import { registerBrowserTabRoutes } from "./tabs.js"; +import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js"; + +function createProfileContext(overrides?: Partial<ReturnType<typeof baseProfileContext>>) { + return { + ...baseProfileContext(), + ...overrides, + }; +} + +function baseProfileContext() { + return { + profile: { + name: "openclaw", + }, + ensureBrowserAvailable: vi.fn(async () => {}), + ensureTabAvailable: vi.fn(async () => ({ + targetId: "T1", + title: "Tab 1", + url: "https://example.com", + type: "page", + })), + isHttpReachable: vi.fn(async () => true), + isReachable: vi.fn(async () => true), + listTabs: vi.fn(async () => [ + { + targetId: "T1", + title: "Tab 1", + url: "https://example.com", + type: "page", + }, + ]), + openTab: vi.fn(async () => ({ + targetId: "T1", + title: "Tab 1", + url: "https://example.com", + type: "page", + })), + focusTab: vi.fn(async () => {}), + closeTab: vi.fn(async () => {}), + stopRunningBrowser: vi.fn(async () => ({ stopped: false })), + resetProfile: vi.fn(async () => ({ moved: false, from: "" })), + }; +} + +function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>) { + return { + state: () => ({ resolved: { ssrfPolicy: undefined } }), + forProfile: () => profileCtx, + listProfiles: vi.fn(async () => []), + mapTabError: vi.fn(() => null), + ensureBrowserAvailable: profileCtx.ensureBrowserAvailable, + ensureTabAvailable: profileCtx.ensureTabAvailable, + isHttpReachable: profileCtx.isHttpReachable, + isReachable: profileCtx.isReachable, + listTabs: profileCtx.listTabs, + openTab: profileCtx.openTab, + focusTab: profileCtx.focusTab, + closeTab: profileCtx.closeTab, + stopRunningBrowser: profileCtx.stopRunningBrowser, + resetProfile: profileCtx.resetProfile, + }; +} + +async function callTabsAction(params: { + body: Record<string, unknown>; + profileCtx: ReturnType<typeof createProfileContext>; +}) { + const { app, postHandlers } = createBrowserRouteApp(); + registerBrowserTabRoutes(app, createRouteContext(params.profileCtx) as never); + const handler = postHandlers.get("/tabs/action"); + expect(handler).toBeTypeOf("function"); + + const response = createBrowserRouteResponse(); + await handler?.({ params: {}, query: {}, body: params.body }, response.res); + return response; +} + +describe("browser tab routes", () => { + it("returns browser-not-running for close when the browser is not reachable", async () => { + const profileCtx = createProfileContext({ + isReachable: vi.fn(async () => false), + }); + + const response = await callTabsAction({ + body: { action: "close", index: 0 }, + profileCtx, + }); + + expect(response.statusCode).toBe(409); + expect(response.body).toEqual({ error: "browser not running" }); + expect(profileCtx.listTabs).not.toHaveBeenCalled(); + expect(profileCtx.closeTab).not.toHaveBeenCalled(); + }); + + it("returns browser-not-running for select when the browser is not reachable", async () => { + const profileCtx = createProfileContext({ + isReachable: vi.fn(async () => false), + }); + + const response = await callTabsAction({ + body: { action: "select", index: 0 }, + profileCtx, + }); + + expect(response.statusCode).toBe(409); + expect(response.body).toEqual({ error: "browser not running" }); + expect(profileCtx.listTabs).not.toHaveBeenCalled(); + expect(profileCtx.focusTab).not.toHaveBeenCalled(); + }); +});
extensions/browser/src/browser/routes/tabs.ts+6 −0 modified@@ -201,6 +201,9 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse } if (action === "close") { + if (!(await ensureBrowserRunning(profileCtx, res))) { + return; + } const tabs = await profileCtx.listTabs(); const target = resolveIndexedTab(tabs, index); if (!target) { @@ -214,6 +217,9 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse if (typeof index !== "number") { return jsonError(res, 400, "index is required"); } + if (!(await ensureBrowserRunning(profileCtx, res))) { + return; + } const tabs = await profileCtx.listTabs(); const target = tabs[index]; if (!target) {
extensions/browser/src/browser/server-context.loopback-direct-ws.test.ts+22 −0 modified@@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { withFetchPreconnect } from "../../test-support.js"; import * as cdpModule from "./cdp.js"; +import { BrowserCdpEndpointBlockedError } from "./errors.js"; import { createBrowserRouteContext } from "./server-context.js"; import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js"; @@ -139,4 +140,25 @@ describe("browser server-context loopback direct WebSocket profiles", () => { await openclaw.focusTab("T2"); await openclaw.closeTab("T2"); }); + + it("blocks direct WebSocket tab operations when strict SSRF policy rejects the cdpUrl", async () => { + const fetchMock = vi.fn(async () => { + throw new Error("unexpected fetch"); + }); + + global.fetch = withFetchPreconnect(fetchMock); + const state = makeState("openclaw"); + state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false }; + state.resolved.profiles.openclaw = { + cdpUrl: "ws://10.0.0.42:18800/devtools/browser/SESSION?token=abc", + color: "#FF4500", + }; + const ctx = createBrowserRouteContext({ getState: () => state }); + const openclaw = ctx.forProfile("openclaw"); + + await expect(openclaw.listTabs()).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); + await expect(openclaw.focusTab("T1")).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); + await expect(openclaw.closeTab("T1")).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); + expect(fetchMock).not.toHaveBeenCalled(); + }); });
extensions/browser/src/browser/server-context.remote-profile-tab-ops.playwright.test.ts+37 −3 modified@@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { BrowserCdpEndpointBlockedError } from "./errors.js"; import { installRemoteProfileTestLifecycle, loadRemoteProfileTestDeps, @@ -36,15 +37,16 @@ describe("browser remote profile tab ops via Playwright", () => { expect(opened.targetId).toBe("T2"); expect(state.profiles.get("remote")?.lastTargetId).toBe("T2"); expect(createPageViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: "https://browserless.example/chrome?token=abc", + cdpUrl: "https://1.1.1.1:9222/chrome?token=abc", url: "http://127.0.0.1:3000", ssrfPolicy: { allowPrivateNetwork: true }, }); await remote.closeTab("T1"); expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: "https://browserless.example/chrome?token=abc", + cdpUrl: "https://1.1.1.1:9222/chrome?token=abc", targetId: "T1", + ssrfPolicy: { allowPrivateNetwork: true }, }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -140,13 +142,45 @@ describe("browser remote profile tab ops via Playwright", () => { await remote.focusTab("T1"); expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith({ - cdpUrl: "https://browserless.example/chrome?token=abc", + cdpUrl: "https://1.1.1.1:9222/chrome?token=abc", targetId: "T1", + ssrfPolicy: { allowPrivateNetwork: true }, }); expect(fetchMock).not.toHaveBeenCalled(); expect(state.profiles.get("remote")?.lastTargetId).toBe("T1"); }); + it("blocks remote Playwright tab operations when strict SSRF policy rejects the cdpUrl", async () => { + const listPagesViaPlaywright = vi.fn(async () => [ + { targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" }, + ]); + const focusPageByTargetIdViaPlaywright = vi.fn(async () => {}); + const closePageByTargetIdViaPlaywright = vi.fn(async () => {}); + + vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue({ + listPagesViaPlaywright, + focusPageByTargetIdViaPlaywright, + closePageByTargetIdViaPlaywright, + } as unknown as Awaited<ReturnType<typeof deps.pwAiModule.getPwAiModule>>); + + const state = deps.makeState("remote"); + state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false }; + state.resolved.profiles.remote = { + ...state.resolved.profiles.remote, + cdpUrl: "http://10.0.0.42:9222", + cdpPort: 9222, + }; + const ctx = deps.createBrowserRouteContext({ getState: () => state }); + const remote = ctx.forProfile("remote"); + + await expect(remote.listTabs()).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); + await expect(remote.focusTab("T1")).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); + await expect(remote.closeTab("T1")).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError); + expect(listPagesViaPlaywright).not.toHaveBeenCalled(); + expect(focusPageByTargetIdViaPlaywright).not.toHaveBeenCalled(); + expect(closePageByTargetIdViaPlaywright).not.toHaveBeenCalled(); + }); + it("does not swallow Playwright runtime errors for remote profiles", async () => { vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue({ listPagesViaPlaywright: vi.fn(async () => {
extensions/browser/src/browser/server-context.remote-tab-ops.harness.ts+3 −3 modified@@ -17,7 +17,7 @@ export function makeState( cdpPortRangeStart: 18800, cdpPortRangeEnd: 18899, cdpProtocol: profile === "remote" ? "https" : "http", - cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1", + cdpHost: profile === "remote" ? "1.1.1.1" : "127.0.0.1", cdpIsLoopback: profile !== "remote", remoteCdpTimeoutMs: 1500, remoteCdpHandshakeTimeoutMs: 3000, @@ -31,8 +31,8 @@ export function makeState( defaultProfile: profile, profiles: { remote: { - cdpUrl: "https://browserless.example/chrome?token=abc", - cdpPort: 443, + cdpUrl: "https://1.1.1.1:9222/chrome?token=abc", + cdpPort: 9222, color: "#00AA00", }, openclaw: { cdpPort: 18800, color: "#FF4500" },
extensions/browser/src/browser/server-context.selection.ts+21 −2 modified@@ -1,17 +1,26 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; +import { + assertCdpEndpointAllowed, + 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 { BrowserTab, ProfileRuntimeState } from "./server-context.types.js"; +import type { + BrowserServerState, + BrowserTab, + ProfileRuntimeState, +} from "./server-context.types.js"; import { resolveTargetIdFromTabs } from "./target-id.js"; type SelectionDeps = { profile: ResolvedBrowserProfile; + state: () => BrowserServerState; getProfileState: () => ProfileRuntimeState; ensureBrowserAvailable: () => Promise<void>; listTabs: () => Promise<BrowserTab[]>; @@ -26,13 +35,17 @@ type SelectionOps = { export function createProfileSelectionOps({ profile, + state, getProfileState, 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 ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => { await ensureBrowserAvailable(); @@ -106,16 +119,19 @@ 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}`)); const profileState = getProfileState(); profileState.lastTargetId = resolvedTargetId; @@ -135,14 +151,17 @@ 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}`)); };
extensions/browser/src/browser/server-context.tab-ops.ts+19 −2 modified@@ -1,5 +1,10 @@ import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js"; -import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js"; +import { + assertCdpEndpointAllowed, + 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"; @@ -64,6 +69,9 @@ 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 listTabs = async (): Promise<BrowserTab[]> => { if (capabilities.usesChromeMcp) { @@ -74,7 +82,13 @@ export function createProfileTabOps({ const mod = await getPwAiModule({ mode: "strict" }); const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright; if (typeof listPagesViaPlaywright === "function") { - const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl }); + // SSRF check runs inside connectBrowser on cache miss; skip the + // redundant pre-call DNS lookup so cached sessions are not broken + // by transient resolver failures. + const pages = await listPagesViaPlaywright({ + cdpUrl: profile.cdpUrl, + ssrfPolicy: state().resolved.ssrfPolicy, + }); return pages.map((p) => ({ targetId: p.targetId, title: p.title, @@ -84,6 +98,7 @@ export function createProfileTabOps({ } } + await assertProfileCdpEndpointAllowed(); const raw = await fetchJson< Array<{ id?: string; @@ -123,6 +138,7 @@ 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(() => { // best-effort cleanup only @@ -210,6 +226,7 @@ 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) => {
extensions/browser/src/browser/server-context.ts+1 −8 modified@@ -1,9 +1,7 @@ -import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js"; import type { ResolvedBrowserProfile } from "./config.js"; import { resolveProfile } from "./config.js"; import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js"; -import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; import { getBrowserProfileCapabilities } from "./profile-capabilities.js"; import { refreshResolvedBrowserConfigFromDisk, @@ -87,6 +85,7 @@ function createProfileContext( const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({ profile, + state, getProfileState, ensureBrowserAvailable, listTabs, @@ -229,12 +228,6 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon if (browserMapped) { return browserMapped; } - if (err instanceof SsrFBlockedError) { - return { status: 400, message: err.message }; - } - if (err instanceof InvalidBrowserNavigationUrlError) { - return { status: 400, message: err.message }; - } return null; };
48c0347921b7Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/openclaw/openclaw/commit/48c0347921b7e9438af0312968fc360ca88023f3nvdPatchWEB
- github.com/advisories/GHSA-rj2p-j66c-mgqhghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-rj2p-j66c-mgqhnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-42439ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-ssrf-policy-bypass-in-browser-tabs-action-routesnvdThird Party AdvisoryWEB
- github.com/openclaw/openclaw/commit/48c03479211799ec3c1305ad69037cea25ba0e1eghsaWEB
- github.com/openclaw/openclaw/pull/63332ghsaWEB
News mentions
0No linked articles in our index yet.