fix(ssrf): allow IPv6 fake-ip SSRF opt-in

Allow trusted fake-IP proxy stacks to opt into IPv6 unique-local SSRF resolution without opening broader private-network access.
This commit is contained in:
Jeff
2026-04-29 21:31:17 +02:00
committed by GitHub
parent cd00a6d6dd
commit 9b6670d5c9
18 changed files with 203 additions and 9 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Web fetch: add a documented `tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` opt-in and thread it through cache keys and DNS/IP checks so trusted fake-IP proxy stacks using `fc00::/7` can work without broad private-network access. Fixes #74351. Thanks @jeffrey701.
- Anthropic/Meridian: preserve text and thinking content seeded on `content_block_start` in anthropic-messages streams, so `[thinking, text]` replies no longer persist as empty turns or trigger empty-response fallbacks. Fixes #74410. Thanks @vyctorbrzezowski.
- Media: include redacted per-attempt resize failures and resolved model input capabilities in vision-pipeline errors so ARM64 image failures are diagnosable without closing the remaining routing investigation. Refs #74552. Thanks @1yihui.
- Control UI/i18n: route zh-CN agent, debug, channel-refresh, and exec-approval copy through the locale source while preserving the English `Cron Jobs` agent tab label and the security-audit command styling. Carries forward #39692 repair context. Thanks @hepeng154833488 and @vincentkoc.

View File

@@ -1,4 +1,4 @@
7436d39dbbe5fb2642f9036198572d021e5a56daaecb207e5a1a21838730bd02 config-baseline.json
c481235c42b8845c36eb92923bbd4d00ce9e417955f0a4b40a02f5ba0842a432 config-baseline.core.json
b6640810820e0f54631e8006fa35798f84139b162ee472d150994571b730226a config-baseline.json
d63d3aa51c0c38a315cadbff01715844b73ecc35909b6bbb6cd318af59f3d2cc config-baseline.core.json
9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json
0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json
c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json

View File

@@ -74,6 +74,10 @@ Truncate output to this many characters.
maxRedirects: 3,
readability: true, // use Readability extraction
userAgent: "Mozilla/5.0 ...", // override User-Agent
ssrfPolicy: {
allowRfc2544BenchmarkRange: true, // opt-in for trusted fake-IP proxies using 198.18.0.0/15
allowIpv6UniqueLocalRange: true, // opt-in for trusted fake-IP proxies using fc00::/7
},
},
},
},
@@ -140,6 +144,10 @@ Current runtime behavior:
- Response body is capped at `maxResponseBytes` before parsing; oversized
responses are truncated with a warning
- Private/internal hostnames are blocked
- `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` and
`tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` are narrow opt-ins
for trusted fake-IP proxy stacks; leave them unset unless your proxy owns
those synthetic ranges and enforces its own destination policy
- Redirects are checked and limited by `maxRedirects`
- `web_fetch` is best-effort -- some sites need the [Web Browser](/tools/browser)

View File

@@ -2,6 +2,7 @@ export type SsrFPolicy = {
allowPrivateNetwork?: boolean;
dangerouslyAllowPrivateNetwork?: boolean;
allowRfc2544BenchmarkRange?: boolean;
allowIpv6UniqueLocalRange?: boolean;
allowedHostnames?: string[];
hostnameAllowlist?: string[];
};

View File

@@ -36,7 +36,7 @@ function setMockFetch(
function createWebFetchToolForTest(params?: {
firecrawlApiKey?: string;
ssrfPolicy?: { allowRfc2544BenchmarkRange?: boolean };
ssrfPolicy?: { allowRfc2544BenchmarkRange?: boolean; allowIpv6UniqueLocalRange?: boolean };
cacheTtlMinutes?: number;
}) {
return createWebFetchTool({
@@ -178,4 +178,28 @@ describe("web_fetch SSRF protection", () => {
const stricterTool = createWebFetchToolForTest({ cacheTtlMinutes: 1 });
await expectBlockedUrl(stricterTool, url, /private|internal|blocked/i);
});
it("allows IPv6 unique-local DNS answers only when web_fetch ssrfPolicy opts in", async () => {
const url = "https://fake-ip.test/file";
lookupMock.mockResolvedValue([{ address: "fc00::153", family: 6 }]);
const deniedTool = createWebFetchToolForTest({ cacheTtlMinutes: 1 });
await expectBlockedUrl(deniedTool, url, /private|internal|blocked/i);
const fetchSpy = setMockFetch().mockResolvedValue(textResponse("ipv6 ula ok"));
const allowedTool = createWebFetchToolForTest({
ssrfPolicy: { allowIpv6UniqueLocalRange: true },
cacheTtlMinutes: 1,
});
const allowed = await allowedTool?.execute?.("call", { url });
expect(allowed?.details).toMatchObject({
status: 200,
extractor: "raw",
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const stricterTool = createWebFetchToolForTest({ cacheTtlMinutes: 1 });
await expectBlockedUrl(stricterTool, url, /private|internal|blocked/i);
});
});

View File

@@ -1,6 +1,6 @@
import { Type } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { SsrFBlockedError, type LookupFn } from "../../infra/net/ssrf.js";
import { SsrFBlockedError, type LookupFn, type SsrFPolicy } from "../../infra/net/ssrf.js";
import { logDebug } from "../../logger.js";
import type { RuntimeWebFetchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js";
@@ -274,6 +274,7 @@ type WebFetchRuntimeParams = {
config?: OpenClawConfig;
ssrfPolicy?: {
allowRfc2544BenchmarkRange?: boolean;
allowIpv6UniqueLocalRange?: boolean;
};
lookupFn?: LookupFn;
resolveProviderFallback: () => Promise<WebFetchProviderFallback>;
@@ -389,8 +390,16 @@ async function maybeFetchProviderWebFetchPayload(
async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string, unknown>> {
const allowRfc2544BenchmarkRange = params.ssrfPolicy?.allowRfc2544BenchmarkRange === true;
const allowIpv6UniqueLocalRange = params.ssrfPolicy?.allowIpv6UniqueLocalRange === true;
const ssrfPolicy: SsrFPolicy | undefined =
allowRfc2544BenchmarkRange || allowIpv6UniqueLocalRange
? {
...(allowRfc2544BenchmarkRange ? { allowRfc2544BenchmarkRange } : {}),
...(allowIpv6UniqueLocalRange ? { allowIpv6UniqueLocalRange } : {}),
}
: undefined;
const cacheKey = normalizeCacheKey(
`fetch:${params.url}:${params.extractMode}:${params.maxChars}${allowRfc2544BenchmarkRange ? ":allow-rfc2544" : ""}`,
`fetch:${params.url}:${params.extractMode}:${params.maxChars}${allowRfc2544BenchmarkRange ? ":allow-rfc2544" : ""}${allowIpv6UniqueLocalRange ? ":allow-ipv6-ula" : ""}`,
);
const cached = readCache(FETCH_CACHE, cacheKey);
if (cached) {
@@ -418,7 +427,7 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string
maxRedirects: params.maxRedirects,
timeoutSeconds: params.timeoutSeconds,
lookupFn: params.lookupFn,
policy: allowRfc2544BenchmarkRange ? { allowRfc2544BenchmarkRange } : undefined,
policy: ssrfPolicy,
init: {
headers: {
Accept: "text/markdown, text/html;q=0.9, */*;q=0.1",

View File

@@ -8638,6 +8638,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"Allow RFC 2544 benchmark-range IPs (198.18.0.0/15) for fake-IP proxy compatibility such as Clash or Surge.",
},
allowIpv6UniqueLocalRange: {
type: "boolean",
title: "Web Fetch Allow IPv6 Unique Local Range",
description:
"Allow IPv6 Unique Local Addresses (fc00::/7) for trusted fake-IP proxy compatibility such as sing-box, Clash, or Surge.",
},
},
additionalProperties: false,
title: "Web Fetch SSRF Policy",
@@ -25717,6 +25723,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Allow RFC 2544 benchmark-range IPs (198.18.0.0/15) for fake-IP proxy compatibility such as Clash or Surge.",
tags: ["access", "tools"],
},
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange": {
label: "Web Fetch Allow IPv6 Unique Local Range",
help: "Allow IPv6 Unique Local Addresses (fc00::/7) for trusted fake-IP proxy compatibility such as sing-box, Clash, or Surge.",
tags: ["access", "tools"],
},
"gateway.controlUi.basePath": {
label: "Control UI Base Path",
help: "Optional URL prefix where the Control UI is served (e.g. /openclaw).",

View File

@@ -830,6 +830,8 @@ export const FIELD_HELP: Record<string, string> = {
"Scoped SSRF policy overrides for web_fetch. Keep this narrow and opt in only for known local-network proxy environments.",
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange":
"Allow RFC 2544 benchmark-range IPs (198.18.0.0/15) for fake-IP proxy compatibility such as Clash or Surge.",
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange":
"Allow IPv6 Unique Local Addresses (fc00::/7) for trusted fake-IP proxy compatibility such as sing-box, Clash, or Surge.",
models:
"Model catalog root for provider definitions, merge/replace behavior, and optional Bedrock discovery integration. Keep provider definitions explicit and validated before relying on production failover paths.",
"models.mode":

View File

@@ -300,6 +300,7 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.web.fetch.ssrfPolicy": "Web Fetch SSRF Policy",
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange":
"Web Fetch Allow RFC 2544 Benchmark Range",
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange": "Web Fetch Allow IPv6 Unique Local Range",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode",

View File

@@ -315,6 +315,7 @@ describe("config schema", () => {
fetch: {
ssrfPolicy: {
allowRfc2544BenchmarkRange: true,
allowIpv6UniqueLocalRange: true,
},
},
},
@@ -322,6 +323,7 @@ describe("config schema", () => {
expect(parsed?.web?.fetch?.ssrfPolicy).toEqual({
allowRfc2544BenchmarkRange: true,
allowIpv6UniqueLocalRange: true,
});
});

View File

@@ -579,6 +579,8 @@ export type ToolsConfig = {
ssrfPolicy?: {
/** Allow RFC 2544 benchmark range IPs (198.18.0.0/15) for fake-IP proxy compatibility (e.g., Clash TUN mode, Surge). */
allowRfc2544BenchmarkRange?: boolean;
/** Allow IPv6 Unique Local Addresses (fc00::/7) for trusted fake-IP proxy compatibility. */
allowIpv6UniqueLocalRange?: boolean;
};
};
};

View File

@@ -354,6 +354,7 @@ export const ToolsWebFetchSchema = z
ssrfPolicy: z
.object({
allowRfc2544BenchmarkRange: z.boolean().optional(),
allowIpv6UniqueLocalRange: z.boolean().optional(),
})
.strict()
.optional(),

View File

@@ -157,6 +157,26 @@ describe("isBlockedHostnameOrIp", () => {
expect(isBlockedHostnameOrIp(value, policy)).toBe(expected);
});
// #74351: fake-ip proxy stacks (sing-box / Clash / Surge) resolve foreign
// domains to BOTH IPv4 198.18.0.0/15 AND IPv6 fc00::/7 simultaneously.
// The policy must let operators opt into the IPv6 ULA range
// independently of the IPv4 benchmark exemption.
it.each([
["fc00::1", undefined, true],
["fc00::1", { allowIpv6UniqueLocalRange: true }, false],
["fdff::dead:beef", { allowIpv6UniqueLocalRange: true }, false],
// Other reserved IPv6 ranges stay blocked even with the new flag set —
// the exemption is scoped to ULA, not "any reserved IPv6".
["::1", { allowIpv6UniqueLocalRange: true }, true],
["fec0::1", { allowIpv6UniqueLocalRange: true }, true],
// The flag is independent of the IPv4 benchmark flag — neither
// implies the other.
["198.18.0.1", { allowIpv6UniqueLocalRange: true }, true],
["fc00::1", { allowRfc2544BenchmarkRange: true }, true],
] as const)("applies IPv6 unique-local policy for %s", (value, policy, expected) => {
expect(isBlockedHostnameOrIp(value, policy)).toBe(expected);
});
it.each(["0177.0.0.1", "8.8.2056", "127.1", "2130706433"])(
"blocks legacy IPv4 literal %s",
(address) => {
@@ -194,5 +214,19 @@ describe("isSameSsrFPolicy", () => {
{ dangerouslyAllowPrivateNetwork: true, allowRfc2544BenchmarkRange: true },
),
).toBe(false);
// #74351: the new `allowIpv6UniqueLocalRange` flag must participate in
// semantic equality. Otherwise consumers caching policy objects keyed by
// `isSameSsrFPolicy` would silently reuse a stale fc00::/7-blocking
// policy after the flag was flipped on.
expect(
isSameSsrFPolicy(
{ allowPrivateNetwork: true },
{ allowPrivateNetwork: true, allowIpv6UniqueLocalRange: true },
),
).toBe(false);
expect(
isSameSsrFPolicy({ allowIpv6UniqueLocalRange: true }, { allowIpv6UniqueLocalRange: true }),
).toBe(true);
});
});

View File

@@ -7,6 +7,7 @@ import {
isBlockedSpecialUseIpv6Address,
isCanonicalDottedDecimalIPv4,
type Ipv4SpecialUseBlockOptions,
type Ipv6SpecialUseBlockOptions,
isIpv4Address,
isLegacyIpv4Literal,
parseCanonicalIpAddress,
@@ -40,6 +41,14 @@ export type SsrFPolicy = {
allowPrivateNetwork?: boolean;
dangerouslyAllowPrivateNetwork?: boolean;
allowRfc2544BenchmarkRange?: boolean;
/**
* Exempt addresses in `fc00::/7` (IPv6 Unique Local Address block, RFC 4193)
* from the SSRF private-IP block. Companion to
* `allowRfc2544BenchmarkRange` for fake-ip proxy stacks (sing-box, Clash,
* Surge) that resolve foreign domains to ULA addresses alongside the IPv4
* 198.18.0.0/15 range. See #74351.
*/
allowIpv6UniqueLocalRange?: boolean;
allowedHostnames?: string[];
hostnameAllowlist?: string[];
};
@@ -61,6 +70,7 @@ function normalizeSsrFPolicyForComparison(policy?: SsrFPolicy) {
allowPrivateNetwork: policy.allowPrivateNetwork === true,
dangerouslyAllowPrivateNetwork: policy.dangerouslyAllowPrivateNetwork === true,
allowRfc2544BenchmarkRange: policy.allowRfc2544BenchmarkRange === true,
allowIpv6UniqueLocalRange: policy.allowIpv6UniqueLocalRange === true,
allowedHostnames: normalizeSsrFPolicyHostnames(policy.allowedHostnames),
hostnameAllowlist: [...normalizeHostnameAllowlist(policy.hostnameAllowlist)].toSorted(),
};
@@ -132,6 +142,12 @@ function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseB
};
}
function resolveIpv6SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv6SpecialUseBlockOptions {
return {
allowUniqueLocalRange: policy?.allowIpv6UniqueLocalRange === true,
};
}
export function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
if (pattern.startsWith("*.")) {
const suffix = pattern.slice(2);
@@ -170,13 +186,14 @@ export function isPrivateIpAddress(address: string, policy?: SsrFPolicy): boolea
return false;
}
const blockOptions = resolveIpv4SpecialUseBlockOptions(policy);
const ipv6BlockOptions = resolveIpv6SpecialUseBlockOptions(policy);
const strictIp = parseCanonicalIpAddress(normalized);
if (strictIp) {
if (isIpv4Address(strictIp)) {
return isBlockedSpecialUseIpv4Address(strictIp, blockOptions);
}
if (isBlockedSpecialUseIpv6Address(strictIp)) {
if (isBlockedSpecialUseIpv6Address(strictIp, ipv6BlockOptions)) {
return true;
}
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(strictIp);

View File

@@ -147,6 +147,7 @@ describe("mergeSsrFPolicies", () => {
{
dangerouslyAllowPrivateNetwork: true,
allowRfc2544BenchmarkRange: true,
allowIpv6UniqueLocalRange: true,
allowedHostnames: ["api.example.com", "cdn.example.com"],
hostnameAllowlist: ["downloads.example.com", "assets.example.com"],
},
@@ -155,6 +156,7 @@ describe("mergeSsrFPolicies", () => {
allowPrivateNetwork: true,
dangerouslyAllowPrivateNetwork: true,
allowRfc2544BenchmarkRange: true,
allowIpv6UniqueLocalRange: true,
allowedHostnames: ["api.example.com", "cdn.example.com"],
hostnameAllowlist: ["downloads.example.com", "assets.example.com"],
});

View File

@@ -77,6 +77,9 @@ export function mergeSsrFPolicies(
if (policy.allowRfc2544BenchmarkRange) {
merged.allowRfc2544BenchmarkRange = true;
}
if (policy.allowIpv6UniqueLocalRange) {
merged.allowIpv6UniqueLocalRange = true;
}
if (policy.allowedHostnames?.length) {
merged.allowedHostnames = Array.from(
new Set([...(merged.allowedHostnames ?? []), ...policy.allowedHostnames]),

View File

@@ -3,6 +3,7 @@ import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js";
import {
extractEmbeddedIpv4FromIpv6,
isBlockedSpecialUseIpv4Address,
isBlockedSpecialUseIpv6Address,
isCanonicalDottedDecimalIPv4,
isCarrierGradeNatIpv4Address,
isIpInCidr,
@@ -103,4 +104,52 @@ describe("shared ip helpers", () => {
false,
);
});
it("blocks IPv6 unique-local addresses by default and exempts them on opt-in (#74351)", () => {
// fc00::/7 is the IPv6 ULA range. Sing-box / Clash / Surge fake-ip
// proxies resolve foreign domains here, alongside the IPv4 198.18.0.0/15
// benchmark range. Operators using those proxies need both ranges
// exempted to keep web_fetch working.
const ula = parseCanonicalIpAddress("fc00::1");
expect(ula?.kind()).toBe("ipv6");
if (!ula || !isIpv6Address(ula)) {
throw new Error("expected ipv6 fixture");
}
// Default policy (no options) must continue to block the ULA range.
expect(isBlockedSpecialUseIpv6Address(ula)).toBe(true);
expect(isBlockedSpecialUseIpv6Address(ula, {})).toBe(true);
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: false })).toBe(true);
// Opt-in flag — the only path the SSRF policy uses to thread fake-ip
// proxy intent through to the address classifier.
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: true })).toBe(false);
});
it("opt-in unique-local exemption does NOT bleed into other special-use IPv6 ranges (#74351)", () => {
// The exemption must be scoped: loopback (::1), unspecified (::), and
// multicast (ff00::/8) all stay blocked even when `allowUniqueLocalRange`
// is set, otherwise the flag silently widens the SSRF escape hatch
// beyond what operators opted into.
const loopback = parseCanonicalIpAddress("::1");
const multicast = parseCanonicalIpAddress("ff02::1");
const siteLocal = parseCanonicalIpAddress("fec0::1"); // deprecated fec0::/10
if (
!loopback ||
!isIpv6Address(loopback) ||
!multicast ||
!isIpv6Address(multicast) ||
!siteLocal ||
!isIpv6Address(siteLocal)
) {
throw new Error("expected ipv6 fixtures");
}
for (const options of [{}, { allowUniqueLocalRange: true }] as const) {
expect(isBlockedSpecialUseIpv6Address(loopback, options)).toBe(true);
expect(isBlockedSpecialUseIpv6Address(multicast, options)).toBe(true);
expect(isBlockedSpecialUseIpv6Address(siteLocal, options)).toBe(true);
}
});
});

View File

@@ -40,6 +40,24 @@ export type Ipv4SpecialUseBlockOptions = {
allowRfc2544BenchmarkRange?: boolean;
};
/**
* Per-call exemptions for `isBlockedSpecialUseIpv6Address`. Mirror of
* {@link Ipv4SpecialUseBlockOptions} for the IPv6 side. Currently only
* `allowUniqueLocalRange` is exposed (#74351); other reserved IPv6 ranges stay
* unconditionally blocked because they have no documented fake-ip / proxy
* use case.
*/
export type Ipv6SpecialUseBlockOptions = {
/**
* When true, exempt addresses in `fc00::/7` (the IPv6 Unique Local Address
* block, RFC 4193) from the SSRF private-IP block. Sing-box / Clash / Surge
* fake-ip implementations resolve foreign domains to ULA addresses
* alongside RFC 2544 benchmark IPv4 addresses, and operators using those
* proxy stacks need both ranges exempted to keep `web_fetch` working.
*/
allowUniqueLocalRange?: boolean;
};
const EMBEDDED_IPV4_SENTINEL_RULES: Array<{
matches: (parts: number[]) => boolean;
toHextets: (parts: number[]) => [high: number, low: number];
@@ -237,10 +255,19 @@ export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean {
return isBlockedSpecialUseIpv6Address(normalized);
}
export function isBlockedSpecialUseIpv6Address(address: ipaddr.IPv6): boolean {
export function isBlockedSpecialUseIpv6Address(
address: ipaddr.IPv6,
options: Ipv6SpecialUseBlockOptions = {},
): boolean {
// ipaddr.js returns "discard" at runtime for 100::/64, but its published
// TypeScript IPv6Range union omits that literal.
const range = address.range() as BlockedIpv6Range;
if (range === "uniqueLocal" && options.allowUniqueLocalRange === true) {
// Operators running fake-ip proxy stacks (sing-box, Clash, Surge) opt in
// to fc00::/7 reaching the network — same intent as
// `allowRfc2544BenchmarkRange` for the IPv4 side (#74351).
return false;
}
if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(range)) {
return true;
}