Skip to content

Commit 9b6670d

Browse files
authored
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.
1 parent cd00a6d commit 9b6670d

18 files changed

Lines changed: 203 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424

2525
### Fixes
2626

27+
- 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.
2728
- 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.
2829
- 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.
2930
- 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.
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
7436d39dbbe5fb2642f9036198572d021e5a56daaecb207e5a1a21838730bd02 config-baseline.json
2-
c481235c42b8845c36eb92923bbd4d00ce9e417955f0a4b40a02f5ba0842a432 config-baseline.core.json
1+
b6640810820e0f54631e8006fa35798f84139b162ee472d150994571b730226a config-baseline.json
2+
d63d3aa51c0c38a315cadbff01715844b73ecc35909b6bbb6cd318af59f3d2cc config-baseline.core.json
33
9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json
4-
0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json
4+
c4231c2194206547af8ad94342dc00aadb734f43cb49cc79d4c46bdbb80c3f95 config-baseline.plugin.json

docs/tools/web-fetch.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ Truncate output to this many characters.
7474
maxRedirects: 3,
7575
readability: true, // use Readability extraction
7676
userAgent: "Mozilla/5.0 ...", // override User-Agent
77+
ssrfPolicy: {
78+
allowRfc2544BenchmarkRange: true, // opt-in for trusted fake-IP proxies using 198.18.0.0/15
79+
allowIpv6UniqueLocalRange: true, // opt-in for trusted fake-IP proxies using fc00::/7
80+
},
7781
},
7882
},
7983
},
@@ -140,6 +144,10 @@ Current runtime behavior:
140144
- Response body is capped at `maxResponseBytes` before parsing; oversized
141145
responses are truncated with a warning
142146
- Private/internal hostnames are blocked
147+
- `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` and
148+
`tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange` are narrow opt-ins
149+
for trusted fake-IP proxy stacks; leave them unset unless your proxy owns
150+
those synthetic ranges and enforces its own destination policy
143151
- Redirects are checked and limited by `maxRedirects`
144152
- `web_fetch` is best-effort -- some sites need the [Web Browser](/tools/browser)
145153

packages/memory-host-sdk/src/host/ssrf-policy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type SsrFPolicy = {
22
allowPrivateNetwork?: boolean;
33
dangerouslyAllowPrivateNetwork?: boolean;
44
allowRfc2544BenchmarkRange?: boolean;
5+
allowIpv6UniqueLocalRange?: boolean;
56
allowedHostnames?: string[];
67
hostnameAllowlist?: string[];
78
};

src/agents/tools/web-fetch.ssrf.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function setMockFetch(
3636

3737
function createWebFetchToolForTest(params?: {
3838
firecrawlApiKey?: string;
39-
ssrfPolicy?: { allowRfc2544BenchmarkRange?: boolean };
39+
ssrfPolicy?: { allowRfc2544BenchmarkRange?: boolean; allowIpv6UniqueLocalRange?: boolean };
4040
cacheTtlMinutes?: number;
4141
}) {
4242
return createWebFetchTool({
@@ -178,4 +178,28 @@ describe("web_fetch SSRF protection", () => {
178178
const stricterTool = createWebFetchToolForTest({ cacheTtlMinutes: 1 });
179179
await expectBlockedUrl(stricterTool, url, /private|internal|blocked/i);
180180
});
181+
182+
it("allows IPv6 unique-local DNS answers only when web_fetch ssrfPolicy opts in", async () => {
183+
const url = "https://fake-ip.test/file";
184+
lookupMock.mockResolvedValue([{ address: "fc00::153", family: 6 }]);
185+
186+
const deniedTool = createWebFetchToolForTest({ cacheTtlMinutes: 1 });
187+
await expectBlockedUrl(deniedTool, url, /private|internal|blocked/i);
188+
189+
const fetchSpy = setMockFetch().mockResolvedValue(textResponse("ipv6 ula ok"));
190+
const allowedTool = createWebFetchToolForTest({
191+
ssrfPolicy: { allowIpv6UniqueLocalRange: true },
192+
cacheTtlMinutes: 1,
193+
});
194+
195+
const allowed = await allowedTool?.execute?.("call", { url });
196+
expect(allowed?.details).toMatchObject({
197+
status: 200,
198+
extractor: "raw",
199+
});
200+
expect(fetchSpy).toHaveBeenCalledTimes(1);
201+
202+
const stricterTool = createWebFetchToolForTest({ cacheTtlMinutes: 1 });
203+
await expectBlockedUrl(stricterTool, url, /private|internal|blocked/i);
204+
});
181205
});

src/agents/tools/web-fetch.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Type } from "typebox";
22
import type { OpenClawConfig } from "../../config/types.openclaw.js";
3-
import { SsrFBlockedError, type LookupFn } from "../../infra/net/ssrf.js";
3+
import { SsrFBlockedError, type LookupFn, type SsrFPolicy } from "../../infra/net/ssrf.js";
44
import { logDebug } from "../../logger.js";
55
import type { RuntimeWebFetchMetadata } from "../../secrets/runtime-web-tools.types.js";
66
import { wrapExternalContent, wrapWebContent } from "../../security/external-content.js";
@@ -274,6 +274,7 @@ type WebFetchRuntimeParams = {
274274
config?: OpenClawConfig;
275275
ssrfPolicy?: {
276276
allowRfc2544BenchmarkRange?: boolean;
277+
allowIpv6UniqueLocalRange?: boolean;
277278
};
278279
lookupFn?: LookupFn;
279280
resolveProviderFallback: () => Promise<WebFetchProviderFallback>;
@@ -389,8 +390,16 @@ async function maybeFetchProviderWebFetchPayload(
389390

390391
async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string, unknown>> {
391392
const allowRfc2544BenchmarkRange = params.ssrfPolicy?.allowRfc2544BenchmarkRange === true;
393+
const allowIpv6UniqueLocalRange = params.ssrfPolicy?.allowIpv6UniqueLocalRange === true;
394+
const ssrfPolicy: SsrFPolicy | undefined =
395+
allowRfc2544BenchmarkRange || allowIpv6UniqueLocalRange
396+
? {
397+
...(allowRfc2544BenchmarkRange ? { allowRfc2544BenchmarkRange } : {}),
398+
...(allowIpv6UniqueLocalRange ? { allowIpv6UniqueLocalRange } : {}),
399+
}
400+
: undefined;
392401
const cacheKey = normalizeCacheKey(
393-
`fetch:${params.url}:${params.extractMode}:${params.maxChars}${allowRfc2544BenchmarkRange ? ":allow-rfc2544" : ""}`,
402+
`fetch:${params.url}:${params.extractMode}:${params.maxChars}${allowRfc2544BenchmarkRange ? ":allow-rfc2544" : ""}${allowIpv6UniqueLocalRange ? ":allow-ipv6-ula" : ""}`,
394403
);
395404
const cached = readCache(FETCH_CACHE, cacheKey);
396405
if (cached) {
@@ -418,7 +427,7 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string
418427
maxRedirects: params.maxRedirects,
419428
timeoutSeconds: params.timeoutSeconds,
420429
lookupFn: params.lookupFn,
421-
policy: allowRfc2544BenchmarkRange ? { allowRfc2544BenchmarkRange } : undefined,
430+
policy: ssrfPolicy,
422431
init: {
423432
headers: {
424433
Accept: "text/markdown, text/html;q=0.9, */*;q=0.1",

src/config/schema.base.generated.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8638,6 +8638,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
86388638
description:
86398639
"Allow RFC 2544 benchmark-range IPs (198.18.0.0/15) for fake-IP proxy compatibility such as Clash or Surge.",
86408640
},
8641+
allowIpv6UniqueLocalRange: {
8642+
type: "boolean",
8643+
title: "Web Fetch Allow IPv6 Unique Local Range",
8644+
description:
8645+
"Allow IPv6 Unique Local Addresses (fc00::/7) for trusted fake-IP proxy compatibility such as sing-box, Clash, or Surge.",
8646+
},
86418647
},
86428648
additionalProperties: false,
86438649
title: "Web Fetch SSRF Policy",
@@ -25717,6 +25723,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
2571725723
help: "Allow RFC 2544 benchmark-range IPs (198.18.0.0/15) for fake-IP proxy compatibility such as Clash or Surge.",
2571825724
tags: ["access", "tools"],
2571925725
},
25726+
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange": {
25727+
label: "Web Fetch Allow IPv6 Unique Local Range",
25728+
help: "Allow IPv6 Unique Local Addresses (fc00::/7) for trusted fake-IP proxy compatibility such as sing-box, Clash, or Surge.",
25729+
tags: ["access", "tools"],
25730+
},
2572025731
"gateway.controlUi.basePath": {
2572125732
label: "Control UI Base Path",
2572225733
help: "Optional URL prefix where the Control UI is served (e.g. /openclaw).",

src/config/schema.help.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,8 @@ export const FIELD_HELP: Record<string, string> = {
830830
"Scoped SSRF policy overrides for web_fetch. Keep this narrow and opt in only for known local-network proxy environments.",
831831
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange":
832832
"Allow RFC 2544 benchmark-range IPs (198.18.0.0/15) for fake-IP proxy compatibility such as Clash or Surge.",
833+
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange":
834+
"Allow IPv6 Unique Local Addresses (fc00::/7) for trusted fake-IP proxy compatibility such as sing-box, Clash, or Surge.",
833835
models:
834836
"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.",
835837
"models.mode":

src/config/schema.labels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ export const FIELD_LABELS: Record<string, string> = {
300300
"tools.web.fetch.ssrfPolicy": "Web Fetch SSRF Policy",
301301
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange":
302302
"Web Fetch Allow RFC 2544 Benchmark Range",
303+
"tools.web.fetch.ssrfPolicy.allowIpv6UniqueLocalRange": "Web Fetch Allow IPv6 Unique Local Range",
303304
"gateway.controlUi.basePath": "Control UI Base Path",
304305
"gateway.controlUi.root": "Control UI Assets Root",
305306
"gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode",

src/config/schema.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,15 @@ describe("config schema", () => {
315315
fetch: {
316316
ssrfPolicy: {
317317
allowRfc2544BenchmarkRange: true,
318+
allowIpv6UniqueLocalRange: true,
318319
},
319320
},
320321
},
321322
});
322323

323324
expect(parsed?.web?.fetch?.ssrfPolicy).toEqual({
324325
allowRfc2544BenchmarkRange: true,
326+
allowIpv6UniqueLocalRange: true,
325327
});
326328
});
327329

0 commit comments

Comments
 (0)