refactor(browser): unify navigation guard path and error typing

This commit is contained in:
Peter Steinberger
2026-02-19 14:04:08 +01:00
parent badafdc7b3
commit 9f9cd5cbb2
10 changed files with 133 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js"; import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js"; export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
@@ -88,11 +88,14 @@ export async function createTargetViaCdp(opts: {
cdpUrl: string; cdpUrl: string;
url: string; url: string;
ssrfPolicy?: SsrFPolicy; ssrfPolicy?: SsrFPolicy;
navigationChecked?: boolean;
}): Promise<{ targetId: string }> { }): Promise<{ targetId: string }> {
await assertBrowserNavigationAllowed({ if (!opts.navigationChecked) {
url: opts.url, await assertBrowserNavigationAllowed({
ssrfPolicy: opts.ssrfPolicy, url: opts.url,
}); ...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
}
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"), appendCdpPath(opts.cdpUrl, "/json/version"),

View File

@@ -1,6 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; import {
assertBrowserNavigationAllowed,
InvalidBrowserNavigationUrlError,
} from "./navigation-guard.js";
function createLookupFn(address: string): LookupFn {
const family = address.includes(":") ? 6 : 4;
return vi.fn(async () => [{ address, family }]) as unknown as LookupFn;
}
describe("browser navigation guard", () => { describe("browser navigation guard", () => {
it("blocks private loopback URLs by default", async () => { it("blocks private loopback URLs by default", async () => {
@@ -19,15 +27,39 @@ describe("browser navigation guard", () => {
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
it("allows localhost when explicitly allowed", async () => { it("allows blocked hostnames when explicitly allowed", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect( await expect(
assertBrowserNavigationAllowed({ assertBrowserNavigationAllowed({
url: "http://localhost:3000", url: "http://agent.internal:3000",
ssrfPolicy: { ssrfPolicy: {
allowedHostnames: ["localhost"], allowedHostnames: ["agent.internal"],
}, },
lookupFn,
}), }),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("agent.internal", { all: true });
});
it("blocks hostnames that resolve to private addresses by default", async () => {
const lookupFn = createLookupFn("127.0.0.1");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("allows hostnames that resolve to public addresses", async () => {
const lookupFn = createLookupFn("93.184.216.34");
await expect(
assertBrowserNavigationAllowed({
url: "https://example.com",
lookupFn,
}),
).resolves.toBeUndefined();
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
}); });
it("rejects invalid URLs", async () => { it("rejects invalid URLs", async () => {
@@ -35,6 +67,6 @@ describe("browser navigation guard", () => {
assertBrowserNavigationAllowed({ assertBrowserNavigationAllowed({
url: "not a url", url: "not a url",
}), }),
).rejects.toThrow(/Invalid URL/); ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
}); });
}); });

View File

@@ -1,21 +1,44 @@
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "../infra/net/ssrf.js"; import {
resolvePinnedHostnameWithPolicy,
type LookupFn,
type SsrFPolicy,
} from "../infra/net/ssrf.js";
const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]); const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
export async function assertBrowserNavigationAllowed(opts: { export class InvalidBrowserNavigationUrlError extends Error {
url: string; constructor(message: string) {
super(message);
this.name = "InvalidBrowserNavigationUrlError";
}
}
export type BrowserNavigationPolicyOptions = {
ssrfPolicy?: SsrFPolicy; ssrfPolicy?: SsrFPolicy;
}): Promise<void> { };
export function withBrowserNavigationPolicy(
ssrfPolicy?: SsrFPolicy,
): BrowserNavigationPolicyOptions {
return ssrfPolicy ? { ssrfPolicy } : {};
}
export async function assertBrowserNavigationAllowed(
opts: {
url: string;
lookupFn?: LookupFn;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const rawUrl = String(opts.url ?? "").trim(); const rawUrl = String(opts.url ?? "").trim();
if (!rawUrl) { if (!rawUrl) {
throw new Error("url is required"); throw new InvalidBrowserNavigationUrlError("url is required");
} }
let parsed: URL; let parsed: URL;
try { try {
parsed = new URL(rawUrl); parsed = new URL(rawUrl);
} catch { } catch {
throw new Error(`Invalid URL: ${rawUrl}`); throw new InvalidBrowserNavigationUrlError(`Invalid URL: ${rawUrl}`);
} }
if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) { if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
@@ -23,6 +46,7 @@ export async function assertBrowserNavigationAllowed(opts: {
} }
await resolvePinnedHostnameWithPolicy(parsed.hostname, { await resolvePinnedHostnameWithPolicy(parsed.hostname, {
lookupFn: opts.lookupFn,
policy: opts.ssrfPolicy, policy: opts.ssrfPolicy,
}); });
} }

View File

@@ -12,7 +12,7 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js"; import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js"; import { normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js"; import { getChromeWebSocketUrl } from "./chrome.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
export type BrowserConsoleMessage = { export type BrowserConsoleMessage = {
type: string; type: string;
@@ -722,6 +722,7 @@ export async function createPageViaPlaywright(opts: {
cdpUrl: string; cdpUrl: string;
url: string; url: string;
ssrfPolicy?: SsrFPolicy; ssrfPolicy?: SsrFPolicy;
navigationChecked?: boolean;
}): Promise<{ }): Promise<{
targetId: string; targetId: string;
title: string; title: string;
@@ -738,10 +739,12 @@ export async function createPageViaPlaywright(opts: {
// Navigate to the URL // Navigate to the URL
const targetUrl = opts.url.trim() || "about:blank"; const targetUrl = opts.url.trim() || "about:blank";
if (targetUrl !== "about:blank") { if (targetUrl !== "about:blank") {
await assertBrowserNavigationAllowed({ if (!opts.navigationChecked) {
url: targetUrl, await assertBrowserNavigationAllowed({
ssrfPolicy: opts.ssrfPolicy, url: targetUrl,
}); ...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
}
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
// Navigation might fail for some URLs, but page is still created // Navigation might fail for some URLs, but page is still created
}); });

View File

@@ -1,6 +1,6 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js"; import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
import { import {
buildRoleSnapshotFromAiSnapshot, buildRoleSnapshotFromAiSnapshot,
buildRoleSnapshotFromAriaSnapshot, buildRoleSnapshotFromAriaSnapshot,
@@ -168,7 +168,7 @@ export async function navigateViaPlaywright(opts: {
} }
await assertBrowserNavigationAllowed({ await assertBrowserNavigationAllowed({
url, url,
ssrfPolicy: opts.ssrfPolicy, ...withBrowserNavigationPolicy(opts.ssrfPolicy),
}); });
const page = await getPageForTargetId(opts); const page = await getPageForTargetId(opts);
ensurePageState(page); ensurePageState(page);

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS, DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS, DEFAULT_AI_SNAPSHOT_MAX_CHARS,
} from "../constants.js"; } from "../constants.js";
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
import { import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES, DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
@@ -65,12 +66,11 @@ export function registerBrowserAgentSnapshotRoutes(
targetId, targetId,
feature: "navigate", feature: "navigate",
run: async ({ cdpUrl, tab, pw }) => { run: async ({ cdpUrl, tab, pw }) => {
const ssrfPolicy = ctx.state().resolved.ssrfPolicy;
const result = await pw.navigateViaPlaywright({ const result = await pw.navigateViaPlaywright({
cdpUrl, cdpUrl,
targetId: tab.targetId, targetId: tab.targetId,
url, url,
...(ssrfPolicy ? { ssrfPolicy } : {}), ...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
}); });
res.json({ ok: true, targetId: tab.targetId, ...result }); res.json({ ok: true, targetId: tab.targetId, ...result });
}, },

View File

@@ -121,6 +121,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
req, req,
res, res,
ctx, ctx,
mapTabError: true,
run: async (profileCtx) => { run: async (profileCtx) => {
await profileCtx.ensureBrowserAvailable(); await profileCtx.ensureBrowserAvailable();
const tab = await profileCtx.openTab(url); const tab = await profileCtx.openTab(url);

View File

@@ -90,6 +90,12 @@ describe("browser server-context remote profile tab operations", () => {
const opened = await remote.openTab("http://127.0.0.1:3000"); const opened = await remote.openTab("http://127.0.0.1:3000");
expect(opened.targetId).toBe("T2"); expect(opened.targetId).toBe("T2");
expect(state.profiles.get("remote")?.lastTargetId).toBe("T2"); expect(state.profiles.get("remote")?.lastTargetId).toBe("T2");
expect(createPageViaPlaywright).toHaveBeenCalledWith({
cdpUrl: "https://browserless.example/chrome?token=abc",
url: "http://127.0.0.1:3000",
ssrfPolicy: { allowPrivateNetwork: true },
navigationChecked: true,
});
await remote.closeTab("T1"); await remote.closeTab("T1");
expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({ expect(closePageByTargetIdViaPlaywright).toHaveBeenCalledWith({
@@ -214,7 +220,9 @@ describe("browser server-context remote profile tab operations", () => {
describe("browser server-context tab selection state", () => { describe("browser server-context tab selection state", () => {
it("updates lastTargetId when openTab is created via CDP", async () => { it("updates lastTargetId when openTab is created via CDP", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "CREATED" }); const createTargetViaCdp = vi
.spyOn(cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "CREATED" });
const fetchMock = vi.fn(async (url: unknown) => { const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url); const u = String(url);
@@ -244,5 +252,11 @@ describe("browser server-context tab selection state", () => {
const opened = await openclaw.openTab("http://127.0.0.1:8080"); const opened = await openclaw.openTab("http://127.0.0.1:8080");
expect(opened.targetId).toBe("CREATED"); expect(opened.targetId).toBe("CREATED");
expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED"); expect(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED");
expect(createTargetViaCdp).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18800",
url: "http://127.0.0.1:8080",
ssrfPolicy: { allowPrivateNetwork: true },
navigationChecked: true,
});
}); });
}); });

View File

@@ -15,7 +15,11 @@ import {
ensureChromeExtensionRelayServer, ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer, stopChromeExtensionRelayServer,
} from "./extension-relay.js"; } from "./extension-relay.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; import {
assertBrowserNavigationAllowed,
InvalidBrowserNavigationUrlError,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import type { PwAiModule } from "./pw-ai-module.js"; import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js";
import { import {
@@ -132,8 +136,8 @@ function createProfileContext(
}; };
const openTab = async (url: string): Promise<BrowserTab> => { const openTab = async (url: string): Promise<BrowserTab> => {
const ssrfPolicy = state().resolved.ssrfPolicy; const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
await assertBrowserNavigationAllowed({ url, ssrfPolicy }); await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
// For remote profiles, use Playwright's persistent connection to create tabs // For remote profiles, use Playwright's persistent connection to create tabs
// This ensures the tab persists beyond a single request // This ensures the tab persists beyond a single request
@@ -144,7 +148,8 @@ function createProfileContext(
const page = await createPageViaPlaywright({ const page = await createPageViaPlaywright({
cdpUrl: profile.cdpUrl, cdpUrl: profile.cdpUrl,
url, url,
...(ssrfPolicy ? { ssrfPolicy } : {}), ...ssrfPolicyOpts,
navigationChecked: true,
}); });
const profileState = getProfileState(); const profileState = getProfileState();
profileState.lastTargetId = page.targetId; profileState.lastTargetId = page.targetId;
@@ -160,7 +165,8 @@ function createProfileContext(
const createdViaCdp = await createTargetViaCdp({ const createdViaCdp = await createTargetViaCdp({
cdpUrl: profile.cdpUrl, cdpUrl: profile.cdpUrl,
url, url,
...(ssrfPolicy ? { ssrfPolicy } : {}), ...ssrfPolicyOpts,
navigationChecked: true,
}) })
.then((r) => r.targetId) .then((r) => r.targetId)
.catch(() => null); .catch(() => null);
@@ -645,10 +651,10 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
if (err instanceof SsrFBlockedError) { if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message }; return { status: 400, message: err.message };
} }
const msg = String(err); if (err instanceof InvalidBrowserNavigationUrlError) {
if (msg.includes("Invalid URL:")) { return { status: 400, message: err.message };
return { status: 400, message: msg };
} }
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) { if (msg.includes("ambiguous target id prefix")) {
return { status: 409, message: "ambiguous target id prefix" }; return { status: 409, message: "ambiguous target id prefix" };
} }

View File

@@ -32,6 +32,20 @@ describe("browser control server", () => {
const body = (await result.json()) as { error: string }; const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found"); expect(body.error).toContain("not found");
}); });
it("POST /tabs/open returns 400 for invalid URLs", async () => {
await startBrowserControlServerFromConfig();
const base = getBrowserControlServerBaseUrl();
const result = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "not a url" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("Invalid URL:");
});
}); });
describe("profile CRUD endpoints", () => { describe("profile CRUD endpoints", () => {