Skip to content

Commit b93c8f6

Browse files
committed
fix(net): harden trusted env proxy fetch guard and add web_fetch opt-in
1 parent 4d82dc4 commit b93c8f6

15 files changed

Lines changed: 399 additions & 14 deletions
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
39c5c0620611f355f20d5e9d2ddd74e198c344c63d5551a987e4b7538833ceac config-baseline.json
2-
805bd3f63ff7327da45c01b78dbc990ed53bd13b89e0cbf50f319aa99334ba92 config-baseline.core.json
1+
7c92086c3dccfca80812ac65a6d9f994de8db6022bd3cc0f504de747ec2ffaad config-baseline.json
2+
17e0eeb204084f0f5204dbe8fc0b30ac60cf1cb6119bdd9e2a31c7a23f75e05a config-baseline.core.json
33
323a9fd49a669951ca5b3442d95aad243bd1330083f9857e83a8dcfae2bbc9d0 config-baseline.channel.json
4-
1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json
4+
0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json

docs/tools/web-fetch.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Truncate output to this many characters.
7272
timeoutSeconds: 30,
7373
cacheTtlMinutes: 15,
7474
maxRedirects: 3,
75+
useTrustedEnvProxy: false, // let a trusted HTTP(S) env proxy resolve DNS
7576
readability: true, // use Readability extraction
7677
userAgent: "Mozilla/5.0 ...", // override User-Agent
7778
},
@@ -134,13 +135,32 @@ Current runtime behavior:
134135
- If Readability is disabled, `web_fetch` skips straight to the selected
135136
provider fallback. If no provider is available, it fails closed.
136137

138+
## Trusted env proxy mode
139+
140+
If your deployment requires `web_fetch` to go through a trusted outbound HTTP(S)
141+
proxy, set `tools.web.fetch.useTrustedEnvProxy: true`.
142+
143+
In this mode, OpenClaw still applies hostname-based SSRF checks before sending
144+
the request, but it lets the proxy resolve DNS instead of doing local DNS
145+
pinning. Enable this only when the proxy is operator-controlled and enforces
146+
outbound policy after DNS resolution.
147+
148+
<Note>
149+
If no HTTP(S) proxy env var is configured, or the target host is excluded by
150+
`NO_PROXY`, `web_fetch` falls back to the normal strict path with local DNS
151+
pinning.
152+
</Note>
153+
137154
## Limits and safety
138155

139156
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`
140157
- Response body is capped at `maxResponseBytes` before parsing; oversized
141158
responses are truncated with a warning
142159
- Private/internal hostnames are blocked
143160
- Redirects are checked and limited by `maxRedirects`
161+
- `useTrustedEnvProxy` is an explicit opt-in and should only be enabled for
162+
operator-controlled proxies that still enforce outbound policy after DNS
163+
resolution
144164
- `web_fetch` is best-effort -- some sites need the [Web Browser](/tools/browser)
145165

146166
## Tool profiles

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ function setMockFetch(
3636

3737
function createWebFetchToolForTest(params?: {
3838
firecrawlApiKey?: string;
39+
useTrustedEnvProxy?: boolean;
3940
ssrfPolicy?: { allowRfc2544BenchmarkRange?: boolean };
4041
cacheTtlMinutes?: number;
4142
}) {
@@ -58,6 +59,7 @@ function createWebFetchToolForTest(params?: {
5859
web: {
5960
fetch: {
6061
cacheTtlMinutes: params?.cacheTtlMinutes ?? 0,
62+
useTrustedEnvProxy: params?.useTrustedEnvProxy,
6163
ssrfPolicy: params?.ssrfPolicy,
6264
...(params?.firecrawlApiKey ? { provider: "firecrawl" } : {}),
6365
},
@@ -89,6 +91,7 @@ describe("web_fetch SSRF protection", () => {
8991
global.fetch = priorFetch;
9092
lookupMock.mockClear();
9193
vi.restoreAllMocks();
94+
vi.unstubAllEnvs();
9295
});
9396

9497
it("blocks localhost hostnames before fetch/firecrawl", async () => {
@@ -178,4 +181,18 @@ describe("web_fetch SSRF protection", () => {
178181
const stricterTool = createWebFetchToolForTest({ cacheTtlMinutes: 1 });
179182
await expectBlockedUrl(stricterTool, url, /private|internal|blocked/i);
180183
});
184+
185+
it("still blocks dangerous hostnames when trusted env proxy is explicitly enabled", async () => {
186+
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
187+
vi.stubEnv("http_proxy", "http://127.0.0.1:7890");
188+
const fetchSpy = setMockFetch();
189+
const tool = createWebFetchToolForTest({
190+
useTrustedEnvProxy: true,
191+
cacheTtlMinutes: 1,
192+
});
193+
194+
await expectBlockedUrl(tool, "http://localhost/test", /Blocked hostname/i);
195+
expect(fetchSpy).not.toHaveBeenCalled();
196+
expect(lookupMock).not.toHaveBeenCalled();
197+
});
181198
});

src/agents/tools/web-fetch.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ function resolveFetchReadabilityEnabled(fetch?: WebFetchConfig): boolean {
116116
return true;
117117
}
118118

119+
function resolveFetchUseTrustedEnvProxy(fetch?: WebFetchConfig): boolean {
120+
return fetch?.useTrustedEnvProxy === true;
121+
}
122+
119123
function resolveFetchMaxCharsCap(fetch?: WebFetchConfig): number {
120124
const raw =
121125
fetch && "maxCharsCap" in fetch && typeof fetch.maxCharsCap === "number"
@@ -272,6 +276,7 @@ type WebFetchRuntimeParams = {
272276
userAgent: string;
273277
readabilityEnabled: boolean;
274278
config?: OpenClawConfig;
279+
useTrustedEnvProxy: boolean;
275280
ssrfPolicy?: {
276281
allowRfc2544BenchmarkRange?: boolean;
277282
};
@@ -389,8 +394,9 @@ async function maybeFetchProviderWebFetchPayload(
389394

390395
async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string, unknown>> {
391396
const allowRfc2544BenchmarkRange = params.ssrfPolicy?.allowRfc2544BenchmarkRange === true;
397+
const useTrustedEnvProxy = params.useTrustedEnvProxy ?? false;
392398
const cacheKey = normalizeCacheKey(
393-
`fetch:${params.url}:${params.extractMode}:${params.maxChars}${allowRfc2544BenchmarkRange ? ":allow-rfc2544" : ""}`,
399+
`fetch:${params.url}:${params.extractMode}:${params.maxChars}${allowRfc2544BenchmarkRange ? ":allow-rfc2544" : ""}${useTrustedEnvProxy ? ":trusted-env-proxy" : ""}`,
394400
);
395401
const cached = readCache(FETCH_CACHE, cacheKey);
396402
if (cached) {
@@ -418,6 +424,7 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string
418424
maxRedirects: params.maxRedirects,
419425
timeoutSeconds: params.timeoutSeconds,
420426
lookupFn: params.lookupFn,
427+
useEnvProxy: useTrustedEnvProxy,
421428
policy: allowRfc2544BenchmarkRange ? { allowRfc2544BenchmarkRange } : undefined,
422429
init: {
423430
headers: {
@@ -651,6 +658,7 @@ export function createWebFetchTool(options?: {
651658
userAgent,
652659
readabilityEnabled,
653660
config: options?.config,
661+
useTrustedEnvProxy: resolveFetchUseTrustedEnvProxy(fetch),
654662
ssrfPolicy: fetch?.ssrfPolicy,
655663
lookupFn: options?.lookupFn,
656664
resolveProviderFallback,

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ describe("web_fetch extraction fallbacks", () => {
331331
expect(details?.warning).toContain("Response body truncated");
332332
});
333333

334-
it("keeps DNS pinning for untrusted web_fetch URLs even when HTTP_PROXY is configured", async () => {
334+
it("keeps DNS pinning for web_fetch by default even when HTTP_PROXY is configured", async () => {
335335
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
336336
const mockFetch = installMockFetch((input: RequestInfo | URL) =>
337337
Promise.resolve({
@@ -353,6 +353,31 @@ describe("web_fetch extraction fallbacks", () => {
353353
expect(requestInit?.dispatcher).not.toBeInstanceOf(EnvHttpProxyAgent);
354354
});
355355

356+
it("uses env proxy dispatch for web_fetch when trusted env proxy is explicitly enabled", async () => {
357+
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
358+
const mockFetch = installMockFetch((input: RequestInfo | URL) =>
359+
Promise.resolve({
360+
ok: true,
361+
status: 200,
362+
headers: makeFetchHeaders({ "content-type": "text/plain" }),
363+
text: async () => "proxy body",
364+
url: resolveRequestUrl(input),
365+
} as Response),
366+
);
367+
const tool = createFetchTool({
368+
firecrawl: { enabled: false },
369+
useTrustedEnvProxy: true,
370+
});
371+
372+
await tool?.execute?.("call", { url: "https://example.com/proxy" });
373+
374+
const requestInit = mockFetch.mock.calls[0]?.[1] as
375+
| (RequestInit & { dispatcher?: unknown })
376+
| undefined;
377+
expect(requestInit?.dispatcher).toBeDefined();
378+
expect(requestInit?.dispatcher).toBeInstanceOf(EnvHttpProxyAgent);
379+
});
380+
356381
// NOTE: Test for wrapping url/finalUrl/warning fields requires DNS mocking.
357382
// The sanitization of these fields is verified by external-content.test.ts tests.
358383

src/config/schema.base.generated.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8601,6 +8601,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
86018601
description:
86028602
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
86038603
},
8604+
useTrustedEnvProxy: {
8605+
type: "boolean",
8606+
title: "Web Fetch Trusted Env Proxy",
8607+
description:
8608+
"Route web_fetch through a trusted HTTP(S) env proxy and let the proxy resolve DNS. Enable only when that proxy is operator-controlled and enforces outbound policy after DNS resolution.",
8609+
},
86048610
ssrfPolicy: {
86058611
type: "object",
86068612
properties: {
@@ -25644,6 +25650,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
2564425650
help: "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
2564525651
tags: ["tools"],
2564625652
},
25653+
"tools.web.fetch.useTrustedEnvProxy": {
25654+
label: "Web Fetch Trusted Env Proxy",
25655+
help: "Route web_fetch through a trusted HTTP(S) env proxy and let the proxy resolve DNS. Enable only when that proxy is operator-controlled and enforces outbound policy after DNS resolution.",
25656+
tags: ["tools"],
25657+
},
2564725658
"tools.web.fetch.ssrfPolicy": {
2564825659
label: "Web Fetch SSRF Policy",
2564925660
help: "Scoped SSRF policy overrides for web_fetch. Keep this narrow and opt in only for known local-network proxy environments.",

src/config/schema.help.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,8 @@ export const FIELD_HELP: Record<string, string> = {
820820
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
821821
"tools.web.fetch.readability":
822822
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
823+
"tools.web.fetch.useTrustedEnvProxy":
824+
"Route web_fetch through a trusted HTTP(S) env proxy and let the proxy resolve DNS. Enable only when that proxy is operator-controlled and enforces outbound policy after DNS resolution.",
823825
"tools.web.fetch.ssrfPolicy":
824826
"Scoped SSRF policy overrides for web_fetch. Keep this narrow and opt in only for known local-network proxy environments.",
825827
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange":

src/config/schema.labels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ export const FIELD_LABELS: Record<string, string> = {
296296
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
297297
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
298298
"tools.web.fetch.readability": "Web Fetch Readability Extraction",
299+
"tools.web.fetch.useTrustedEnvProxy": "Web Fetch Trusted Env Proxy",
299300
"tools.web.fetch.ssrfPolicy": "Web Fetch SSRF Policy",
300301
"tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange":
301302
"Web Fetch Allow RFC 2544 Benchmark Range",

src/config/schema.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,18 @@ describe("config schema", () => {
345345
).toThrow();
346346
});
347347

348+
it("accepts web fetch trusted env proxy opt-in in the runtime zod schema", () => {
349+
const parsed = ToolsSchema.parse({
350+
web: {
351+
fetch: {
352+
useTrustedEnvProxy: true,
353+
},
354+
},
355+
});
356+
357+
expect(parsed?.web?.fetch?.useTrustedEnvProxy).toBe(true);
358+
});
359+
348360
it("rejects unknown keys inside web fetch firecrawl config", () => {
349361
expect(() =>
350362
ToolsSchema.parse({

src/config/types.tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,8 @@ export type ToolsConfig = {
575575
userAgent?: string;
576576
/** Use Readability to extract main content (default: true). */
577577
readability?: boolean;
578+
/** Route web_fetch through a trusted HTTP(S) env proxy and let the proxy resolve DNS. Enable only when that proxy enforces outbound policy. */
579+
useTrustedEnvProxy?: boolean;
578580
/** SSRF policy configuration for web_fetch. */
579581
ssrfPolicy?: {
580582
/** Allow RFC 2544 benchmark range IPs (198.18.0.0/15) for fake-IP proxy compatibility (e.g., Clash TUN mode, Surge). */

0 commit comments

Comments
 (0)