From 5eb72ab769517e31d96140f1aa66bd1a47a40c2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 01:51:44 +0000 Subject: [PATCH] fix(security): harden browser SSRF defaults and migrate legacy key --- CHANGELOG.md | 2 + docs/gateway/configuration-reference.md | 10 ++++ docs/gateway/doctor.md | 1 + docs/gateway/security/index.md | 24 ++++++++++ docs/tools/browser.md | 23 +++++++++ docs/tools/web.md | 2 + src/agents/tools/web-search.redirect.test.ts | 47 +++++++++++++++++++ src/agents/tools/web-search.ts | 18 +++++-- src/browser/config.test.ts | 17 +++++-- src/browser/config.ts | 17 +++++-- src/browser/navigation-guard.test.ts | 19 ++++++++ src/browser/navigation-guard.ts | 29 ++++++++++++ src/browser/pw-session.ts | 13 ++++- src/browser/pw-tools-core.snapshot.ts | 13 ++++- src/browser/server-context.ts | 6 ++- .../doctor-legacy-config.migrations.test.ts | 35 ++++++++++++++ src/commands/doctor-legacy-config.ts | 45 ++++++++++++++++++ src/config/schema.help.quality.test.ts | 1 + src/config/schema.help.ts | 4 +- src/config/schema.labels.ts | 1 + src/config/types.browser.ts | 4 +- src/config/zod-schema.ts | 1 + src/infra/net/ssrf.pinning.test.ts | 15 ++++++ src/infra/net/ssrf.ts | 7 ++- 24 files changed, 334 insertions(+), 20 deletions(-) create mode 100644 src/agents/tools/web-search.redirect.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 239d38a1442..9bd89866034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Docs: https://docs.openclaw.ai ### Breaking +- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically. + ### Fixes - Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 58629f1db15..5da1c71c09c 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2018,6 +2018,12 @@ See [Plugins](/tools/plugin). enabled: true, evaluateEnabled: true, defaultProfile: "chrome", + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, @@ -2033,6 +2039,10 @@ See [Plugins](/tools/plugin). ``` - `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`. +- `ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` when unset (trusted-network model). +- Set `ssrfPolicy.dangerouslyAllowPrivateNetwork: false` for strict public-only browser navigation. +- `ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias. +- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions. - Remote profiles are attach-only (start/stop/reset disabled). - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index f048435483a..4647cb8b411 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -125,6 +125,7 @@ Current migrations: - `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents) - `agent.model`/`allowedModels`/`modelAliases`/`modelFallbacks`/`imageModelFallbacks` → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` +- `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` ### 2b) OpenCode Zen provider overrides diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 880f869ca7f..0697ddf25b2 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -852,6 +852,30 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`). - Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. +### Browser SSRF policy (trusted-network default) + +OpenClaw’s browser network policy defaults to the trusted-operator model: private/internal destinations are allowed unless you explicitly disable them. + +- Default: `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: true` (implicit when unset). +- Legacy alias: `browser.ssrfPolicy.allowPrivateNetwork` is still accepted for compatibility. +- Strict mode: set `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork: false` to block private/internal/special-use destinations by default. +- In strict mode, use `hostnameAllowlist` (patterns like `*.example.com`) and `allowedHostnames` (exact host exceptions, including blocked names like `localhost`) for explicit exceptions. +- Navigation is checked before request and best-effort re-checked on the final `http(s)` URL after navigation to reduce redirect-based pivots. + +Example strict policy: + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], + }, + }, +} +``` + ## Per-agent access profiles (multi-agent) With multi-agent routing, each agent can have its own sandbox + tool policy: diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 4d8492f2151..13eaf3203f8 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -59,6 +59,12 @@ Browser settings live in `~/.openclaw/openclaw.json`. { browser: { enabled: true, // default: true + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: true, // default trusted-network mode + // allowPrivateNetwork: true, // legacy alias + // hostnameAllowlist: ["*.example.com", "example.com"], + // allowedHostnames: ["localhost"], + }, // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) @@ -86,6 +92,9 @@ Notes: - `cdpUrl` defaults to the relay port when unset. - `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks. - `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks. +- Browser navigation/open-tab is SSRF-guarded before navigation and best-effort re-checked on final `http(s)` URL after navigation. +- `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` defaults to `true` (trusted-network model). Set it to `false` for strict public-only browsing. +- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. - Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser. @@ -561,6 +570,20 @@ These are useful for “make the site behave like X” workflows: - Keep the Gateway/node host private (loopback or tailnet-only). - Remote CDP endpoints are powerful; tunnel and protect them. +Strict-mode example (block private/internal destinations by default): + +```json5 +{ + browser: { + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + hostnameAllowlist: ["*.example.com", "example.com"], + allowedHostnames: ["localhost"], // optional exact allow + }, + }, +} +``` + ## Troubleshooting For Linux-specific issues (especially snap Chromium), see diff --git a/docs/tools/web.md b/docs/tools/web.md index 85093ad62bd..0d48d746b5e 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -193,6 +193,8 @@ For a gateway install, put it in `~/.openclaw/.env`. - Citation URLs from Gemini grounding are automatically resolved from Google's redirect URLs to direct URLs. +- Redirect resolution uses the SSRF guard path (HEAD + redirect checks + http/https validation) before returning the final citation URL. +- This redirect resolver follows the trusted-network model (private/internal networks allowed by default) to match Gateway operator trust assumptions. - The default model (`gemini-2.5-flash`) is fast and cost-effective. Any Gemini model that supports grounding can be used. diff --git a/src/agents/tools/web-search.redirect.test.ts b/src/agents/tools/web-search.redirect.test.ts new file mode 100644 index 00000000000..b717c85e9a7 --- /dev/null +++ b/src/agents/tools/web-search.redirect.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ + fetchWithSsrFGuardMock: vi.fn(), +})); + +vi.mock("../../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +import { __testing } from "./web-search.js"; + +describe("web_search redirect resolution hardening", () => { + const { resolveRedirectUrl } = __testing; + + beforeEach(() => { + fetchWithSsrFGuardMock.mockReset(); + }); + + it("resolves redirects via SSRF-guarded HEAD requests", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValue({ + response: new Response(null, { status: 200 }), + finalUrl: "https://example.com/final", + release, + }); + + const resolved = await resolveRedirectUrl("https://example.com/start"); + expect(resolved).toBe("https://example.com/final"); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://example.com/start", + timeoutMs: 5000, + init: { method: "HEAD" }, + policy: { dangerouslyAllowPrivateNetwork: true }, + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("falls back to the original URL when guarded resolution fails", async () => { + fetchWithSsrFGuardMock.mockRejectedValue(new Error("blocked")); + await expect(resolveRedirectUrl("https://example.com/start")).resolves.toBe( + "https://example.com/start", + ); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 54845f8a042..d2da64e281a 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -1,6 +1,7 @@ import { Type } from "@sinclair/typebox"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js"; import { defaultRuntime } from "../../runtime.js"; import { wrapWebContent } from "../../security/external-content.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; @@ -42,6 +43,7 @@ const KIMI_WEB_SEARCH_TOOL = { const SEARCH_CACHE = new Map>>(); const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]); const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/; +const TRUSTED_NETWORK_SSRF_POLICY = { dangerouslyAllowPrivateNetwork: true } as const; const WebSearchSchema = Type.Object({ query: Type.String({ description: "Search query string." }), @@ -681,12 +683,17 @@ const REDIRECT_TIMEOUT_MS = 5000; */ async function resolveRedirectUrl(url: string): Promise { try { - const res = await fetch(url, { - method: "HEAD", - redirect: "follow", - signal: withTimeout(undefined, REDIRECT_TIMEOUT_MS), + const { finalUrl, release } = await fetchWithSsrFGuard({ + url, + init: { method: "HEAD" }, + timeoutMs: REDIRECT_TIMEOUT_MS, + policy: TRUSTED_NETWORK_SSRF_POLICY, }); - return res.url || url; + try { + return finalUrl || url; + } finally { + await release(); + } } catch { return url; } @@ -1345,4 +1352,5 @@ export const __testing = { resolveKimiModel, resolveKimiBaseUrl, extractKimiCitations, + resolveRedirectUrl, } as const; diff --git a/src/browser/config.test.ts b/src/browser/config.test.ts index 8d5cf358023..cef7e284d70 100644 --- a/src/browser/config.test.ts +++ b/src/browser/config.test.ts @@ -177,14 +177,25 @@ describe("browser config", () => { }, }); expect(resolved.ssrfPolicy).toEqual({ - allowPrivateNetwork: true, + dangerouslyAllowPrivateNetwork: true, allowedHostnames: ["localhost"], hostnameAllowlist: ["*.trusted.example"], }); }); - it("keeps browser SSRF policy undefined when not configured", () => { + it("defaults browser SSRF policy to trusted-network mode", () => { const resolved = resolveBrowserConfig({}); - expect(resolved.ssrfPolicy).toBeUndefined(); + expect(resolved.ssrfPolicy).toEqual({ + dangerouslyAllowPrivateNetwork: true, + }); + }); + + it("supports explicit strict mode by disabling private network access", () => { + const resolved = resolveBrowserConfig({ + ssrfPolicy: { + dangerouslyAllowPrivateNetwork: false, + }, + }); + expect(resolved.ssrfPolicy).toEqual({}); }); }); diff --git a/src/browser/config.ts b/src/browser/config.ts index d247fbe4ea8..c1e6cdc162f 100644 --- a/src/browser/config.ts +++ b/src/browser/config.ts @@ -75,19 +75,28 @@ function normalizeStringList(raw: string[] | undefined): string[] | undefined { function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined { const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork; + const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork; const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames); const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist); + const hasExplicitPrivateSetting = + allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined; + // Browser defaults to trusted-network mode unless explicitly disabled by policy. + const resolvedAllowPrivateNetwork = + dangerouslyAllowPrivateNetwork === true || + allowPrivateNetwork === true || + !hasExplicitPrivateSetting; if ( - allowPrivateNetwork === undefined && - allowedHostnames === undefined && - hostnameAllowlist === undefined + !resolvedAllowPrivateNetwork && + !hasExplicitPrivateSetting && + !allowedHostnames && + !hostnameAllowlist ) { return undefined; } return { - ...(allowPrivateNetwork === true ? { allowPrivateNetwork: true } : {}), + ...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}), ...(allowedHostnames ? { allowedHostnames } : {}), ...(hostnameAllowlist ? { hostnameAllowlist } : {}), }; diff --git a/src/browser/navigation-guard.test.ts b/src/browser/navigation-guard.test.ts index 3a096aac8d9..58ea7a4cd74 100644 --- a/src/browser/navigation-guard.test.ts +++ b/src/browser/navigation-guard.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js"; import { assertBrowserNavigationAllowed, + assertBrowserNavigationResultAllowed, InvalidBrowserNavigationUrlError, } from "./navigation-guard.js"; @@ -101,4 +102,22 @@ describe("browser navigation guard", () => { }), ).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError); }); + + it("validates final network URLs after navigation", async () => { + const lookupFn = createLookupFn("127.0.0.1"); + await expect( + assertBrowserNavigationResultAllowed({ + url: "http://private.test", + lookupFn, + }), + ).rejects.toBeInstanceOf(SsrFBlockedError); + }); + + it("ignores non-network browser-internal final URLs", async () => { + await expect( + assertBrowserNavigationResultAllowed({ + url: "chrome-error://chromewebdata/", + }), + ).resolves.toBeUndefined(); + }); }); diff --git a/src/browser/navigation-guard.ts b/src/browser/navigation-guard.ts index f9b9fe2268b..c089caceeb1 100644 --- a/src/browser/navigation-guard.ts +++ b/src/browser/navigation-guard.ts @@ -61,3 +61,32 @@ export async function assertBrowserNavigationAllowed( policy: opts.ssrfPolicy, }); } + +/** + * Best-effort post-navigation guard for final page URLs. + * Only validates network URLs (http/https) and about:blank to avoid false + * positives on browser-internal error pages (e.g. chrome-error://). + */ +export async function assertBrowserNavigationResultAllowed( + opts: { + url: string; + lookupFn?: LookupFn; + } & BrowserNavigationPolicyOptions, +): Promise { + const rawUrl = String(opts.url ?? "").trim(); + if (!rawUrl) { + return; + } + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return; + } + if ( + NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) || + isAllowedNonNetworkNavigationUrl(parsed) + ) { + await assertBrowserNavigationAllowed(opts); + } +} diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts index 08371f4bd2e..f07bcfeae98 100644 --- a/src/browser/pw-session.ts +++ b/src/browser/pw-session.ts @@ -12,7 +12,11 @@ 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, withBrowserNavigationPolicy } from "./navigation-guard.js"; +import { + assertBrowserNavigationAllowed, + assertBrowserNavigationResultAllowed, + withBrowserNavigationPolicy, +} from "./navigation-guard.js"; export type BrowserConsoleMessage = { type: string; @@ -738,13 +742,18 @@ export async function createPageViaPlaywright(opts: { // Navigate to the URL const targetUrl = opts.url.trim() || "about:blank"; if (targetUrl !== "about:blank") { + const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy); await assertBrowserNavigationAllowed({ url: targetUrl, - ...withBrowserNavigationPolicy(opts.ssrfPolicy), + ...navigationPolicy, }); await page.goto(targetUrl, { timeout: 30_000 }).catch(() => { // Navigation might fail for some URLs, but page is still created }); + await assertBrowserNavigationResultAllowed({ + url: page.url(), + ...navigationPolicy, + }); } // Get the targetId for this page diff --git a/src/browser/pw-tools-core.snapshot.ts b/src/browser/pw-tools-core.snapshot.ts index 076a33a1140..ff35f74139c 100644 --- a/src/browser/pw-tools-core.snapshot.ts +++ b/src/browser/pw-tools-core.snapshot.ts @@ -1,6 +1,10 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js"; import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js"; -import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js"; +import { + assertBrowserNavigationAllowed, + assertBrowserNavigationResultAllowed, + withBrowserNavigationPolicy, +} from "./navigation-guard.js"; import { buildRoleSnapshotFromAiSnapshot, buildRoleSnapshotFromAriaSnapshot, @@ -175,7 +179,12 @@ export async function navigateViaPlaywright(opts: { await page.goto(url, { timeout: Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)), }); - return { url: page.url() }; + const finalUrl = page.url(); + await assertBrowserNavigationResultAllowed({ + url: finalUrl, + ...withBrowserNavigationPolicy(opts.ssrfPolicy), + }); + return { url: finalUrl }; } export async function resizeViewportViaPlaywright(opts: { diff --git a/src/browser/server-context.ts b/src/browser/server-context.ts index fa6f5ac3aee..ce7c75a2d11 100644 --- a/src/browser/server-context.ts +++ b/src/browser/server-context.ts @@ -17,6 +17,7 @@ import { } from "./extension-relay.js"; import { assertBrowserNavigationAllowed, + assertBrowserNavigationResultAllowed, InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, } from "./navigation-guard.js"; @@ -176,6 +177,7 @@ function createProfileContext( const tabs = await listTabs().catch(() => [] as BrowserTab[]); const found = tabs.find((t) => t.targetId === createdViaCdp); if (found) { + await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts }); return found; } await new Promise((r) => setTimeout(r, 100)); @@ -214,10 +216,12 @@ function createProfileContext( } const profileState = getProfileState(); profileState.lastTargetId = created.id; + const resolvedUrl = created.url ?? url; + await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts }); return { targetId: created.id, title: created.title ?? "", - url: created.url ?? url, + url: resolvedUrl, wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl), type: created.type, }; diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index 2a188e2d657..a626371c8e3 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -222,4 +222,39 @@ describe("normalizeLegacyConfigValues", () => { "Moved channels.slack.streaming (boolean) → channels.slack.nativeStreaming (false).", ]); }); + + it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => { + const res = normalizeLegacyConfigValues({ + browser: { + ssrfPolicy: { + allowPrivateNetwork: true, + allowedHostnames: ["localhost"], + }, + }, + }); + + expect(res.config.browser?.ssrfPolicy?.allowPrivateNetwork).toBeUndefined(); + expect(res.config.browser?.ssrfPolicy?.dangerouslyAllowPrivateNetwork).toBe(true); + expect(res.config.browser?.ssrfPolicy?.allowedHostnames).toEqual(["localhost"]); + expect(res.changes).toContain( + "Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (true).", + ); + }); + + it("normalizes conflicting browser SSRF alias keys without changing effective behavior", () => { + const res = normalizeLegacyConfigValues({ + browser: { + ssrfPolicy: { + allowPrivateNetwork: true, + dangerouslyAllowPrivateNetwork: false, + }, + }, + }); + + expect(res.config.browser?.ssrfPolicy?.allowPrivateNetwork).toBeUndefined(); + expect(res.config.browser?.ssrfPolicy?.dangerouslyAllowPrivateNetwork).toBe(true); + expect(res.changes).toContain( + "Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (true).", + ); + }); }); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index c8043d5a7ad..6f84067ca62 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -293,6 +293,51 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): { normalizeProvider("slack"); normalizeProvider("discord"); + const normalizeBrowserSsrFPolicyAlias = () => { + const rawBrowser = next.browser; + if (!isRecord(rawBrowser)) { + return; + } + const rawSsrFPolicy = rawBrowser.ssrfPolicy; + if (!isRecord(rawSsrFPolicy) || !("allowPrivateNetwork" in rawSsrFPolicy)) { + return; + } + + const legacyAllowPrivateNetwork = rawSsrFPolicy.allowPrivateNetwork; + const currentDangerousAllowPrivateNetwork = rawSsrFPolicy.dangerouslyAllowPrivateNetwork; + + let resolvedDangerousAllowPrivateNetwork: unknown = currentDangerousAllowPrivateNetwork; + if ( + typeof legacyAllowPrivateNetwork === "boolean" || + typeof currentDangerousAllowPrivateNetwork === "boolean" + ) { + // Preserve runtime behavior while collapsing to the canonical key. + resolvedDangerousAllowPrivateNetwork = + legacyAllowPrivateNetwork === true || currentDangerousAllowPrivateNetwork === true; + } else if (currentDangerousAllowPrivateNetwork === undefined) { + resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork; + } + + const nextSsrFPolicy: Record = { ...rawSsrFPolicy }; + delete nextSsrFPolicy.allowPrivateNetwork; + if (resolvedDangerousAllowPrivateNetwork !== undefined) { + nextSsrFPolicy.dangerouslyAllowPrivateNetwork = resolvedDangerousAllowPrivateNetwork; + } + + const migratedBrowser = { ...next.browser } as Record; + migratedBrowser.ssrfPolicy = nextSsrFPolicy; + + next = { + ...next, + browser: migratedBrowser as OpenClawConfig["browser"], + }; + changes.push( + `Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (${String(resolvedDangerousAllowPrivateNetwork)}).`, + ); + }; + + normalizeBrowserSsrFPolicyAlias(); + const legacyAckReaction = cfg.messages?.ackReaction?.trim(); const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined; if (legacyAckReaction && hasWhatsAppConfig) { diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 7532cedae47..2980d62eac7 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -519,6 +519,7 @@ const FINAL_BACKLOG_TARGET_KEYS = [ "browser.snapshotDefaults.mode", "browser.ssrfPolicy", "browser.ssrfPolicy.allowPrivateNetwork", + "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork", "browser.ssrfPolicy.allowedHostnames", "browser.ssrfPolicy.hostnameAllowlist", "diagnostics.enabled", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 5bf9b2978c5..e5ff4708e09 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -186,7 +186,9 @@ export const FIELD_HELP: Record = { "browser.ssrfPolicy": "Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.", "browser.ssrfPolicy.allowPrivateNetwork": - "Allows access to private-network address ranges from browser/network tooling when SSRF protections are active. Keep disabled unless internal-network access is required and separately controlled.", + "Legacy alias for browser.ssrfPolicy.dangerouslyAllowPrivateNetwork. Prefer the dangerously-named key so risk intent is explicit.", + "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork": + "Allows access to private-network address ranges from browser tooling. Default is enabled for trusted-network operator setups; disable to enforce strict public-only resolution checks.", "browser.ssrfPolicy.allowedHostnames": "Explicit hostname allowlist exceptions for SSRF policy checks on browser/network requests. Keep this list minimal and review entries regularly to avoid stale broad access.", "browser.ssrfPolicy.hostnameAllowlist": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index aa007c23a9a..5b4130b24eb 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -427,6 +427,7 @@ export const FIELD_LABELS: Record = { "browser.snapshotDefaults.mode": "Browser Snapshot Mode", "browser.ssrfPolicy": "Browser SSRF Policy", "browser.ssrfPolicy.allowPrivateNetwork": "Browser Allow Private Network", + "browser.ssrfPolicy.dangerouslyAllowPrivateNetwork": "Browser Dangerously Allow Private Network", "browser.ssrfPolicy.allowedHostnames": "Browser Allowed Hostnames", "browser.ssrfPolicy.hostnameAllowlist": "Browser Hostname Allowlist", "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", diff --git a/src/config/types.browser.ts b/src/config/types.browser.ts index d411fb735a7..b251ef59e60 100644 --- a/src/config/types.browser.ts +++ b/src/config/types.browser.ts @@ -13,8 +13,10 @@ export type BrowserSnapshotDefaults = { mode?: "efficient"; }; export type BrowserSsrFPolicyConfig = { - /** If true, permit browser navigation to private/internal networks. Default: false */ + /** Legacy alias for private-network access. Prefer dangerouslyAllowPrivateNetwork. */ allowPrivateNetwork?: boolean; + /** If true, permit browser navigation to private/internal networks. Default: true */ + dangerouslyAllowPrivateNetwork?: boolean; /** * Explicitly allowed hostnames (exact-match), including blocked names like localhost. * Example: ["localhost", "metadata.internal"] diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d29ea965308..ea46725ee11 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -235,6 +235,7 @@ export const OpenClawSchema = z ssrfPolicy: z .object({ allowPrivateNetwork: z.boolean().optional(), + dangerouslyAllowPrivateNetwork: z.boolean().optional(), allowedHostnames: z.array(z.string()).optional(), hostnameAllowlist: z.array(z.string()).optional(), }) diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts index 392577d235a..660b8b6df6b 100644 --- a/src/infra/net/ssrf.pinning.test.ts +++ b/src/infra/net/ssrf.pinning.test.ts @@ -154,4 +154,19 @@ describe("ssrf pinning", () => { }); expect(lookup).toHaveBeenCalledTimes(1); }); + + it("accepts dangerouslyAllowPrivateNetwork as an allowPrivateNetwork alias", async () => { + const lookup = vi.fn(async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn; + + await expect( + resolvePinnedHostnameWithPolicy("localhost", { + lookupFn: lookup, + policy: { dangerouslyAllowPrivateNetwork: true }, + }), + ).resolves.toMatchObject({ + hostname: "localhost", + addresses: ["127.0.0.1"], + }); + expect(lookup).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 964e95b4dbd..3a4456e7839 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -30,6 +30,7 @@ export type LookupFn = typeof dnsLookup; export type SsrFPolicy = { allowPrivateNetwork?: boolean; + dangerouslyAllowPrivateNetwork?: boolean; allowedHostnames?: string[]; hostnameAllowlist?: string[]; }; @@ -60,6 +61,10 @@ function normalizeHostnameAllowlist(values?: string[]): string[] { ); } +function resolveAllowPrivateNetwork(policy?: SsrFPolicy): boolean { + return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true; +} + function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean { if (pattern.startsWith("*.")) { const suffix = pattern.slice(2); @@ -247,7 +252,7 @@ export async function resolvePinnedHostnameWithPolicy( throw new Error("Invalid hostname"); } - const allowPrivateNetwork = Boolean(params.policy?.allowPrivateNetwork); + const allowPrivateNetwork = resolveAllowPrivateNetwork(params.policy); const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames); const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist); const isExplicitAllowed = allowedHostnames.has(normalized);