VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.102026.4.10

Affected products

2
  • OpenClaw/OpenclawGHSA2 versions
    < 2026.4.10+ 1 more
    • (no CPE)range: < 2026.4.10
    • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*range: <2026.4.10

Patches

2
48c034792117

fix: in the browser extension s tabs action route the (#310) (#63332)

https://github.com/openclaw/openclawDevin RobisonApr 10, 2026via ghsa
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;
       };
     
    

Vulnerability 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

News mentions

0

No linked articles in our index yet.