mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export type SsrFPolicy = {
|
||||
allowPrivateNetwork?: boolean;
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
allowRfc2544BenchmarkRange?: boolean;
|
||||
allowIpv6UniqueLocalRange?: boolean;
|
||||
allowedHostnames?: string[];
|
||||
hostnameAllowlist?: string[];
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -354,6 +354,7 @@ export const ToolsWebFetchSchema = z
|
||||
ssrfPolicy: z
|
||||
.object({
|
||||
allowRfc2544BenchmarkRange: z.boolean().optional(),
|
||||
allowIpv6UniqueLocalRange: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user