mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
23
extensions/browser/src/browser/errors.test.ts
Normal file
23
extensions/browser/src/browser/errors.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user