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
This commit is contained in:
pgondhi987
2026-04-02 21:37:57 +05:30
committed by GitHub
parent 8d81e76f23
commit 462b4020bc
9 changed files with 1010 additions and 53 deletions

0
.codex Normal file
View File

View File

@@ -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

View File

@@ -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",
});
});
});

View File

@@ -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 };
}

View File

@@ -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<void>; abort: () => Promise<void> },
request: unknown,
) => Promise<void>)
| null = null;
const pageGoto = vi.fn<
(...args: unknown[]) => Promise<null | { request: () => Record<string, unknown> }>
>(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);
});
});

View File

@@ -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<string, ConnectedBrowser>();
const connectingByCdpUrl = new Map<string, Promise<ConnectedBrowser>>();
const blockedTargetsByCdpUrl = new Set<string>();
const blockedPageRefsByCdpUrl = new Map<string, WeakSet<Page>>();
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<Page> {
const normalized = normalizeCdpUrl(cdpUrl);
const existing = blockedPageRefsByCdpUrl.get(normalized);
if (existing) {
return existing;
}
const created = new WeakSet<Page>();
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<Page[]> {
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<string | null> {
const session = await page.context().newCDPSession(page);
try {
@@ -484,6 +608,9 @@ async function resolvePageByTargetIdOrThrow(opts: {
cdpUrl: string;
targetId: string;
}): Promise<Page> {
if (isBlockedTarget(opts.cdpUrl, opts.targetId)) {
throw new BlockedBrowserTargetError();
}
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
@@ -496,24 +623,165 @@ export async function getPageForTargetId(opts: {
cdpUrl: string;
targetId?: string;
}): Promise<Page> {
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<void> {
// 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<void> {
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<Response | null> {
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");
}

View File

@@ -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,
);
});
});

View File

@@ -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 };
}

View File

@@ -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<unknown> };
}) => (await opts.page.goto(opts.url, { timeout: opts.timeoutMs })) ?? null,
),
restoreRoleRefsForTarget: vi.fn(() => {}),
storeRoleRefsForTarget: vi.fn(() => {}),
refLocator: vi.fn(() => {