fix(security): harden browser SSRF defaults and migrate legacy key

This commit is contained in:
Peter Steinberger 2026-02-24 01:51:44 +00:00
parent 8779b523dc
commit 5eb72ab769
24 changed files with 334 additions and 20 deletions

View File

@ -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.

View File

@ -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`).

View File

@ -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

View File

@ -852,6 +852,30 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
- Disable browser proxy routing when you dont 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)
OpenClaws 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:

View File

@ -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

View File

@ -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.

View File

@ -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",
);
});
});

View File

@ -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<string, CacheEntry<Record<string, unknown>>>();
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<string> {
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;

View File

@ -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({});
});
});

View File

@ -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 } : {}),
};

View File

@ -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();
});
});

View File

@ -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<void> {
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);
}
}

View File

@ -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

View File

@ -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: {

View File

@ -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,
};

View File

@ -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).",
);
});
});

View File

@ -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<string, unknown> = { ...rawSsrFPolicy };
delete nextSsrFPolicy.allowPrivateNetwork;
if (resolvedDangerousAllowPrivateNetwork !== undefined) {
nextSsrFPolicy.dangerouslyAllowPrivateNetwork = resolvedDangerousAllowPrivateNetwork;
}
const migratedBrowser = { ...next.browser } as Record<string, unknown>;
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) {

View File

@ -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",

View File

@ -186,7 +186,9 @@ export const FIELD_HELP: Record<string, string> = {
"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":

View File

@ -427,6 +427,7 @@ export const FIELD_LABELS: Record<string, string> = {
"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)",

View File

@ -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"]

View File

@ -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(),
})

View File

@ -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);
});
});

View File

@ -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);