fix(security): harden browser SSRF defaults and migrate legacy key
This commit is contained in:
parent
8779b523dc
commit
5eb72ab769
@ -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.
|
||||
|
||||
@ -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`).
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
47
src/agents/tools/web-search.redirect.test.ts
Normal file
47
src/agents/tools/web-search.redirect.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 } : {}),
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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).",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user