diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fcab64a85..788f38ea892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts b/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts index 9eff7804b72..5b5268a8d45 100644 --- a/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts +++ b/extensions/browser/src/browser/routes/agent.act.existing-session-navigation-guard.test.ts @@ -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(); }); diff --git a/extensions/browser/src/browser/routes/agent.act.ts b/extensions/browser/src/browser/routes/agent.act.ts index 9ebe491ff12..d174f5f753e 100644 --- a/extensions/browser/src/browser/routes/agent.act.ts +++ b/extensions/browser/src/browser/routes/agent.act.ts @@ -71,11 +71,28 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: { userDataDir?: string; targetId: string; ssrfPolicy?: BrowserNavigationPolicyOptions["ssrfPolicy"]; + listTabs: () => Promise>; + initialTabTargetIds: ReadonlySet; }): Promise { 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(); const existingSessionNavigationGuard = { profileName, userDataDir: profileCtx.profile.userDataDir, targetId: tab.targetId, ssrfPolicy, + listTabs: () => profileCtx.listTabs(), + initialTabTargetIds, }; const unsupportedMessage = getExistingSessionUnsupportedMessage(action); if (unsupportedMessage) { diff --git a/extensions/browser/src/browser/routes/agent.existing-session.test.ts b/extensions/browser/src/browser/routes/agent.existing-session.test.ts index de0e8c050e7..99b8a78ef97 100644 --- a/extensions/browser/src/browser/routes/agent.existing-session.test.ts +++ b/extensions/browser/src/browser/routes/agent.existing-session.test.ts @@ -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 () => { diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index f17616f9559..816536195d0 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -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, diff --git a/extensions/browser/src/browser/routes/existing-session.test-support.ts b/extensions/browser/src/browser/routes/existing-session.test-support.ts index 12ab28c50d4..48e4cdf680e 100644 --- a/extensions/browser/src/browser/routes/existing-session.test-support.ts +++ b/extensions/browser/src/browser/routes/existing-session.test-support.ts @@ -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", diff --git a/extensions/browser/src/browser/routes/tabs.test.ts b/extensions/browser/src/browser/routes/tabs.test.ts index 461328c8510..bfc118579c3 100644 --- a/extensions/browser/src/browser/routes/tabs.test.ts +++ b/extensions/browser/src/browser/routes/tabs.test.ts @@ -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>) { return { ...baseProfileContext(), @@ -44,12 +55,21 @@ function baseProfileContext() { }; } -function createRouteContext(profileCtx: ReturnType) { +function createRouteContext( + profileCtx: ReturnType, + 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) async function callTabsAction(params: { body: Record; profileCtx: ReturnType; + 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; + 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; + body: Record; + 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", + }, + ], + }); + }); }); diff --git a/extensions/browser/src/browser/routes/tabs.ts b/extensions/browser/src/browser/routes/tabs.ts index cc8aa993d5d..a190bfaaf4f 100644 --- a/extensions/browser/src/browser/routes/tabs.ts +++ b/extensions/browser/src/browser/routes/tabs.ts @@ -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>; + ssrfPolicy: ReturnType["resolved"]["ssrfPolicy"]; +}): Promise>> { + const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy); + if (!ssrfPolicyOpts.ssrfPolicy) { + return params.tabs; + } + + const redactedTabs: Awaited> = []; + 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>, 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 }); }