diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c0ba494d18..98ce537c872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index f2fa57a9c0e..9903f0ecc6f 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/tools/web-fetch.md b/docs/tools/web-fetch.md index 5211abd28fc..6dbf8d4f3a1 100644 --- a/docs/tools/web-fetch.md +++ b/docs/tools/web-fetch.md @@ -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) diff --git a/packages/memory-host-sdk/src/host/ssrf-policy.ts b/packages/memory-host-sdk/src/host/ssrf-policy.ts index 23d81905930..aefa20ebb3b 100644 --- a/packages/memory-host-sdk/src/host/ssrf-policy.ts +++ b/packages/memory-host-sdk/src/host/ssrf-policy.ts @@ -2,6 +2,7 @@ export type SsrFPolicy = { allowPrivateNetwork?: boolean; dangerouslyAllowPrivateNetwork?: boolean; allowRfc2544BenchmarkRange?: boolean; + allowIpv6UniqueLocalRange?: boolean; allowedHostnames?: string[]; hostnameAllowlist?: string[]; }; diff --git a/src/agents/tools/web-fetch.ssrf.test.ts b/src/agents/tools/web-fetch.ssrf.test.ts index fa1227e928b..db3c5477b22 100644 --- a/src/agents/tools/web-fetch.ssrf.test.ts +++ b/src/agents/tools/web-fetch.ssrf.test.ts @@ -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); + }); }); diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 1816ab1f9e5..63e8b23f12b 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -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; @@ -389,8 +390,16 @@ async function maybeFetchProviderWebFetchPayload( async function runWebFetch(params: WebFetchRuntimeParams): Promise> { 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 = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 7a59595c915..5a1f3451ec4 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -300,6 +300,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 7d3d91843d7..77ee73c8696 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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, }); }); diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 82253708c1e..9aae9945e92 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -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; }; }; }; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index be607c0ebb4..04368f09ede 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -354,6 +354,7 @@ export const ToolsWebFetchSchema = z ssrfPolicy: z .object({ allowRfc2544BenchmarkRange: z.boolean().optional(), + allowIpv6UniqueLocalRange: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/infra/net/ssrf.test.ts b/src/infra/net/ssrf.test.ts index 90cf7998c19..ffeca962b2a 100644 --- a/src/infra/net/ssrf.test.ts +++ b/src/infra/net/ssrf.test.ts @@ -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); }); }); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 33e5d63fb5d..f36e2f69c15 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -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); diff --git a/src/plugin-sdk/ssrf-policy.test.ts b/src/plugin-sdk/ssrf-policy.test.ts index 8ff09dccaaa..42e1f3cdb3f 100644 --- a/src/plugin-sdk/ssrf-policy.test.ts +++ b/src/plugin-sdk/ssrf-policy.test.ts @@ -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"], }); diff --git a/src/plugin-sdk/ssrf-policy.ts b/src/plugin-sdk/ssrf-policy.ts index ca49ccfe236..e216f3f3c0f 100644 --- a/src/plugin-sdk/ssrf-policy.ts +++ b/src/plugin-sdk/ssrf-policy.ts @@ -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]), diff --git a/src/shared/net/ip.test.ts b/src/shared/net/ip.test.ts index 16a103e76b4..82461043c98 100644 --- a/src/shared/net/ip.test.ts +++ b/src/shared/net/ip.test.ts @@ -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); + } + }); }); diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index 14872109ee8..c49b2875e04 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -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; }