fix(browser): unify SSRF guard path for navigation

This commit is contained in:
Peter Steinberger
2026-02-19 13:43:48 +01:00
parent 3c419b7bd3
commit 6195660b1a
15 changed files with 269 additions and 18 deletions

View File

@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
- Commands/Doctor: avoid rewriting invalid configs with new `gateway.auth.token` defaults during repair and only write when real config changes are detected, preventing accidental token duplication and backup churn. - Commands/Doctor: avoid rewriting invalid configs with new `gateway.auth.token` defaults during repair and only write when real config changes are detected, preventing accidental token duplication and backup churn.
- Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh. - Sandbox/Registry: serialize container and browser registry writes with shared file locks and atomic replacement to prevent lost updates and delete rollback races from desyncing `sandbox list`, `prune`, and `recreate --all`. Thanks @kexinoh.
- Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting. - Security/Exec: require `tools.exec.safeBins` binaries to resolve from trusted bin directories (system defaults plus gateway startup `PATH`) so PATH-hijacked trojan binaries cannot bypass allowlist checks. Thanks @jackhax for reporting.
- Security/Browser: route browser URL navigation through one SSRF-guarded validation path for tab-open/CDP-target/Playwright navigation flows and block private/metadata destinations by default (configurable via `browser.ssrfPolicy`). This ships in the next npm release. Thanks @dorjoos for reporting.
- Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code. - Cron/Webhooks: protect cron webhook POST delivery with SSRF-guarded outbound fetch (`fetchWithSsrFGuard`) to block private/metadata destinations before request dispatch. Thanks @Adam55A-code.
- Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax. - Security/Net: block SSRF bypass via NAT64 (`64:ff9b::/96`, `64:ff9b:1::/48`), 6to4 (`2002::/16`), and Teredo (`2001:0000::/32`) IPv6 transition addresses, and fail closed on IPv6 parse errors. Thanks @jackhax.

View File

@@ -1,6 +1,7 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws"; import { type WebSocket, WebSocketServer } from "ws";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
@@ -92,6 +93,61 @@ describe("cdp", () => {
expect(created.targetId).toBe("TARGET_123"); expect(created.targetId).toBe("TARGET_123");
}); });
it("blocks private navigation targets by default", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "http://127.0.0.1:9222",
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("allows private navigation targets when explicitly configured", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
expect(msg.params?.url).toBe("http://127.0.0.1:8080");
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_LOCAL" },
}),
);
});
httpServer = createServer((req, res) => {
if (req.url === "/json/version") {
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
}),
);
return;
}
res.statusCode = 404;
res.end("not found");
});
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
const httpPort = (httpServer.address() as { port: number }).port;
const created = await createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "http://127.0.0.1:8080",
ssrfPolicy: { allowPrivateNetwork: true },
});
expect(created.targetId).toBe("TARGET_LOCAL");
});
it("evaluates javascript via CDP", async () => { it("evaluates javascript via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => { const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Runtime.enable") { if (msg.method === "Runtime.enable") {

View File

@@ -1,4 +1,6 @@
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";
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js"; export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
@@ -85,7 +87,13 @@ export async function captureScreenshot(opts: {
export async function createTargetViaCdp(opts: { export async function createTargetViaCdp(opts: {
cdpUrl: string; cdpUrl: string;
url: string; url: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ targetId: string }> { }): Promise<{ targetId: string }> {
await assertBrowserNavigationAllowed({
url: opts.url,
ssrfPolicy: opts.ssrfPolicy,
});
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"), appendCdpPath(opts.cdpUrl, "/json/version"),
1500, 1500,

View File

@@ -182,4 +182,24 @@ describe("browser config", () => {
}); });
expect(resolved.extraArgs).toEqual([]); expect(resolved.extraArgs).toEqual([]);
}); });
it("resolves browser SSRF policy when configured", () => {
const resolved = resolveBrowserConfig({
ssrfPolicy: {
allowPrivateNetwork: true,
allowedHostnames: [" localhost ", ""],
hostnameAllowlist: [" *.trusted.example ", " "],
},
});
expect(resolved.ssrfPolicy).toEqual({
allowPrivateNetwork: true,
allowedHostnames: ["localhost"],
hostnameAllowlist: ["*.trusted.example"],
});
});
it("keeps browser SSRF policy undefined when not configured", () => {
const resolved = resolveBrowserConfig({});
expect(resolved.ssrfPolicy).toBeUndefined();
});
}); });

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_BROWSER_CONTROL_PORT, DEFAULT_BROWSER_CONTROL_PORT,
} from "../config/port-defaults.js"; } from "../config/port-defaults.js";
import { isLoopbackHost } from "../gateway/net.js"; import { isLoopbackHost } from "../gateway/net.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { import {
DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_COLOR,
DEFAULT_OPENCLAW_BROWSER_ENABLED, DEFAULT_OPENCLAW_BROWSER_ENABLED,
@@ -31,6 +32,7 @@ export type ResolvedBrowserConfig = {
attachOnly: boolean; attachOnly: boolean;
defaultProfile: string; defaultProfile: string;
profiles: Record<string, BrowserProfileConfig>; profiles: Record<string, BrowserProfileConfig>;
ssrfPolicy?: SsrFPolicy;
extraArgs: string[]; extraArgs: string[];
}; };
@@ -61,6 +63,36 @@ function normalizeTimeoutMs(raw: number | undefined, fallback: number) {
return value < 0 ? fallback : value; return value < 0 ? fallback : value;
} }
function normalizeStringList(raw: string[] | undefined): string[] | undefined {
if (!Array.isArray(raw) || raw.length === 0) {
return undefined;
}
const values = raw
.map((value) => value.trim())
.filter((value): value is string => value.length > 0);
return values.length > 0 ? values : undefined;
}
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork;
const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames);
const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist);
if (
allowPrivateNetwork === undefined &&
allowedHostnames === undefined &&
hostnameAllowlist === undefined
) {
return undefined;
}
return {
...(allowPrivateNetwork === true ? { allowPrivateNetwork: true } : {}),
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
};
}
export function parseHttpUrl(raw: string, label: string) { export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim(); const trimmed = raw.trim();
const parsed = new URL(trimmed); const parsed = new URL(trimmed);
@@ -200,6 +232,7 @@ export function resolveBrowserConfig(
const extraArgs = Array.isArray(cfg?.extraArgs) const extraArgs = Array.isArray(cfg?.extraArgs)
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
: []; : [];
const ssrfPolicy = resolveBrowserSsrFPolicy(cfg);
return { return {
enabled, enabled,
@@ -217,6 +250,7 @@ export function resolveBrowserConfig(
attachOnly, attachOnly,
defaultProfile, defaultProfile,
profiles, profiles,
ssrfPolicy,
extraArgs, extraArgs,
}; };
} }

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
describe("browser navigation guard", () => {
it("blocks private loopback URLs by default", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("allows non-network schemes", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "about:blank",
}),
).resolves.toBeUndefined();
});
it("allows localhost when explicitly allowed", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "http://localhost:3000",
ssrfPolicy: {
allowedHostnames: ["localhost"],
},
}),
).resolves.toBeUndefined();
});
it("rejects invalid URLs", async () => {
await expect(
assertBrowserNavigationAllowed({
url: "not a url",
}),
).rejects.toThrow(/Invalid URL/);
});
});

View File

@@ -0,0 +1,28 @@
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "../infra/net/ssrf.js";
const NETWORK_NAVIGATION_PROTOCOLS = new Set(["http:", "https:"]);
export async function assertBrowserNavigationAllowed(opts: {
url: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<void> {
const rawUrl = String(opts.url ?? "").trim();
if (!rawUrl) {
throw new Error("url is required");
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid URL: ${rawUrl}`);
}
if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
return;
}
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy: opts.ssrfPolicy,
});
}

View File

@@ -8,9 +8,11 @@ import type {
} from "playwright-core"; } from "playwright-core";
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
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";
export type BrowserConsoleMessage = { export type BrowserConsoleMessage = {
type: string; type: string;
@@ -716,7 +718,11 @@ export async function listPagesViaPlaywright(opts: { cdpUrl: string }): Promise<
* Used for remote profiles where HTTP-based /json/new is ephemeral. * Used for remote profiles where HTTP-based /json/new is ephemeral.
* Returns the new page's targetId and metadata. * Returns the new page's targetId and metadata.
*/ */
export async function createPageViaPlaywright(opts: { cdpUrl: string; url: string }): Promise<{ export async function createPageViaPlaywright(opts: {
cdpUrl: string;
url: string;
ssrfPolicy?: SsrFPolicy;
}): Promise<{
targetId: string; targetId: string;
title: string; title: string;
url: string; url: string;
@@ -732,6 +738,10 @@ export async function createPageViaPlaywright(opts: { cdpUrl: string; url: strin
// 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({
url: targetUrl,
ssrfPolicy: 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,4 +1,6 @@
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 { import {
buildRoleSnapshotFromAiSnapshot, buildRoleSnapshotFromAiSnapshot,
buildRoleSnapshotFromAriaSnapshot, buildRoleSnapshotFromAriaSnapshot,
@@ -158,11 +160,16 @@ export async function navigateViaPlaywright(opts: {
targetId?: string; targetId?: string;
url: string; url: string;
timeoutMs?: number; timeoutMs?: number;
ssrfPolicy?: SsrFPolicy;
}): Promise<{ url: string }> { }): Promise<{ url: string }> {
const url = String(opts.url ?? "").trim(); const url = String(opts.url ?? "").trim();
if (!url) { if (!url) {
throw new Error("url is required"); throw new Error("url is required");
} }
await assertBrowserNavigationAllowed({
url,
ssrfPolicy: opts.ssrfPolicy,
});
const page = await getPageForTargetId(opts); const page = await getPageForTargetId(opts);
ensurePageState(page); ensurePageState(page);
await page.goto(url, { await page.goto(url, {

View File

@@ -65,10 +65,12 @@ 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 } : {}),
}); });
res.json({ ok: true, targetId: tab.targetId, ...result }); res.json({ ok: true, targetId: tab.targetId, ...result });
}, },

View File

@@ -34,6 +34,7 @@ function makeState(
headless: true, headless: true,
noSandbox: false, noSandbox: false,
attachOnly: false, attachOnly: false,
ssrfPolicy: { allowPrivateNetwork: true },
defaultProfile: profile, defaultProfile: profile,
profiles: { profiles: {
remote: { remote: {
@@ -65,12 +66,12 @@ function createRemoteRouteHarness(fetchMock?: ReturnType<typeof vi.fn>) {
describe("browser server-context remote profile tab operations", () => { describe("browser server-context remote profile tab operations", () => {
it("uses Playwright tab operations when available", async () => { it("uses Playwright tab operations when available", async () => {
const listPagesViaPlaywright = vi.fn(async () => [ const listPagesViaPlaywright = vi.fn(async () => [
{ targetId: "T1", title: "Tab 1", url: "https://a.example", type: "page" }, { targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
]); ]);
const createPageViaPlaywright = vi.fn(async () => ({ const createPageViaPlaywright = vi.fn(async () => ({
targetId: "T2", targetId: "T2",
title: "Tab 2", title: "Tab 2",
url: "https://b.example", url: "http://127.0.0.1:3000",
type: "page", type: "page",
})); }));
const closePageByTargetIdViaPlaywright = vi.fn(async () => {}); const closePageByTargetIdViaPlaywright = vi.fn(async () => {});
@@ -86,7 +87,7 @@ describe("browser server-context remote profile tab operations", () => {
const tabs = await remote.listTabs(); const tabs = await remote.listTabs();
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]); expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
const opened = await remote.openTab("https://b.example"); 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");
@@ -102,21 +103,21 @@ describe("browser server-context remote profile tab operations", () => {
const responses = [ const responses = [
// ensureTabAvailable() calls listTabs twice // ensureTabAvailable() calls listTabs twice
[ [
{ targetId: "A", title: "A", url: "https://a.example", type: "page" }, { targetId: "A", title: "A", url: "https://example.com", type: "page" },
{ targetId: "B", title: "B", url: "https://b.example", type: "page" }, { targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
], ],
[ [
{ targetId: "A", title: "A", url: "https://a.example", type: "page" }, { targetId: "A", title: "A", url: "https://example.com", type: "page" },
{ targetId: "B", title: "B", url: "https://b.example", type: "page" }, { targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
], ],
// second ensureTabAvailable() calls listTabs twice, order flips // second ensureTabAvailable() calls listTabs twice, order flips
[ [
{ targetId: "B", title: "B", url: "https://b.example", type: "page" }, { targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
{ targetId: "A", title: "A", url: "https://a.example", type: "page" }, { targetId: "A", title: "A", url: "https://example.com", type: "page" },
], ],
[ [
{ targetId: "B", title: "B", url: "https://b.example", type: "page" }, { targetId: "B", title: "B", url: "https://www.example.com", type: "page" },
{ targetId: "A", title: "A", url: "https://a.example", type: "page" }, { targetId: "A", title: "A", url: "https://example.com", type: "page" },
], ],
]; ];
@@ -148,7 +149,7 @@ describe("browser server-context remote profile tab operations", () => {
it("uses Playwright focus for remote profiles when available", async () => { it("uses Playwright focus for remote profiles when available", async () => {
const listPagesViaPlaywright = vi.fn(async () => [ const listPagesViaPlaywright = vi.fn(async () => [
{ targetId: "T1", title: "Tab 1", url: "https://a.example", type: "page" }, { targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
]); ]);
const focusPageByTargetIdViaPlaywright = vi.fn(async () => {}); const focusPageByTargetIdViaPlaywright = vi.fn(async () => {});
@@ -195,7 +196,7 @@ describe("browser server-context remote profile tab operations", () => {
{ {
id: "T1", id: "T1",
title: "Tab 1", title: "Tab 1",
url: "https://a.example", url: "https://example.com",
webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1", webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1",
type: "page", type: "page",
}, },
@@ -226,7 +227,7 @@ describe("browser server-context tab selection state", () => {
{ {
id: "CREATED", id: "CREATED",
title: "New Tab", title: "New Tab",
url: "https://created.example", url: "http://127.0.0.1:8080",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page", type: "page",
}, },
@@ -240,7 +241,7 @@ describe("browser server-context tab selection state", () => {
const ctx = createBrowserRouteContext({ getState: () => state }); const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw"); const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("https://created.example"); 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");
}); });

View File

@@ -1,4 +1,5 @@
import fs from "node:fs"; import fs from "node:fs";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { fetchJson, fetchOk } from "./cdp.helpers.js"; import { fetchJson, fetchOk } from "./cdp.helpers.js";
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import { import {
@@ -14,6 +15,7 @@ import {
ensureChromeExtensionRelayServer, ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer, stopChromeExtensionRelayServer,
} from "./extension-relay.js"; } from "./extension-relay.js";
import { assertBrowserNavigationAllowed } 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 {
@@ -130,13 +132,20 @@ function createProfileContext(
}; };
const openTab = async (url: string): Promise<BrowserTab> => { const openTab = async (url: string): Promise<BrowserTab> => {
const ssrfPolicy = state().resolved.ssrfPolicy;
await assertBrowserNavigationAllowed({ url, ssrfPolicy });
// 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
if (!profile.cdpIsLoopback) { if (!profile.cdpIsLoopback) {
const mod = await getPwAiModule({ mode: "strict" }); const mod = await getPwAiModule({ mode: "strict" });
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright; const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
if (typeof createPageViaPlaywright === "function") { if (typeof createPageViaPlaywright === "function") {
const page = await createPageViaPlaywright({ cdpUrl: profile.cdpUrl, url }); const page = await createPageViaPlaywright({
cdpUrl: profile.cdpUrl,
url,
...(ssrfPolicy ? { ssrfPolicy } : {}),
});
const profileState = getProfileState(); const profileState = getProfileState();
profileState.lastTargetId = page.targetId; profileState.lastTargetId = page.targetId;
return { return {
@@ -151,6 +160,7 @@ function createProfileContext(
const createdViaCdp = await createTargetViaCdp({ const createdViaCdp = await createTargetViaCdp({
cdpUrl: profile.cdpUrl, cdpUrl: profile.cdpUrl,
url, url,
...(ssrfPolicy ? { ssrfPolicy } : {}),
}) })
.then((r) => r.targetId) .then((r) => r.targetId)
.catch(() => null); .catch(() => null);
@@ -632,7 +642,13 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
const getDefaultContext = () => forProfile(); const getDefaultContext = () => forProfile();
const mapTabError = (err: unknown) => { const mapTabError = (err: unknown) => {
if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message };
}
const msg = String(err); const msg = String(err);
if (msg.includes("Invalid URL:")) {
return { status: 400, message: msg };
}
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

@@ -225,6 +225,10 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.evaluateEnabled": "Browser Evaluate Enabled", "browser.evaluateEnabled": "Browser Evaluate Enabled",
"browser.snapshotDefaults": "Browser Snapshot Defaults", "browser.snapshotDefaults": "Browser Snapshot Defaults",
"browser.snapshotDefaults.mode": "Browser Snapshot Mode", "browser.snapshotDefaults.mode": "Browser Snapshot Mode",
"browser.ssrfPolicy": "Browser SSRF Policy",
"browser.ssrfPolicy.allowPrivateNetwork": "Browser Allow Private Network",
"browser.ssrfPolicy.allowedHostnames": "Browser Allowed Hostnames",
"browser.ssrfPolicy.hostnameAllowlist": "Browser Hostname Allowlist",
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
"session.dmScope": "DM Session Scope", "session.dmScope": "DM Session Scope",

View File

@@ -12,6 +12,20 @@ export type BrowserSnapshotDefaults = {
/** Default snapshot mode (applies when mode is not provided). */ /** Default snapshot mode (applies when mode is not provided). */
mode?: "efficient"; mode?: "efficient";
}; };
export type BrowserSsrFPolicyConfig = {
/** If true, permit browser navigation to private/internal networks. Default: false */
allowPrivateNetwork?: boolean;
/**
* Explicitly allowed hostnames (exact-match), including blocked names like localhost.
* Example: ["localhost", "metadata.internal"]
*/
allowedHostnames?: string[];
/**
* Hostname allowlist patterns for browser navigation.
* Supports exact hosts and "*.example.com" wildcard subdomains.
*/
hostnameAllowlist?: string[];
};
export type BrowserConfig = { export type BrowserConfig = {
enabled?: boolean; enabled?: boolean;
/** If false, disable browser act:evaluate (arbitrary JS). Default: true */ /** If false, disable browser act:evaluate (arbitrary JS). Default: true */
@@ -38,6 +52,8 @@ export type BrowserConfig = {
profiles?: Record<string, BrowserProfileConfig>; profiles?: Record<string, BrowserProfileConfig>;
/** Default snapshot options (applied by the browser tool/CLI when unset). */ /** Default snapshot options (applied by the browser tool/CLI when unset). */
snapshotDefaults?: BrowserSnapshotDefaults; snapshotDefaults?: BrowserSnapshotDefaults;
/** SSRF policy for browser navigation/open-tab operations. */
ssrfPolicy?: BrowserSsrFPolicyConfig;
/** /**
* Additional Chrome launch arguments. * Additional Chrome launch arguments.
* Useful for stealth flags, window size overrides, or custom user-agent strings. * Useful for stealth flags, window size overrides, or custom user-agent strings.

View File

@@ -221,6 +221,14 @@ export const OpenClawSchema = z
attachOnly: z.boolean().optional(), attachOnly: z.boolean().optional(),
defaultProfile: z.string().optional(), defaultProfile: z.string().optional(),
snapshotDefaults: BrowserSnapshotDefaultsSchema, snapshotDefaults: BrowserSnapshotDefaultsSchema,
ssrfPolicy: z
.object({
allowPrivateNetwork: z.boolean().optional(),
allowedHostnames: z.array(z.string()).optional(),
hostnameAllowlist: z.array(z.string()).optional(),
})
.strict()
.optional(),
profiles: z profiles: z
.record( .record(
z z