mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 09:02:45 +00:00
fix(browser): unify SSRF guard path for navigation
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/browser/navigation-guard.test.ts
Normal file
40
src/browser/navigation-guard.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/browser/navigation-guard.ts
Normal file
28
src/browser/navigation-guard.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user