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.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.14 | 2026.4.14 |
Affected products
1Patches
1b75ad800a590fix(browser): enforce SSRF policy on snapshot, screenshot, and tab routes [AI] (#66040)
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- github.com/advisories/GHSA-c4qm-58hj-j6pjghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-42436ghsaADVISORY
- github.com/openclaw/openclaw/commit/b75ad800a59009fc47eaa3471410f69046150e59nvdWEB
- github.com/openclaw/openclaw/pull/66040ghsaWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-c4qm-58hj-j6pjnvdWEB
- www.vulncheck.com/advisories/openclaw-internal-page-content-exposure-via-browser-snapshot-and-screenshot-routesnvdWEB
News mentions
0No linked articles in our index yet.