diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f3b01baf7e..1fcb5160f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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/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. - 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. diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 07f6d688cc5..281d7a6ec00 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -1,6 +1,7 @@ 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 { SsrFBlockedError } from "../infra/net/ssrf.js"; import { rawDataToString } from "../infra/ws.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; @@ -92,6 +93,61 @@ describe("cdp", () => { 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((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 () => { const wsPort = await startWsServerWithMessages((msg, socket) => { if (msg.method === "Runtime.enable") { diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 28fbb872c1f..58616fc2728 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -1,4 +1,6 @@ +import type { SsrFPolicy } from "../infra/net/ssrf.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"; @@ -85,7 +87,13 @@ export async function captureScreenshot(opts: { export async function createTargetViaCdp(opts: { cdpUrl: string; url: string; + ssrfPolicy?: SsrFPolicy; }): Promise<{ targetId: string }> { + await assertBrowserNavigationAllowed({ + url: opts.url, + ssrfPolicy: opts.ssrfPolicy, + }); + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( appendCdpPath(opts.cdpUrl, "/json/version"), 1500, diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index f19682abf11..8d6dc6fc421 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -182,4 +182,24 @@ describe("browser config", () => { }); 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(); + }); }); diff --git a/src/browser/config.ts b/src/browser/config.ts index ffb4a85bd83..d247fbe4ea8 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -6,6 +6,7 @@ import { DEFAULT_BROWSER_CONTROL_PORT, } from "../config/port-defaults.js"; import { isLoopbackHost } from "../gateway/net.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_ENABLED, @@ -31,6 +32,7 @@ export type ResolvedBrowserConfig = { attachOnly: boolean; defaultProfile: string; profiles: Record; + ssrfPolicy?: SsrFPolicy; extraArgs: string[]; }; @@ -61,6 +63,36 @@ function normalizeTimeoutMs(raw: number | undefined, fallback: number) { 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) { const trimmed = raw.trim(); const parsed = new URL(trimmed); @@ -200,6 +232,7 @@ export function resolveBrowserConfig( const extraArgs = Array.isArray(cfg?.extraArgs) ? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0) : []; + const ssrfPolicy = resolveBrowserSsrFPolicy(cfg); return { enabled, @@ -217,6 +250,7 @@ export function resolveBrowserConfig( attachOnly, defaultProfile, profiles, + ssrfPolicy, extraArgs, }; } diff --git a/src/browser/navigation-guard.test.ts b/src/browser/navigation-guard.test.ts new file mode 100644 index 00000000000..ce97e47754f --- /dev/null +++ b/src/browser/navigation-guard.test.ts @@ -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/); + }); +}); diff --git a/src/browser/navigation-guard.ts b/src/browser/navigation-guard.ts new file mode 100644 index 00000000000..6bcca27598e --- /dev/null +++ b/src/browser/navigation-guard.ts @@ -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 { + 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, + }); +} diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 4920af5b5b4..b8022a6bee8 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -8,9 +8,11 @@ import type { } from "playwright-core"; import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js"; import { normalizeCdpWsUrl } from "./cdp.js"; import { getChromeWebSocketUrl } from "./chrome.js"; +import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; export type BrowserConsoleMessage = { 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. * 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; title: string; url: string; @@ -732,6 +738,10 @@ export async function createPageViaPlaywright(opts: { cdpUrl: string; url: strin // Navigate to the URL const targetUrl = opts.url.trim() || "about:blank"; if (targetUrl !== "about:blank") { + await assertBrowserNavigationAllowed({ + url: targetUrl, + ssrfPolicy: opts.ssrfPolicy, + }); await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { // Navigation might fail for some URLs, but page is still created }); diff --git a/src/browser/pw-tools-core.snapshot.ts b/src/browser/pw-tools-core.snapshot.ts index 158b092a9d7..a1a59399fcc 100644 --- a/src/browser/pw-tools-core.snapshot.ts +++ b/src/browser/pw-tools-core.snapshot.ts @@ -1,4 +1,6 @@ +import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; +import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; import { buildRoleSnapshotFromAiSnapshot, buildRoleSnapshotFromAriaSnapshot, @@ -158,11 +160,16 @@ export async function navigateViaPlaywright(opts: { targetId?: string; url: string; timeoutMs?: number; + ssrfPolicy?: SsrFPolicy; }): Promise<{ url: string }> { const url = String(opts.url ?? "").trim(); if (!url) { throw new Error("url is required"); } + await assertBrowserNavigationAllowed({ + url, + ssrfPolicy: opts.ssrfPolicy, + }); const page = await getPageForTargetId(opts); ensurePageState(page); await page.goto(url, { diff --git a/src/browser/routes/agent.snapshot.ts b/src/browser/routes/agent.snapshot.ts index cb3c94c4417..45b367f4839 100644 --- a/src/browser/routes/agent.snapshot.ts +++ b/src/browser/routes/agent.snapshot.ts @@ -65,10 +65,12 @@ export function registerBrowserAgentSnapshotRoutes( targetId, feature: "navigate", run: async ({ cdpUrl, tab, pw }) => { + const ssrfPolicy = ctx.state().resolved.ssrfPolicy; const result = await pw.navigateViaPlaywright({ cdpUrl, targetId: tab.targetId, url, + ...(ssrfPolicy ? { ssrfPolicy } : {}), }); res.json({ ok: true, targetId: tab.targetId, ...result }); }, diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index 6e06937774c..f2a49b400c3 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -34,6 +34,7 @@ function makeState( headless: true, noSandbox: false, attachOnly: false, + ssrfPolicy: { allowPrivateNetwork: true }, defaultProfile: profile, profiles: { remote: { @@ -65,12 +66,12 @@ function createRemoteRouteHarness(fetchMock?: ReturnType) { describe("browser server-context remote profile tab operations", () => { it("uses Playwright tab operations when available", 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 () => ({ targetId: "T2", title: "Tab 2", - url: "https://b.example", + url: "http://127.0.0.1:3000", type: "page", })); const closePageByTargetIdViaPlaywright = vi.fn(async () => {}); @@ -86,7 +87,7 @@ describe("browser server-context remote profile tab operations", () => { const tabs = await remote.listTabs(); 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(state.profiles.get("remote")?.lastTargetId).toBe("T2"); @@ -102,21 +103,21 @@ describe("browser server-context remote profile tab operations", () => { const responses = [ // ensureTabAvailable() calls listTabs twice [ - { targetId: "A", title: "A", url: "https://a.example", type: "page" }, - { targetId: "B", title: "B", url: "https://b.example", type: "page" }, + { targetId: "A", title: "A", url: "https://example.com", type: "page" }, + { targetId: "B", title: "B", url: "https://www.example.com", type: "page" }, ], [ - { targetId: "A", title: "A", url: "https://a.example", type: "page" }, - { targetId: "B", title: "B", url: "https://b.example", type: "page" }, + { targetId: "A", title: "A", url: "https://example.com", type: "page" }, + { targetId: "B", title: "B", url: "https://www.example.com", type: "page" }, ], // second ensureTabAvailable() calls listTabs twice, order flips [ - { targetId: "B", title: "B", url: "https://b.example", type: "page" }, - { targetId: "A", title: "A", url: "https://a.example", type: "page" }, + { targetId: "B", title: "B", url: "https://www.example.com", type: "page" }, + { targetId: "A", title: "A", url: "https://example.com", type: "page" }, ], [ - { targetId: "B", title: "B", url: "https://b.example", type: "page" }, - { targetId: "A", title: "A", url: "https://a.example", type: "page" }, + { targetId: "B", title: "B", url: "https://www.example.com", 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 () => { 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 () => {}); @@ -195,7 +196,7 @@ describe("browser server-context remote profile tab operations", () => { { id: "T1", title: "Tab 1", - url: "https://a.example", + url: "https://example.com", webSocketDebuggerUrl: "wss://browserless.example/devtools/page/T1", type: "page", }, @@ -226,7 +227,7 @@ describe("browser server-context tab selection state", () => { { id: "CREATED", title: "New Tab", - url: "https://created.example", + url: "http://127.0.0.1:8080", webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED", type: "page", }, @@ -240,7 +241,7 @@ describe("browser server-context tab selection state", () => { const ctx = createBrowserRouteContext({ getState: () => state }); 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(state.profiles.get("openclaw")?.lastTargetId).toBe("CREATED"); }); diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index 01426b49aaa..e0f45c6f78e 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { SsrFBlockedError } from "../infra/net/ssrf.js"; import { fetchJson, fetchOk } from "./cdp.helpers.js"; import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js"; import { @@ -14,6 +15,7 @@ import { ensureChromeExtensionRelayServer, stopChromeExtensionRelayServer, } from "./extension-relay.js"; +import { assertBrowserNavigationAllowed } from "./navigation-guard.js"; import type { PwAiModule } from "./pw-ai-module.js"; import { getPwAiModule } from "./pw-ai-module.js"; import { @@ -130,13 +132,20 @@ function createProfileContext( }; const openTab = async (url: string): Promise => { + const ssrfPolicy = state().resolved.ssrfPolicy; + await assertBrowserNavigationAllowed({ url, ssrfPolicy }); + // For remote profiles, use Playwright's persistent connection to create tabs // This ensures the tab persists beyond a single request if (!profile.cdpIsLoopback) { const mod = await getPwAiModule({ mode: "strict" }); const createPageViaPlaywright = (mod as Partial | null)?.createPageViaPlaywright; 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(); profileState.lastTargetId = page.targetId; return { @@ -151,6 +160,7 @@ function createProfileContext( const createdViaCdp = await createTargetViaCdp({ cdpUrl: profile.cdpUrl, url, + ...(ssrfPolicy ? { ssrfPolicy } : {}), }) .then((r) => r.targetId) .catch(() => null); @@ -632,7 +642,13 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon const getDefaultContext = () => forProfile(); const mapTabError = (err: unknown) => { + if (err instanceof SsrFBlockedError) { + return { status: 400, message: err.message }; + } const msg = String(err); + if (msg.includes("Invalid URL:")) { + return { status: 400, message: msg }; + } if (msg.includes("ambiguous target id prefix")) { return { status: 409, message: "ambiguous target id prefix" }; } diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 5b27235ef49..e84abca0570 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -225,6 +225,10 @@ export const FIELD_LABELS: Record = { "browser.evaluateEnabled": "Browser Evaluate Enabled", "browser.snapshotDefaults": "Browser Snapshot Defaults", "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.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", "session.dmScope": "DM Session Scope", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index 18567d74586..d411fb735a7 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -12,6 +12,20 @@ export type BrowserSnapshotDefaults = { /** Default snapshot mode (applies when mode is not provided). */ 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 = { enabled?: boolean; /** If false, disable browser act:evaluate (arbitrary JS). Default: true */ @@ -38,6 +52,8 @@ export type BrowserConfig = { profiles?: Record; /** Default snapshot options (applied by the browser tool/CLI when unset). */ snapshotDefaults?: BrowserSnapshotDefaults; + /** SSRF policy for browser navigation/open-tab operations. */ + ssrfPolicy?: BrowserSsrFPolicyConfig; /** * Additional Chrome launch arguments. * Useful for stealth flags, window size overrides, or custom user-agent strings. diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index aa28f397a44..cc129240256 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -221,6 +221,14 @@ export const OpenClawSchema = z attachOnly: z.boolean().optional(), defaultProfile: z.string().optional(), 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 .record( z