From 462b4020bc41ca703ad43adb9d34b83ac6be79f9 Mon Sep 17 00:00:00 2001 From: pgondhi987 Date: Thu, 2 Apr 2026 21:37:57 +0530 Subject: [PATCH] fix(browser): block SSRF redirect bypass via real-time route interception (#58771) Install a Playwright route handler before `page.goto()` so navigations to private/internal IPs are intercepted and aborted mid-redirect instead of being checked post-hoc after the request already reached the internal host. Blocked targets are permanently marked and rejected for subsequent tool calls. Thanks @pgondhi987 --- .codex | 0 CHANGELOG.md | 1 + extensions/browser/src/browser/errors.test.ts | 23 + extensions/browser/src/browser/errors.ts | 3 + ...ssion.create-page.navigation-guard.test.ts | 639 +++++++++++++++++- extensions/browser/src/browser/pw-session.ts | 332 ++++++++- ...tools-core.snapshot.navigate-guard.test.ts | 25 +- .../src/browser/pw-tools-core.snapshot.ts | 32 +- .../src/browser/pw-tools-core.test-harness.ts | 8 + 9 files changed, 1010 insertions(+), 53 deletions(-) create mode 100644 .codex create mode 100644 extensions/browser/src/browser/errors.test.ts diff --git a/.codex b/.codex new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b15d49bcd618..1766440da79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19. +- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987. ## 2026.4.2-beta.1 diff --git a/extensions/browser/src/browser/errors.test.ts b/extensions/browser/src/browser/errors.test.ts new file mode 100644 index 000000000000..1fceaf41ceb9 --- /dev/null +++ b/extensions/browser/src/browser/errors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { BrowserValidationError, toBrowserErrorResponse } from "./errors.js"; + +describe("browser error mapping", () => { + it("maps blocked browser targets to conflict responses", () => { + const err = new Error( + "Browser target is unavailable after SSRF policy blocked its navigation.", + ); + err.name = "BlockedBrowserTargetError"; + + expect(toBrowserErrorResponse(err)).toEqual({ + status: 409, + message: "Browser target is unavailable after SSRF policy blocked its navigation.", + }); + }); + + it("preserves BrowserError mappings", () => { + expect(toBrowserErrorResponse(new BrowserValidationError("bad input"))).toEqual({ + status: 400, + message: "bad input", + }); + }); +}); diff --git a/extensions/browser/src/browser/errors.ts b/extensions/browser/src/browser/errors.ts index b363de4b06e0..0b7a7078509b 100644 --- a/extensions/browser/src/browser/errors.ts +++ b/extensions/browser/src/browser/errors.ts @@ -72,6 +72,9 @@ export function toBrowserErrorResponse(err: unknown): { if (err instanceof BrowserError) { return { status: err.status, message: err.message }; } + if (err instanceof Error && err.name === "BlockedBrowserTargetError") { + return { status: 409, message: err.message }; + } if (err instanceof SsrFBlockedError) { return { status: 400, message: err.message }; } diff --git a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts index ae20e43c230a..1152df77c99c 100644 --- a/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts +++ b/extensions/browser/src/browser/pw-session.create-page.navigation-guard.test.ts @@ -2,19 +2,49 @@ import { chromium } from "playwright-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { SsrFBlockedError } from "../infra/net/ssrf.js"; import * as chromeModule from "./chrome.js"; +import { BrowserTabNotFoundError } from "./errors.js"; import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js"; -import { closePlaywrightBrowserConnection, createPageViaPlaywright } from "./pw-session.js"; +import * as navigationGuardModule from "./navigation-guard.js"; +import { + BlockedBrowserTargetError, + closePlaywrightBrowserConnection, + createPageViaPlaywright, + forceDisconnectPlaywrightForTarget, + getPageForTargetId, + gotoPageWithNavigationGuard, + listPagesViaPlaywright, +} from "./pw-session.js"; const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP"); const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl"); function installBrowserMocks() { const pageOn = vi.fn(); + let routeHandler: + | (( + route: { continue: () => Promise; abort: () => Promise }, + request: unknown, + ) => Promise) + | null = null; const pageGoto = vi.fn< (...args: unknown[]) => Promise Record }> >(async () => null); const pageTitle = vi.fn(async () => ""); const pageUrl = vi.fn(() => "about:blank"); + const pageRoute = vi.fn(async (_pattern: string, handler: typeof routeHandler) => { + routeHandler = handler; + }); + const pageUnroute = vi.fn(async () => { + routeHandler = null; + }); + const openPages: import("playwright-core").Page[] = []; + const pageClose = vi.fn(async () => { + const index = openPages.indexOf(page); + if (index >= 0) { + openPages.splice(index, 1); + } + }); + const mainFrame = {}; const contextOn = vi.fn(); const browserOn = vi.fn(); const browserClose = vi.fn(async () => {}); @@ -27,9 +57,12 @@ function installBrowserMocks() { const sessionDetach = vi.fn(async () => {}); const context = { - pages: () => [], + pages: () => openPages, on: contextOn, - newPage: vi.fn(async () => page), + newPage: vi.fn(async () => { + openPages.push(page); + return page; + }), newCDPSession: vi.fn(async () => ({ send: sessionSend, detach: sessionDetach, @@ -42,6 +75,10 @@ function installBrowserMocks() { goto: pageGoto, title: pageTitle, url: pageUrl, + route: pageRoute, + unroute: pageUnroute, + close: pageClose, + mainFrame: () => mainFrame, } as unknown as import("playwright-core").Page; const browser = { @@ -53,7 +90,24 @@ function installBrowserMocks() { connectOverCdpSpy.mockResolvedValue(browser); getChromeWebSocketUrlSpy.mockResolvedValue(null); - return { pageGoto, browserClose }; + const getBrowserDisconnectedHandler = () => + browserOn.mock.calls.find((call) => call[0] === "disconnected")?.[1] as + | (() => void) + | undefined; + + return { + pageGoto, + browserClose, + pageClose, + sessionSend, + getBrowserDisconnectedHandler, + getRouteHandler: () => routeHandler, + mainFrame, + pushOpenPage: () => { + openPages.push(page); + return page; + }, + }; } afterEach(async () => { @@ -89,18 +143,29 @@ describe("pw-session createPageViaPlaywright navigation guard", () => { }); it("blocks private intermediate redirect hops", async () => { - const { pageGoto } = installBrowserMocks(); - pageGoto.mockResolvedValueOnce({ - request: () => ({ - url: () => "https://93.184.216.34/final", - redirectedFrom: () => ({ + const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, url: () => "http://127.0.0.1:18080/internal-hop", - redirectedFrom: () => ({ - url: () => "https://93.184.216.34/start", - redirectedFrom: () => null, - }), - }), - }), + }, + ); + throw new Error("Navigation aborted"); }); await expect( @@ -109,5 +174,549 @@ describe("pw-session createPageViaPlaywright navigation guard", () => { url: "https://93.184.216.34/start", }), ).rejects.toBeInstanceOf(SsrFBlockedError); + + expect(pageGoto).toHaveBeenCalledTimes(1); + expect(pageClose).toHaveBeenCalledTimes(1); + }); + + it("preserves the created tab on ordinary navigation failure", async () => { + const { pageGoto, pageClose } = installBrowserMocks(); + pageGoto.mockRejectedValueOnce(new Error("page.goto: net::ERR_NAME_NOT_RESOLVED")); + + const created = await createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }); + + expect(created.targetId).toBe("TARGET_1"); + expect(created.url).toBe("about:blank"); + expect(pageGoto).toHaveBeenCalledTimes(1); + expect(pageClose).not.toHaveBeenCalled(); + }); + + it("does not quarantine a tab when route.continue fails", async () => { + const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { + continue: vi.fn(async () => { + throw new Error("page.goto: Frame has been detached"); + }), + abort: vi.fn(async () => {}), + }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://example.com", + }, + ); + throw new Error("page.goto: Frame has been detached"); + }); + + const created = await createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://example.com", + }); + + expect(created.targetId).toBe("TARGET_1"); + expect(pageGoto).toHaveBeenCalledTimes(1); + expect(pageClose).not.toHaveBeenCalled(); + }); + + it("propagates unsupported redirect protocols as navigation errors", async () => { + const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "file:///etc/passwd", + }, + ); + throw new Error("Navigation aborted"); + }); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); + + expect(pageGoto).toHaveBeenCalledTimes(1); + expect(pageClose).toHaveBeenCalledTimes(1); + }); + + it("does not quarantine a tab on transient redirect lookup errors", async () => { + const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); + const assertNavigationAllowedSpy = vi.spyOn( + navigationGuardModule, + "assertBrowserNavigationAllowed", + ); + assertNavigationAllowedSpy.mockImplementation(async (opts: { url: string }) => { + if (opts.url === "http://127.0.0.1:18080/internal-hop") { + throw new Error("getaddrinfo EAI_AGAIN internal-hop"); + } + }); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + try { + const created = await createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }); + const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:18792" }); + + expect(created.targetId).toBe("TARGET_1"); + expect(pages).toHaveLength(1); + expect(pageClose).not.toHaveBeenCalled(); + } finally { + assertNavigationAllowedSpy.mockRestore(); + } + }); + + it("does not quarantine a tab on transient post-navigation check errors", async () => { + const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); + const assertRedirectChainAllowedSpy = vi.spyOn( + navigationGuardModule, + "assertBrowserNavigationRedirectChainAllowed", + ); + assertRedirectChainAllowedSpy.mockRejectedValueOnce( + new Error("getaddrinfo EAI_AGAIN postcheck.example"), + ); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + return { + request: () => ({ + url: () => "https://93.184.216.34/final", + redirectedFrom: () => ({ + url: () => "https://postcheck.example/hop", + redirectedFrom: () => null, + }), + }), + }; + }); + + try { + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toThrow(/getaddrinfo .*postcheck\.example/); + + const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:18792" }); + expect(pages).toHaveLength(1); + expect(pages[0]?.targetId).toBe("TARGET_1"); + expect(pageClose).not.toHaveBeenCalled(); + } finally { + assertRedirectChainAllowedSpy.mockRestore(); + } + }); + + it("keeps blocked tab quarantined if close fails", async () => { + const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); + pageClose.mockRejectedValueOnce(new Error("close failed")); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:18792" }); + expect(pages).toHaveLength(0); + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "TARGET_1", + }), + ).rejects.toBeInstanceOf(BlockedBrowserTargetError); + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + }), + ).rejects.toBeInstanceOf(BlockedBrowserTargetError); + expect(pageClose).toHaveBeenCalledTimes(1); + }); + + it("preserves blocked-target quarantine across forced reconnects", async () => { + const { pageGoto, pageClose, getRouteHandler, mainFrame } = installBrowserMocks(); + pageClose.mockRejectedValueOnce(new Error("close failed")); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + await forceDisconnectPlaywrightForTarget({ + cdpUrl: "http://127.0.0.1:18792", + reason: "test forced reconnect", + }); + + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "TARGET_1", + }), + ).rejects.toBeInstanceOf(BlockedBrowserTargetError); + }); + + it("preserves blocked-target quarantine across transport disconnects", async () => { + const { pageGoto, pageClose, getBrowserDisconnectedHandler, getRouteHandler, mainFrame } = + installBrowserMocks(); + pageClose.mockRejectedValueOnce(new Error("close failed")); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + const disconnectedHandler = getBrowserDisconnectedHandler(); + expect(disconnectedHandler).toBeTypeOf("function"); + disconnectedHandler?.(); + + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "TARGET_1", + }), + ).rejects.toBeInstanceOf(BlockedBrowserTargetError); + }); + + it("keeps blocked tabs inaccessible when target lookup fails", async () => { + const { pageGoto, pageClose, sessionSend, getRouteHandler, mainFrame } = installBrowserMocks(); + pageClose.mockRejectedValueOnce(new Error("close failed")); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + sessionSend.mockRejectedValueOnce(new Error("Target lookup failed")); + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + }), + ).rejects.toBeInstanceOf(BlockedBrowserTargetError); + }); + + it("does not fall back to another tab when explicit target lookup misses", async () => { + const { pageGoto, pageClose, sessionSend, getRouteHandler, mainFrame } = installBrowserMocks(); + pageClose.mockRejectedValueOnce(new Error("close failed")); + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "https://93.184.216.34/start", + }, + ); + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + await expect( + createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://93.184.216.34/start", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + sessionSend.mockImplementationOnce(async (method: string) => { + if (method === "Target.getTargetInfo") { + return { targetInfo: { targetId: "TARGET_2" } }; + } + return {}; + }); + await createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "https://example.com", + }); + + let targetInfoLookups = 0; + sessionSend.mockImplementation(async (method: string) => { + if (method === "Target.getTargetInfo") { + targetInfoLookups += 1; + return { + targetInfo: { targetId: targetInfoLookups % 2 === 1 ? "TARGET_1" : "TARGET_2" }, + }; + } + return {}; + }); + + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "MISSING_TARGET", + }), + ).rejects.toBeInstanceOf(BrowserTabNotFoundError); + }); + + it("quarantines the actual page when blocked navigation receives a stale target id", async () => { + const { pageGoto, pageClose, sessionSend, getRouteHandler, mainFrame } = installBrowserMocks(); + pageClose.mockRejectedValueOnce(new Error("close failed")); + + await createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "about:blank", + }); + + const page = await getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "MISSING_TARGET", + }); + + pageGoto.mockImplementationOnce(async () => { + const handler = getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + // Simulate target-info churn while quarantining so caller target id cannot be trusted. + sessionSend.mockRejectedValueOnce(new Error("Target lookup failed")); + + await expect( + gotoPageWithNavigationGuard({ + cdpUrl: "http://127.0.0.1:18792", + page, + url: "https://93.184.216.34/start", + timeoutMs: 1000, + targetId: "MISSING_TARGET", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + }), + ).rejects.toBeInstanceOf(BlockedBrowserTargetError); + }); + + it("falls back to caller targetId quarantine when target lookup fails", async () => { + const first = installBrowserMocks(); + first.pageClose.mockRejectedValueOnce(new Error("close failed")); + + await createPageViaPlaywright({ + cdpUrl: "http://127.0.0.1:18792", + url: "about:blank", + }); + const page = await getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "TARGET_1", + }); + + first.pageGoto.mockImplementationOnce(async () => { + const handler = first.getRouteHandler(); + if (!handler) { + throw new Error("missing route handler"); + } + await handler( + { continue: vi.fn(async () => {}), abort: vi.fn(async () => {}) }, + { + isNavigationRequest: () => true, + frame: () => first.mainFrame, + url: () => "http://127.0.0.1:18080/internal-hop", + }, + ); + throw new Error("Navigation aborted"); + }); + + first.sessionSend.mockRejectedValueOnce(new Error("Target lookup failed")); + await expect( + gotoPageWithNavigationGuard({ + cdpUrl: "http://127.0.0.1:18792", + page, + url: "https://93.184.216.34/start", + timeoutMs: 1000, + targetId: "TARGET_1", + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + + await forceDisconnectPlaywrightForTarget({ + cdpUrl: "http://127.0.0.1:18792", + reason: "test reconnect after blocked navigation", + }); + + const second = installBrowserMocks(); + second.pushOpenPage(); + + await expect( + getPageForTargetId({ + cdpUrl: "http://127.0.0.1:18792", + targetId: "TARGET_1", + }), + ).rejects.toBeInstanceOf(BlockedBrowserTargetError); }); }); diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 976775575436..cefbf7c22525 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -5,10 +5,11 @@ import type { Page, Request, Response, + Route, } from "playwright-core"; import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; -import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { SsrFBlockedError, type SsrFPolicy } from "../infra/net/ssrf.js"; import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; import { appendCdpPath, @@ -24,6 +25,7 @@ import { assertBrowserNavigationAllowed, assertBrowserNavigationRedirectChainAllowed, assertBrowserNavigationResultAllowed, + InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, } from "./navigation-guard.js"; import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; @@ -118,6 +120,8 @@ const MAX_NETWORK_REQUESTS = 500; const cachedByCdpUrl = new Map(); const connectingByCdpUrl = new Map>(); +const blockedTargetsByCdpUrl = new Set(); +const blockedPageRefsByCdpUrl = new Map>(); function normalizeCdpUrl(raw: string) { return raw.replace(/\/$/, ""); @@ -133,10 +137,99 @@ function findNetworkRequestById(state: PageState, id: string): BrowserNetworkReq return undefined; } -function roleRefsKey(cdpUrl: string, targetId: string) { +function targetKey(cdpUrl: string, targetId: string) { return `${normalizeCdpUrl(cdpUrl)}::${targetId}`; } +function roleRefsKey(cdpUrl: string, targetId: string) { + return targetKey(cdpUrl, targetId); +} + +function isBlockedTarget(cdpUrl: string, targetId?: string): boolean { + const normalizedTargetId = targetId?.trim() || ""; + if (!normalizedTargetId) { + return false; + } + return blockedTargetsByCdpUrl.has(targetKey(cdpUrl, normalizedTargetId)); +} + +function markTargetBlocked(cdpUrl: string, targetId?: string): void { + const normalizedTargetId = targetId?.trim() || ""; + if (!normalizedTargetId) { + return; + } + blockedTargetsByCdpUrl.add(targetKey(cdpUrl, normalizedTargetId)); +} + +function clearBlockedTarget(cdpUrl: string, targetId?: string): void { + const normalizedTargetId = targetId?.trim() || ""; + if (!normalizedTargetId) { + return; + } + blockedTargetsByCdpUrl.delete(targetKey(cdpUrl, normalizedTargetId)); +} + +function clearBlockedTargetsForCdpUrl(cdpUrl?: string): void { + if (!cdpUrl) { + blockedTargetsByCdpUrl.clear(); + return; + } + const prefix = `${normalizeCdpUrl(cdpUrl)}::`; + for (const key of blockedTargetsByCdpUrl) { + if (key.startsWith(prefix)) { + blockedTargetsByCdpUrl.delete(key); + } + } +} + +function blockedPageRefsForCdpUrl(cdpUrl: string): WeakSet { + const normalized = normalizeCdpUrl(cdpUrl); + const existing = blockedPageRefsByCdpUrl.get(normalized); + if (existing) { + return existing; + } + const created = new WeakSet(); + blockedPageRefsByCdpUrl.set(normalized, created); + return created; +} + +function isBlockedPageRef(cdpUrl: string, page: Page): boolean { + return blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.has(page) ?? false; +} + +function markPageRefBlocked(cdpUrl: string, page: Page): void { + blockedPageRefsForCdpUrl(cdpUrl).add(page); +} + +function clearBlockedPageRefsForCdpUrl(cdpUrl?: string): void { + if (!cdpUrl) { + blockedPageRefsByCdpUrl.clear(); + return; + } + blockedPageRefsByCdpUrl.delete(normalizeCdpUrl(cdpUrl)); +} + +function clearBlockedPageRef(cdpUrl: string, page: Page): void { + blockedPageRefsByCdpUrl.get(normalizeCdpUrl(cdpUrl))?.delete(page); +} + +function hasBlockedTargetsForCdpUrl(cdpUrl: string): boolean { + const prefix = `${normalizeCdpUrl(cdpUrl)}::`; + for (const key of blockedTargetsByCdpUrl) { + if (key.startsWith(prefix)) { + return true; + } + } + return false; +} + +export class BlockedBrowserTargetError extends Error { + constructor() { + super("Browser target is unavailable after SSRF policy blocked its navigation."); + this.name = "BlockedBrowserTargetError"; + } +} + export function rememberRoleRefsForTarget(opts: { cdpUrl: string; targetId: string; @@ -395,6 +488,37 @@ async function getAllPages(browser: Browser): Promise { return pages; } +async function partitionAccessiblePages(opts: { + cdpUrl: string; + pages: Page[]; +}): Promise<{ accessible: Page[]; blockedCount: number }> { + const accessible: Page[] = []; + let blockedCount = 0; + for (const page of opts.pages) { + if (isBlockedPageRef(opts.cdpUrl, page)) { + blockedCount += 1; + continue; + } + const targetId = await pageTargetId(page).catch(() => null); + // Fail closed when we cannot resolve a target id while this session has + // quarantined targets; otherwise a blocked tab can become selectable. + if (!targetId) { + if (hasBlockedTargetsForCdpUrl(opts.cdpUrl)) { + blockedCount += 1; + continue; + } + accessible.push(page); + continue; + } + if (isBlockedTarget(opts.cdpUrl, targetId)) { + blockedCount += 1; + continue; + } + accessible.push(page); + } + return { accessible, blockedCount }; +} + async function pageTargetId(page: Page): Promise { const session = await page.context().newCDPSession(page); try { @@ -484,6 +608,9 @@ async function resolvePageByTargetIdOrThrow(opts: { cdpUrl: string; targetId: string; }): Promise { + if (isBlockedTarget(opts.cdpUrl, opts.targetId)) { + throw new BlockedBrowserTargetError(); + } const { browser } = await connectBrowser(opts.cdpUrl); const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); if (!page) { @@ -496,24 +623,165 @@ export async function getPageForTargetId(opts: { cdpUrl: string; targetId?: string; }): Promise { + if (opts.targetId && isBlockedTarget(opts.cdpUrl, opts.targetId)) { + throw new BlockedBrowserTargetError(); + } const { browser } = await connectBrowser(opts.cdpUrl); const pages = await getAllPages(browser); if (!pages.length) { throw new Error("No pages available in the connected browser."); } - const first = pages[0]; + + const { accessible, blockedCount } = await partitionAccessiblePages({ + cdpUrl: opts.cdpUrl, + pages, + }); + if (!accessible.length) { + if (blockedCount > 0) { + throw new BlockedBrowserTargetError(); + } + throw new Error("No pages available in the connected browser."); + } + const first = accessible[0]; if (!opts.targetId) { return first; } const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl); - if (!found) { - // If Playwright only exposes a single Page, use it as a best-effort fallback. - if (pages.length === 1) { - return first; + if (found) { + if (isBlockedPageRef(opts.cdpUrl, found)) { + throw new BlockedBrowserTargetError(); + } + const foundTargetId = await pageTargetId(found).catch(() => null); + if (foundTargetId && isBlockedTarget(opts.cdpUrl, foundTargetId)) { + throw new BlockedBrowserTargetError(); + } + return found; + } + // If Playwright only exposes a single Page total, use it as a best-effort fallback. + if (pages.length === 1) { + return first; + } + throw new BrowserTabNotFoundError(); +} + +function isTopLevelNavigationRequest(page: Page, request: Request): boolean { + if (!request.isNavigationRequest()) { + return false; + } + try { + return request.frame() === page.mainFrame(); + } catch { + return true; + } +} + +function isPolicyDenyNavigationError(err: unknown): boolean { + return err instanceof SsrFBlockedError || err instanceof InvalidBrowserNavigationUrlError; +} + +async function closeBlockedNavigationTarget(opts: { + cdpUrl: string; + page: Page; + targetId?: string; +}): Promise { + // Quarantine the concrete page first; then persist by target id when available. + markPageRefBlocked(opts.cdpUrl, opts.page); + const resolvedTargetId = await pageTargetId(opts.page).catch(() => null); + const fallbackTargetId = opts.targetId?.trim() || ""; + const targetIdToBlock = resolvedTargetId || fallbackTargetId; + if (targetIdToBlock) { + markTargetBlocked(opts.cdpUrl, targetIdToBlock); + } + await opts.page.close().catch(() => {}); +} + +export async function assertPageNavigationCompletedSafely(opts: { + cdpUrl: string; + page: Page; + response: Response | null; + ssrfPolicy?: SsrFPolicy; + targetId?: string; +}): Promise { + const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); + try { + await assertBrowserNavigationRedirectChainAllowed({ + request: opts.response?.request(), + ...navigationPolicy, + }); + await assertBrowserNavigationResultAllowed({ + url: opts.page.url(), + ...navigationPolicy, + }); + } catch (err) { + if (isPolicyDenyNavigationError(err)) { + await closeBlockedNavigationTarget({ + cdpUrl: opts.cdpUrl, + page: opts.page, + targetId: opts.targetId, + }); + } + throw err; + } +} + +export async function gotoPageWithNavigationGuard(opts: { + cdpUrl: string; + page: Page; + url: string; + timeoutMs: number; + ssrfPolicy?: SsrFPolicy; + targetId?: string; +}): Promise { + const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); + let blockedError: unknown = null; + + const handler = async (route: Route, request: Request) => { + if (blockedError) { + await route.abort().catch(() => {}); + return; + } + if (!isTopLevelNavigationRequest(opts.page, request)) { + await route.continue(); + return; + } + try { + await assertBrowserNavigationAllowed({ + url: request.url(), + ...navigationPolicy, + }); + } catch (err) { + if (isPolicyDenyNavigationError(err)) { + blockedError = err; + await route.abort().catch(() => {}); + return; + } + throw err; + } + await route.continue(); + }; + + await opts.page.route("**", handler); + try { + const response = await opts.page.goto(opts.url, { timeout: opts.timeoutMs }); + if (blockedError) { + throw blockedError; + } + return response; + } catch (err) { + if (blockedError) { + throw blockedError; + } + throw err; + } finally { + await opts.page.unroute("**", handler).catch(() => {}); + if (blockedError) { + await closeBlockedNavigationTarget({ + cdpUrl: opts.cdpUrl, + page: opts.page, + targetId: opts.targetId, + }); } - throw new BrowserTabNotFoundError(); } - return found; } export function refLocator(page: Page, ref: string) { @@ -559,6 +827,8 @@ export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string const normalized = opts?.cdpUrl ? normalizeCdpUrl(opts.cdpUrl) : null; if (normalized) { + clearBlockedTargetsForCdpUrl(normalized); + clearBlockedPageRefsForCdpUrl(normalized); const cur = cachedByCdpUrl.get(normalized); cachedByCdpUrl.delete(normalized); connectingByCdpUrl.delete(normalized); @@ -573,6 +843,8 @@ export async function closePlaywrightBrowserConnection(opts?: { cdpUrl?: string } const connections = Array.from(cachedByCdpUrl.values()); + clearBlockedTargetsForCdpUrl(); + clearBlockedPageRefsForCdpUrl(); cachedByCdpUrl.clear(); connectingByCdpUrl.clear(); for (const cur of connections) { @@ -733,8 +1005,11 @@ export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise< }> = []; for (const page of pages) { + if (isBlockedPageRef(opts.cdpUrl, page)) { + continue; + } const tid = await pageTargetId(page).catch(() => null); - if (tid) { + if (tid && !isBlockedTarget(opts.cdpUrl, tid)) { results.push({ targetId: tid, title: await page.title().catch(() => ""), @@ -767,6 +1042,9 @@ export async function createPageViaPlaywright(opts: { const page = await context.newPage(); ensurePageState(page); + clearBlockedPageRef(opts.cdpUrl, page); + const createdTargetId = await pageTargetId(page).catch(() => null); + clearBlockedTarget(opts.cdpUrl, createdTargetId ?? undefined); // Navigate to the URL const targetUrl = opts.url.trim() || "about:blank"; @@ -776,22 +1054,32 @@ export async function createPageViaPlaywright(opts: { url: targetUrl, ...navigationPolicy, }); - const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { - // Navigation might fail for some URLs, but page is still created - return null; - }); - await assertBrowserNavigationRedirectChainAllowed({ - request: response?.request(), - ...navigationPolicy, - }); - await assertBrowserNavigationResultAllowed({ - url: page.url(), - ...navigationPolicy, + let response: Response | null = null; + try { + response = await gotoPageWithNavigationGuard({ + cdpUrl: opts.cdpUrl, + page, + url: targetUrl, + timeoutMs: 30_000, + ssrfPolicy: opts.ssrfPolicy, + targetId: createdTargetId ?? undefined, + }); + } catch (err) { + if (isPolicyDenyNavigationError(err) || err instanceof BlockedBrowserTargetError) { + throw err; + } + } + await assertPageNavigationCompletedSafely({ + cdpUrl: opts.cdpUrl, + page, + response, + ssrfPolicy: opts.ssrfPolicy, + targetId: createdTargetId ?? undefined, }); } // Get the targetId for this page - const tid = await pageTargetId(page).catch(() => null); + const tid = createdTargetId || (await pageTargetId(page).catch(() => null)); if (!tid) { throw new Error("Failed to get targetId for new page"); } diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts index 5a747eb0e262..6ba53aad084a 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.navigate-guard.test.ts @@ -63,6 +63,21 @@ describe("pw-tools-core.snapshot navigate guard", () => { }); expect(goto).toHaveBeenCalledWith("https://example.com", { timeout: 1000 }); + expect(getPwToolsCoreSessionMocks().gotoPageWithNavigationGuard).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18792", + page: expect.anything(), + ssrfPolicy: { allowPrivateNetwork: true }, + targetId: undefined, + timeoutMs: 1000, + url: "https://example.com", + }); + expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledWith({ + cdpUrl: "http://127.0.0.1:18792", + page: expect.anything(), + response: null, + ssrfPolicy: { allowPrivateNetwork: true }, + targetId: undefined, + }); expect(result.url).toBe("https://example.com"); }); @@ -92,7 +107,7 @@ describe("pw-tools-core.snapshot navigate guard", () => { targetId: "tab-1", reason: "retry navigate after detached frame", }); - expect(goto).toHaveBeenCalledTimes(2); + expect(getPwToolsCoreSessionMocks().gotoPageWithNavigationGuard).toHaveBeenCalledTimes(2); expect(result.url).toBe("https://example.com/recovered"); }); @@ -113,6 +128,9 @@ describe("pw-tools-core.snapshot navigate guard", () => { goto, url: vi.fn(() => "https://93.184.216.34/final"), }); + getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely.mockRejectedValueOnce( + new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"), + ); await expect( mod.navigateViaPlaywright({ @@ -121,6 +139,9 @@ describe("pw-tools-core.snapshot navigate guard", () => { }), ).rejects.toBeInstanceOf(SsrFBlockedError); - expect(goto).toHaveBeenCalledTimes(1); + expect(getPwToolsCoreSessionMocks().gotoPageWithNavigationGuard).toHaveBeenCalledTimes(1); + expect(getPwToolsCoreSessionMocks().assertPageNavigationCompletedSafely).toHaveBeenCalledTimes( + 1, + ); }); }); diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.ts index 09926626db18..7b68cdb74e39 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.ts @@ -1,11 +1,6 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; -import { - assertBrowserNavigationAllowed, - assertBrowserNavigationRedirectChainAllowed, - assertBrowserNavigationResultAllowed, - withBrowserNavigationPolicy, -} from "./navigation-guard.js"; +import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; import { buildRoleSnapshotFromAiSnapshot, buildRoleSnapshotFromAriaSnapshot, @@ -14,9 +9,11 @@ import { type RoleRefMap, } from "./pw-role-snapshot.js"; import { + assertPageNavigationCompletedSafely, ensurePageState, forceDisconnectPlaywrightForTarget, getPageForTargetId, + gotoPageWithNavigationGuard, storeRoleRefsForTarget, type WithSnapshotForAI, } from "./pw-session.js"; @@ -197,7 +194,15 @@ export async function navigateViaPlaywright(opts: { const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)); let page = await getPageForTargetId(opts); ensurePageState(page); - const navigate = async () => await page.goto(url, { timeout }); + const navigate = async () => + await gotoPageWithNavigationGuard({ + cdpUrl: opts.cdpUrl, + page, + url, + timeoutMs: timeout, + ssrfPolicy: opts.ssrfPolicy, + targetId: opts.targetId, + }); let response; try { response = await navigate(); @@ -216,15 +221,14 @@ export async function navigateViaPlaywright(opts: { ensurePageState(page); response = await navigate(); } - await assertBrowserNavigationRedirectChainAllowed({ - request: response?.request(), - ...withBrowserNavigationPolicy(opts.ssrfPolicy), + await assertPageNavigationCompletedSafely({ + cdpUrl: opts.cdpUrl, + page, + response, + ssrfPolicy: opts.ssrfPolicy, + targetId: opts.targetId, }); const finalUrl = page.url(); - await assertBrowserNavigationResultAllowed({ - url: finalUrl, - ...withBrowserNavigationPolicy(opts.ssrfPolicy), - }); return { url: finalUrl }; } diff --git a/extensions/browser/src/browser/pw-tools-core.test-harness.ts b/extensions/browser/src/browser/pw-tools-core.test-harness.ts index 6111fa89aefe..039267012537 100644 --- a/extensions/browser/src/browser/pw-tools-core.test-harness.ts +++ b/extensions/browser/src/browser/pw-tools-core.test-harness.ts @@ -15,6 +15,7 @@ let pageState: { }; const sessionMocks = vi.hoisted(() => ({ + assertPageNavigationCompletedSafely: vi.fn(async () => {}), getPageForTargetId: vi.fn(async () => { if (!currentPage) { throw new Error("missing page"); @@ -23,6 +24,13 @@ const sessionMocks = vi.hoisted(() => ({ }), ensurePageState: vi.fn(() => pageState), forceDisconnectPlaywrightForTarget: vi.fn(async () => {}), + gotoPageWithNavigationGuard: vi.fn( + async (opts: { + url: string; + timeoutMs: number; + page: { goto: (url: string, init: { timeout: number }) => Promise }; + }) => (await opts.page.goto(opts.url, { timeout: opts.timeoutMs })) ?? null, + ), restoreRoleRefsForTarget: vi.fn(() => {}), storeRoleRefsForTarget: vi.fn(() => {}), refLocator: vi.fn(() => {