VYPR
High severity7.7GHSA Advisory· Published May 5, 2026· Updated May 5, 2026

CVE-2026-42436

CVE-2026-42436

Description

OpenClaw before 2026.4.14 contains an improper access control vulnerability in browser snapshot, screenshot, and tab routes that fail to consistently validate the final browser target after navigation. Authenticated callers can bypass SSRF restrictions to expose internal or disallowed page content by exploiting route-driven navigation without proper policy re-validation.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.142026.4.14

Affected products

1

Patches

1
b75ad800a590

fix(browser): enforce SSRF policy on snapshot, screenshot, and tab routes [AI] (#66040)

https://github.com/openclaw/openclawPavan Kumar GondhiApr 13, 2026via ghsa
8 files changed · +585 19
  • CHANGELOG.md+1 0 modified
    @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
     
     ### Fixes
     
    +- fix(browser): enforce SSRF policy on snapshot, screenshot, and tab routes [AI]. (#66040) Thanks @pgondhi987.
     - fix(msteams): enforce sender allowlist checks on SSO signin invokes [AI]. (#66033) Thanks @pgondhi987.
     - fix(config): redact sourceConfig and runtimeConfig alias fields in redactConfigSnapshot [AI]. (#66030) Thanks @pgondhi987.
     - Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) thanks @100yenadmin
    
  • extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts+89 2 modified
    @@ -1,5 +1,8 @@
     import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
    -import { createExistingSessionAgentSharedModule } from "./existing-session.test-support.js";
    +import {
    +  createExistingSessionAgentSharedModule,
    +  existingSessionRouteState,
    +} from "./existing-session.test-support.js";
     import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
     
     const chromeMcpMocks = vi.hoisted(() => ({
    @@ -14,7 +17,9 @@ const chromeMcpMocks = vi.hoisted(() => ({
     
     const navigationGuardMocks = vi.hoisted(() => ({
       assertBrowserNavigationAllowed: vi.fn(async () => {}),
    -  assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
    +  assertBrowserNavigationResultAllowed: vi.fn(
    +    async (_opts?: { url: string; ssrfPolicy?: unknown }) => {},
    +  ),
       withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
     }));
     
    @@ -37,6 +42,7 @@ vi.mock("./agent.shared.js", () => createExistingSessionAgentSharedModule());
     const DEFAULT_SSRF_POLICY = { allowPrivateNetwork: false } as const;
     
     const { registerBrowserAgentActRoutes } = await import("./agent.act.js");
    +const routeState = existingSessionRouteState;
     
     function getActPostHandler(
       ssrfPolicy: { allowPrivateNetwork: false } | null = DEFAULT_SSRF_POLICY,
    @@ -65,6 +71,13 @@ describe("existing-session interaction navigation guard", () => {
           fn.mockClear();
         }
         chromeMcpMocks.evaluateChromeMcpScript.mockResolvedValue("https://example.com");
    +    routeState.profileCtx.listTabs.mockReset();
    +    routeState.profileCtx.listTabs.mockResolvedValue([
    +      {
    +        targetId: "7",
    +        url: "https://example.com",
    +      },
    +    ]);
       });
     
       afterEach(() => {
    @@ -144,6 +157,79 @@ describe("existing-session interaction navigation guard", () => {
         ]);
       });
     
    +  it("checks URLs for tabs opened during the interaction window", async () => {
    +    routeState.profileCtx.listTabs
    +      .mockResolvedValueOnce([
    +        {
    +          targetId: "7",
    +          url: "https://example.com",
    +        },
    +      ])
    +      .mockResolvedValueOnce([
    +        {
    +          targetId: "7",
    +          url: "https://example.com",
    +        },
    +        {
    +          targetId: "9",
    +          url: "http://169.254.169.254/latest/meta-data/",
    +        },
    +      ]);
    +
    +    const response = await runAction({ kind: "click", ref: "btn-1" });
    +
    +    expect(response.statusCode).toBe(200);
    +    expect(chromeMcpMocks.clickChromeMcpElement).toHaveBeenCalledOnce();
    +    expectNavigationProbeUrls([
    +      "https://example.com",
    +      "https://example.com",
    +      "https://example.com",
    +      "http://169.254.169.254/latest/meta-data/",
    +    ]);
    +  });
    +
    +  it("fails closed when a newly opened tab URL is blocked", async () => {
    +    routeState.profileCtx.listTabs
    +      .mockResolvedValueOnce([
    +        {
    +          targetId: "7",
    +          url: "https://example.com",
    +        },
    +      ])
    +      .mockResolvedValueOnce([
    +        {
    +          targetId: "7",
    +          url: "https://example.com",
    +        },
    +        {
    +          targetId: "9",
    +          url: "http://169.254.169.254/latest/meta-data/",
    +        },
    +      ]);
    +    navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
    +      async (opts?: { url: string }) => {
    +        const url = opts?.url ?? "";
    +        if (url.includes("169.254.169.254")) {
    +          throw new Error("blocked new tab");
    +        }
    +      },
    +    );
    +
    +    const handler = getActPostHandler();
    +    const response = createBrowserRouteResponse();
    +    const pending =
    +      handler?.({ params: {}, query: {}, body: { kind: "click", ref: "btn-1" } }, response.res) ??
    +      Promise.resolve();
    +    void pending.catch(() => {});
    +    const completion = (async () => {
    +      await vi.runAllTimersAsync();
    +      await pending;
    +    })();
    +
    +    await expect(completion).rejects.toThrow("blocked new tab");
    +    expect(chromeMcpMocks.clickChromeMcpElement).toHaveBeenCalledOnce();
    +  });
    +
       it("fails closed when location probes never return a usable url", async () => {
         chromeMcpMocks.evaluateChromeMcpScript
           .mockResolvedValueOnce("result" as never)
    @@ -243,6 +329,7 @@ describe("existing-session interaction navigation guard", () => {
         expect(response.statusCode).toBe(200);
         expect(chromeMcpMocks.pressChromeMcpKey).toHaveBeenCalledOnce();
         expect(chromeMcpMocks.evaluateChromeMcpScript).not.toHaveBeenCalled();
    +    expect(routeState.profileCtx.listTabs).not.toHaveBeenCalled();
         expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
       });
     
    
  • extensions/browser/src/browser/routes/agent.act.ts+24 0 modified
    @@ -71,11 +71,28 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
       userDataDir?: string;
       targetId: string;
       ssrfPolicy?: BrowserNavigationPolicyOptions["ssrfPolicy"];
    +  listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
    +  initialTabTargetIds: ReadonlySet<string>;
     }): Promise<void> {
       const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
       if (!ssrfPolicyOpts.ssrfPolicy) {
         return;
       }
    +  const listTabs = params.listTabs;
    +  const initialTabTargetIds = params.initialTabTargetIds;
    +
    +  const assertNewTabsAllowed = async () => {
    +    const tabs = await listTabs();
    +    for (const tab of tabs) {
    +      if (initialTabTargetIds.has(tab.targetId)) {
    +        continue;
    +      }
    +      await assertBrowserNavigationResultAllowed({
    +        url: tab.url,
    +        ...ssrfPolicyOpts,
    +      });
    +    }
    +  };
     
       let lastObservedUrl: string | undefined;
       let sawStableAllowedUrl = false;
    @@ -103,6 +120,7 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
       }
     
       if (sawStableAllowedUrl) {
    +    await assertNewTabsAllowed();
         return;
       }
     
    @@ -122,6 +140,7 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
             ...ssrfPolicyOpts,
           });
           if (followUpUrl === lastObservedUrl) {
    +        await assertNewTabsAllowed();
             return;
           }
         } catch {
    @@ -368,11 +387,16 @@ export function registerBrowserAgentActRoutes(
             const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
             const profileName = profileCtx.profile.name;
             if (isExistingSession) {
    +          const initialTabTargetIds = withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy
    +            ? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId))
    +            : new Set<string>();
               const existingSessionNavigationGuard = {
                 profileName,
                 userDataDir: profileCtx.profile.userDataDir,
                 targetId: tab.targetId,
                 ssrfPolicy,
    +            listTabs: () => profileCtx.listTabs(),
    +            initialTabTargetIds,
               };
               const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
               if (unsupportedMessage) {
    
  • extensions/browser/src/browser/routes/agent.existing-session.test.ts+50 7 modified
    @@ -21,6 +21,12 @@ const chromeMcpMocks = vi.hoisted(() => ({
       })),
     }));
     
    +const navigationGuardMocks = vi.hoisted(() => ({
    +  assertBrowserNavigationAllowed: vi.fn(async () => {}),
    +  assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
    +  withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
    +}));
    +
     vi.mock("../chrome-mcp.js", () => ({
       clickChromeMcpElement: vi.fn(async () => {}),
       closeChromeMcpTab: vi.fn(async () => {}),
    @@ -42,9 +48,9 @@ vi.mock("../cdp.js", () => ({
     }));
     
     vi.mock("../navigation-guard.js", () => ({
    -  assertBrowserNavigationAllowed: vi.fn(async () => {}),
    -  assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
    -  withBrowserNavigationPolicy: vi.fn(() => ({})),
    +  assertBrowserNavigationAllowed: navigationGuardMocks.assertBrowserNavigationAllowed,
    +  assertBrowserNavigationResultAllowed: navigationGuardMocks.assertBrowserNavigationResultAllowed,
    +  withBrowserNavigationPolicy: navigationGuardMocks.withBrowserNavigationPolicy,
     }));
     
     vi.mock("../screenshot.js", () => ({
    @@ -66,20 +72,20 @@ vi.mock("./agent.shared.js", () => createExistingSessionAgentSharedModule());
     const { registerBrowserAgentActRoutes } = await import("./agent.act.js");
     const { registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js");
     
    -function getSnapshotGetHandler() {
    +function getSnapshotGetHandler(ssrfPolicy?: unknown) {
       const { app, getHandlers } = createBrowserRouteApp();
       registerBrowserAgentSnapshotRoutes(app, {
    -    state: () => ({ resolved: { ssrfPolicy: undefined } }),
    +    state: () => ({ resolved: { ssrfPolicy } }),
       } as never);
       const handler = getHandlers.get("/snapshot");
       expect(handler).toBeTypeOf("function");
       return handler;
     }
     
    -function getSnapshotPostHandler() {
    +function getSnapshotPostHandler(ssrfPolicy?: unknown) {
       const { app, postHandlers } = createBrowserRouteApp();
       registerBrowserAgentSnapshotRoutes(app, {
    -    state: () => ({ resolved: { ssrfPolicy: undefined } }),
    +    state: () => ({ resolved: { ssrfPolicy } }),
       } as never);
       const handler = postHandlers.get("/screenshot");
       expect(handler).toBeTypeOf("function");
    @@ -99,10 +105,14 @@ function getActPostHandler() {
     describe("existing-session browser routes", () => {
       beforeEach(() => {
         routeState.profileCtx.ensureTabAvailable.mockClear();
    +    routeState.profileCtx.listTabs.mockClear();
         chromeMcpMocks.evaluateChromeMcpScript.mockReset();
         chromeMcpMocks.navigateChromeMcpPage.mockClear();
         chromeMcpMocks.takeChromeMcpScreenshot.mockClear();
         chromeMcpMocks.takeChromeMcpSnapshot.mockClear();
    +    navigationGuardMocks.assertBrowserNavigationAllowed.mockClear();
    +    navigationGuardMocks.assertBrowserNavigationResultAllowed.mockClear();
    +    navigationGuardMocks.withBrowserNavigationPolicy.mockClear();
         chromeMcpMocks.evaluateChromeMcpScript
           .mockResolvedValueOnce({ labels: 1, skipped: 0 } as never)
           .mockResolvedValueOnce(true);
    @@ -125,6 +135,7 @@ describe("existing-session browser routes", () => {
           profileName: "chrome-live",
           targetId: "7",
         });
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
         expect(chromeMcpMocks.takeChromeMcpScreenshot).toHaveBeenCalled();
       });
     
    @@ -153,6 +164,38 @@ describe("existing-session browser routes", () => {
           fullPage: false,
           format: "jpeg",
         });
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
    +  });
    +
    +  it("checks existing-session snapshot URL when SSRF policy is configured", async () => {
    +    const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
    +    const response = createBrowserRouteResponse();
    +    await handler?.({ params: {}, query: { format: "ai" } }, response.res);
    +
    +    expect(response.statusCode).toBe(200);
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
    +      url: "https://example.com",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +  });
    +
    +  it("checks existing-session screenshot URL when SSRF policy is configured", async () => {
    +    const handler = getSnapshotPostHandler({ allowPrivateNetwork: false });
    +    const response = createBrowserRouteResponse();
    +    await handler?.(
    +      {
    +        params: {},
    +        query: {},
    +        body: { ref: "btn-1", type: "jpeg" },
    +      },
    +      response.res,
    +    );
    +
    +    expect(response.statusCode).toBe(200);
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
    +      url: "https://example.com",
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
       });
     
       it("rejects selector-based element screenshots for existing-session profiles", async () => {
    
  • extensions/browser/src/browser/routes/agent.snapshot.ts+14 0 modified
    @@ -315,9 +315,16 @@ export function registerBrowserAgentSnapshotRoutes(
           targetId,
           run: async ({ profileCtx, tab, cdpUrl }) => {
             if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
    +          const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
               if (element) {
                 return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
               }
    +          if (ssrfPolicyOpts.ssrfPolicy) {
    +            await assertBrowserNavigationResultAllowed({
    +              url: tab.url,
    +              ...ssrfPolicyOpts,
    +            });
    +          }
               const buffer = await takeChromeMcpScreenshot({
                 profileName: profileCtx.profile.name,
                 userDataDir: profileCtx.profile.userDataDir,
    @@ -396,9 +403,16 @@ export function registerBrowserAgentSnapshotRoutes(
             return jsonError(res, 400, "labels/mode=efficient require format=ai");
           }
           if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
    +        const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
             if (plan.selectorValue || plan.frameSelectorValue) {
               return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
             }
    +        if (ssrfPolicyOpts.ssrfPolicy) {
    +          await assertBrowserNavigationResultAllowed({
    +            url: tab.url,
    +            ...ssrfPolicyOpts,
    +          });
    +        }
             const snapshot = await takeChromeMcpSnapshot({
               profileName: profileCtx.profile.name,
               userDataDir: profileCtx.profile.userDataDir,
    
  • extensions/browser/src/browser/routes/existing-session.test-support.ts+6 0 modified
    @@ -7,6 +7,12 @@ export const existingSessionRouteState = {
           driver: "existing-session" as const,
           name: "chrome-live",
         },
    +    listTabs: vi.fn(async () => [
    +      {
    +        targetId: "7",
    +        url: "https://example.com",
    +      },
    +    ]),
         ensureTabAvailable: vi.fn(async () => ({
           targetId: "7",
           url: "https://example.com",
    
  • extensions/browser/src/browser/routes/tabs.test.ts+331 6 modified
    @@ -1,7 +1,18 @@
    -import { describe, expect, it, vi } from "vitest";
    -import { registerBrowserTabRoutes } from "./tabs.js";
    +import { beforeEach, describe, expect, it, vi } from "vitest";
     import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
     
    +const navigationGuardMocks = vi.hoisted(() => ({
    +  assertBrowserNavigationAllowed: vi.fn(async () => {}),
    +  assertBrowserNavigationResultAllowed: vi.fn(
    +    async (_opts?: { url: string; ssrfPolicy?: unknown }) => {},
    +  ),
    +  withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
    +}));
    +
    +vi.mock("../navigation-guard.js", () => navigationGuardMocks);
    +
    +const { registerBrowserTabRoutes } = await import("./tabs.js");
    +
     function createProfileContext(overrides?: Partial<ReturnType<typeof baseProfileContext>>) {
       return {
         ...baseProfileContext(),
    @@ -44,12 +55,21 @@ function baseProfileContext() {
       };
     }
     
    -function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>) {
    +function createRouteContext(
    +  profileCtx: ReturnType<typeof createProfileContext>,
    +  options?: { ssrfPolicy?: unknown },
    +) {
       return {
    -    state: () => ({ resolved: { ssrfPolicy: undefined } }),
    +    state: () => ({ resolved: { ssrfPolicy: options?.ssrfPolicy } }),
         forProfile: () => profileCtx,
         listProfiles: vi.fn(async () => []),
    -    mapTabError: vi.fn(() => null),
    +    mapTabError: vi.fn((err: unknown) => {
    +      if (!(err instanceof Error)) {
    +        return null;
    +      }
    +      const status = "status" in err && typeof err.status === "number" ? err.status : 400;
    +      return { status, message: err.message };
    +    }),
         ensureBrowserAvailable: profileCtx.ensureBrowserAvailable,
         ensureTabAvailable: profileCtx.ensureTabAvailable,
         isHttpReachable: profileCtx.isHttpReachable,
    @@ -66,9 +86,13 @@ function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>)
     async function callTabsAction(params: {
       body: Record<string, unknown>;
       profileCtx: ReturnType<typeof createProfileContext>;
    +  ssrfPolicy?: unknown;
     }) {
       const { app, postHandlers } = createBrowserRouteApp();
    -  registerBrowserTabRoutes(app, createRouteContext(params.profileCtx) as never);
    +  registerBrowserTabRoutes(
    +    app,
    +    createRouteContext(params.profileCtx, { ssrfPolicy: params.ssrfPolicy }) as never,
    +  );
       const handler = postHandlers.get("/tabs/action");
       expect(handler).toBeTypeOf("function");
     
    @@ -77,7 +101,51 @@ async function callTabsAction(params: {
       return response;
     }
     
    +async function callTabsList(params: {
    +  profileCtx: ReturnType<typeof createProfileContext>;
    +  ssrfPolicy?: unknown;
    +}) {
    +  const { app, getHandlers } = createBrowserRouteApp();
    +  registerBrowserTabRoutes(
    +    app,
    +    createRouteContext(params.profileCtx, { ssrfPolicy: params.ssrfPolicy }) as never,
    +  );
    +  const handler = getHandlers.get("/tabs");
    +  expect(handler).toBeTypeOf("function");
    +
    +  const response = createBrowserRouteResponse();
    +  await handler?.({ params: {}, query: {}, body: {} }, response.res);
    +  return response;
    +}
    +
    +async function callTabsFocus(params: {
    +  profileCtx: ReturnType<typeof createProfileContext>;
    +  body: Record<string, unknown>;
    +  ssrfPolicy?: unknown;
    +}) {
    +  const { app, postHandlers } = createBrowserRouteApp();
    +  registerBrowserTabRoutes(
    +    app,
    +    createRouteContext(params.profileCtx, { ssrfPolicy: params.ssrfPolicy }) as never,
    +  );
    +  const handler = postHandlers.get("/tabs/focus");
    +  expect(handler).toBeTypeOf("function");
    +
    +  const response = createBrowserRouteResponse();
    +  await handler?.({ params: {}, query: {}, body: params.body }, response.res);
    +  return response;
    +}
    +
     describe("browser tab routes", () => {
    +  beforeEach(() => {
    +    navigationGuardMocks.assertBrowserNavigationAllowed.mockReset();
    +    navigationGuardMocks.assertBrowserNavigationResultAllowed.mockReset();
    +    navigationGuardMocks.withBrowserNavigationPolicy.mockReset();
    +    navigationGuardMocks.withBrowserNavigationPolicy.mockImplementation((ssrfPolicy?: unknown) =>
    +      ssrfPolicy ? { ssrfPolicy } : {},
    +    );
    +  });
    +
       it("returns browser-not-running for close when the browser is not reachable", async () => {
         const profileCtx = createProfileContext({
           isReachable: vi.fn(async () => false),
    @@ -109,4 +177,261 @@ describe("browser tab routes", () => {
         expect(profileCtx.listTabs).not.toHaveBeenCalled();
         expect(profileCtx.focusTab).not.toHaveBeenCalled();
       });
    +
    +  it("redacts blocked tab URLs from GET /tabs", async () => {
    +    navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
    +      async (opts?: { url: string }) => {
    +        const url = opts?.url ?? "";
    +        if (url.includes("169.254.169.254")) {
    +          throw new Error("blocked");
    +        }
    +      },
    +    );
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => [
    +        {
    +          targetId: "T1",
    +          title: "Public",
    +          url: "https://example.com",
    +          type: "page",
    +        },
    +        {
    +          targetId: "T2",
    +          title: "Internal",
    +          url: "http://169.254.169.254/latest/meta-data/",
    +          type: "page",
    +        },
    +      ]),
    +    });
    +
    +    const response = await callTabsList({
    +      profileCtx,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(response.statusCode).toBe(200);
    +    expect(response.body).toEqual({
    +      running: true,
    +      tabs: [
    +        {
    +          targetId: "T1",
    +          title: "Public",
    +          url: "https://example.com",
    +          type: "page",
    +        },
    +        {
    +          targetId: "T2",
    +          title: "Internal",
    +          url: "",
    +          type: "page",
    +        },
    +      ],
    +    });
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledTimes(2);
    +  });
    +
    +  it("blocks /tabs/focus when target tab URL fails SSRF checks", async () => {
    +    navigationGuardMocks.assertBrowserNavigationResultAllowed.mockRejectedValueOnce(
    +      new Error("blocked"),
    +    );
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => [
    +        {
    +          targetId: "T2",
    +          title: "Internal",
    +          url: "http://169.254.169.254/latest/meta-data/",
    +          type: "page",
    +        },
    +      ]),
    +    });
    +
    +    const response = await callTabsFocus({
    +      profileCtx,
    +      body: { targetId: "T2" },
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(response.statusCode).toBe(400);
    +    expect(profileCtx.focusTab).not.toHaveBeenCalled();
    +  });
    +
    +  it("does not create a tab for /tabs/focus when target is missing", async () => {
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => []),
    +    });
    +
    +    const response = await callTabsFocus({
    +      profileCtx,
    +      body: { targetId: "T404" },
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(response.statusCode).toBe(404);
    +    expect(profileCtx.ensureTabAvailable).not.toHaveBeenCalled();
    +    expect(profileCtx.focusTab).not.toHaveBeenCalled();
    +  });
    +
    +  it("returns conflict for ambiguous target-id prefixes in /tabs/focus", async () => {
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => [
    +        {
    +          targetId: "T1abc",
    +          title: "Tab 1",
    +          url: "https://example.com",
    +          type: "page",
    +        },
    +        {
    +          targetId: "T1def",
    +          title: "Tab 2",
    +          url: "https://example.org",
    +          type: "page",
    +        },
    +      ]),
    +    });
    +
    +    const response = await callTabsFocus({
    +      profileCtx,
    +      body: { targetId: "T1" },
    +    });
    +
    +    expect(response.statusCode).toBe(409);
    +    expect(profileCtx.focusTab).not.toHaveBeenCalled();
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
    +  });
    +
    +  it("blocks /tabs/action select when target tab URL fails SSRF checks", async () => {
    +    navigationGuardMocks.assertBrowserNavigationResultAllowed.mockRejectedValueOnce(
    +      new Error("blocked"),
    +    );
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => [
    +        {
    +          targetId: "T1",
    +          title: "Public",
    +          url: "https://example.com",
    +          type: "page",
    +        },
    +        {
    +          targetId: "T2",
    +          title: "Internal",
    +          url: "http://169.254.169.254/latest/meta-data/",
    +          type: "page",
    +        },
    +      ]),
    +    });
    +
    +    const response = await callTabsAction({
    +      body: { action: "select", index: 1 },
    +      profileCtx,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(response.statusCode).toBe(400);
    +    expect(profileCtx.focusTab).not.toHaveBeenCalled();
    +  });
    +
    +  it("does not run SSRF result validation for /tabs/focus when policy is not configured", async () => {
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => [
    +        {
    +          targetId: "T2",
    +          title: "Internal",
    +          url: "http://169.254.169.254/latest/meta-data/",
    +          type: "page",
    +        },
    +      ]),
    +    });
    +
    +    const response = await callTabsFocus({
    +      profileCtx,
    +      body: { targetId: "T2" },
    +    });
    +
    +    expect(response.statusCode).toBe(200);
    +    expect(response.body).toEqual({ ok: true });
    +    expect(profileCtx.focusTab).toHaveBeenCalledWith("T2");
    +    expect(profileCtx.ensureTabAvailable).not.toHaveBeenCalled();
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
    +  });
    +
    +  it("does not run SSRF result validation for /tabs/action select when policy is not configured", async () => {
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => [
    +        {
    +          targetId: "T1",
    +          title: "Public",
    +          url: "https://example.com",
    +          type: "page",
    +        },
    +        {
    +          targetId: "T2",
    +          title: "Internal",
    +          url: "http://169.254.169.254/latest/meta-data/",
    +          type: "page",
    +        },
    +      ]),
    +    });
    +
    +    const response = await callTabsAction({
    +      body: { action: "select", index: 1 },
    +      profileCtx,
    +    });
    +
    +    expect(response.statusCode).toBe(200);
    +    expect(response.body).toEqual({ ok: true, targetId: "T2" });
    +    expect(profileCtx.focusTab).toHaveBeenCalledWith("T2");
    +    expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
    +  });
    +
    +  it("redacts blocked tab URLs for /tabs/action list", async () => {
    +    navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
    +      async (opts?: { url: string }) => {
    +        const url = opts?.url ?? "";
    +        if (url.includes("10.0.0.5")) {
    +          throw new Error("blocked");
    +        }
    +      },
    +    );
    +    const profileCtx = createProfileContext({
    +      listTabs: vi.fn(async () => [
    +        {
    +          targetId: "T1",
    +          title: "Public",
    +          url: "https://example.com",
    +          type: "page",
    +        },
    +        {
    +          targetId: "T2",
    +          title: "Private Admin",
    +          url: "http://10.0.0.5/admin",
    +          type: "page",
    +        },
    +      ]),
    +    });
    +
    +    const response = await callTabsAction({
    +      body: { action: "list" },
    +      profileCtx,
    +      ssrfPolicy: { allowPrivateNetwork: false },
    +    });
    +
    +    expect(response.statusCode).toBe(200);
    +    expect(response.body).toEqual({
    +      ok: true,
    +      tabs: [
    +        {
    +          targetId: "T1",
    +          title: "Public",
    +          url: "https://example.com",
    +          type: "page",
    +        },
    +        {
    +          targetId: "T2",
    +          title: "Private Admin",
    +          url: "",
    +          type: "page",
    +        },
    +      ],
    +    });
    +  });
     });
    
  • extensions/browser/src/browser/routes/tabs.ts+70 4 modified
    @@ -1,9 +1,15 @@
    -import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
    +import {
    +  BrowserProfileUnavailableError,
    +  BrowserTabNotFoundError,
    +  BrowserTargetAmbiguousError,
    +} from "../errors.js";
     import {
       assertBrowserNavigationAllowed,
    +  assertBrowserNavigationResultAllowed,
       withBrowserNavigationPolicy,
     } from "../navigation-guard.js";
     import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
    +import { resolveTargetIdFromTabs } from "../target-id.js";
     import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
     import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
     
    @@ -65,6 +71,34 @@ async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResp
       return true;
     }
     
    +async function redactBlockedTabUrls(params: {
    +  tabs: Awaited<ReturnType<ProfileContext["listTabs"]>>;
    +  ssrfPolicy: ReturnType<BrowserRouteContext["state"]>["resolved"]["ssrfPolicy"];
    +}): Promise<Awaited<ReturnType<ProfileContext["listTabs"]>>> {
    +  const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
    +  if (!ssrfPolicyOpts.ssrfPolicy) {
    +    return params.tabs;
    +  }
    +
    +  const redactedTabs: Awaited<ReturnType<ProfileContext["listTabs"]>> = [];
    +  for (const tab of params.tabs) {
    +    try {
    +      await assertBrowserNavigationResultAllowed({
    +        url: tab.url,
    +        ...ssrfPolicyOpts,
    +      });
    +      redactedTabs.push(tab);
    +    } catch {
    +      // Hide blocked URLs while preserving tab identity for safe operations.
    +      redactedTabs.push({
    +        ...tab,
    +        url: "",
    +      });
    +    }
    +  }
    +  return redactedTabs;
    +}
    +
     function resolveIndexedTab(
       tabs: Awaited<ReturnType<ProfileContext["listTabs"]>>,
       index: number | undefined,
    @@ -114,7 +148,10 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
             if (!reachable) {
               return res.json({ running: false, tabs: [] as unknown[] });
             }
    -        const tabs = await profileCtx.listTabs();
    +        const tabs = await redactBlockedTabUrls({
    +          tabs: await profileCtx.listTabs(),
    +          ssrfPolicy: ctx.state().resolved.ssrfPolicy,
    +        });
             res.json({ running: true, tabs });
           },
         });
    @@ -154,7 +191,26 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
           ctx,
           targetId,
           mutate: async (profileCtx, id) => {
    -        await profileCtx.focusTab(id);
    +        const tabs = await profileCtx.listTabs();
    +        const resolved = resolveTargetIdFromTabs(id, tabs);
    +        if (!resolved.ok) {
    +          if (resolved.reason === "ambiguous") {
    +            throw new BrowserTargetAmbiguousError();
    +          }
    +          throw new BrowserTabNotFoundError();
    +        }
    +        const tab = tabs.find((currentTab) => currentTab.targetId === resolved.targetId);
    +        if (!tab) {
    +          throw new BrowserTabNotFoundError();
    +        }
    +        const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
    +        if (ssrfPolicyOpts.ssrfPolicy) {
    +          await assertBrowserNavigationResultAllowed({
    +            url: tab.url,
    +            ...ssrfPolicyOpts,
    +          });
    +        }
    +        await profileCtx.focusTab(resolved.targetId);
           },
         });
       });
    @@ -190,7 +246,10 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
               if (!reachable) {
                 return res.json({ ok: true, tabs: [] as unknown[] });
               }
    -          const tabs = await profileCtx.listTabs();
    +          const tabs = await redactBlockedTabUrls({
    +            tabs: await profileCtx.listTabs(),
    +            ssrfPolicy: ctx.state().resolved.ssrfPolicy,
    +          });
               return res.json({ ok: true, tabs });
             }
     
    @@ -225,6 +284,13 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
               if (!target) {
                 throw new BrowserTabNotFoundError();
               }
    +          const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
    +          if (ssrfPolicyOpts.ssrfPolicy) {
    +            await assertBrowserNavigationResultAllowed({
    +              url: target.url,
    +              ...ssrfPolicyOpts,
    +            });
    +          }
               await profileCtx.focusTab(target.targetId);
               return res.json({ ok: true, targetId: target.targetId });
             }
    

Vulnerability mechanics

Synthesis attempt was rejected by the grounding validator. Re-run pending.

References

6

News mentions

0

No linked articles in our index yet.