From 082778df1a87f1d58d82f946e5605ce356cccb70 Mon Sep 17 00:00:00 2001 From: Kiryl Kavalenka Date: Tue, 31 Mar 2026 16:10:45 +1300 Subject: [PATCH] fix: respect hostname-scoped proxy bypass (#50650) (thanks @kkav004) * fix(infra/net): route through env proxy in STRICT mode while preserving DNS pinning When HTTP_PROXY/HTTPS_PROXY env vars are configured, the SSRF guard's pinned dispatcher connects directly to the DNS-resolved IP, bypassing the proxy. This fails in environments where direct outbound connections are blocked (OpenShell sandboxes, Docker containers, corporate networks). Use `createPinnedDispatcher` with `mode: "env-proxy"` when `hasEnvHttpProxyConfigured()` returns true. This preserves DNS-pinning (the resolved IP is threaded into the connect option via `EnvHttpProxyAgent`) while routing through the proxy. - Uses `hasEnvHttpProxyConfigured()` (not `hasProxyEnvConfigured()`) to avoid the ALL_PROXY edge case where EnvHttpProxyAgent ignores ALL_PROXY - Preserves STRICT mode's anti-DNS-rebinding guarantee - TRUSTED_ENV_PROXY remains the explicit opt-in for unpinned proxy routing - No change when proxy env vars are not set Fixes #47598, #49948, #32947, #46306 Related: #45248 * test(infra): stabilize fetch guard proxy assertions * fix: respect hostname-scoped proxy bypass (#50650) (thanks @kkav004) --------- Co-authored-by: Kiryl Kavalenka Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + src/infra/net/fetch-guard.ssrf.test.ts | 49 ++++++++++++++++++++++++-- src/infra/net/fetch-guard.ts | 25 +++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f851757bf..bf0fed0a83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras. - Matrix/direct rooms: stop trusting remote `is_direct`, honor explicit local `is_direct: false` for discovered DM candidates, and avoid extra member-state lookups for shared rooms so DM routing and repair stay aligned. (#57124) Thanks @w-sss. - Agents/sandbox: make remote FS bridge reads pin the parent path and open the file atomically in the helper so read access cannot race path resolution. Thanks @AntAISecurityLab and @vincentkoc. +- Tools/web_fetch: route strict SSRF-guarded requests through configured HTTP(S) proxy env vars while keeping hostname-scoped local allowlists on the direct pinned path, so proxy-only installs work without breaking trusted local integrations. (#50650) Thanks @kkav004. - Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc. - Telegram/audio: transcode Telegram voice-note `.ogg` attachments before the local `whisper-cli` auto fallback runs, and keep mention-preflight transcription enabled in auto mode when `tools.media.audio` is unset. - Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras. diff --git a/src/infra/net/fetch-guard.ssrf.test.ts b/src/infra/net/fetch-guard.ssrf.test.ts index 626e29f0824..874b55570af 100644 --- a/src/infra/net/fetch-guard.ssrf.test.ts +++ b/src/infra/net/fetch-guard.ssrf.test.ts @@ -334,14 +334,57 @@ describe("fetchWithSsrFGuard hardening", () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); - it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => { + it("routes through env proxy in strict mode via pinned env-proxy dispatcher", async () => { await runProxyModeDispatcherTest({ mode: GUARDED_FETCH_MODE.STRICT, - expectEnvProxy: false, + expectEnvProxy: true, }); }); - it("uses env proxy only when dangerous proxy bypass is explicitly enabled", async () => { + it("keeps allowed hostnames on the direct pinned path when env proxy is configured", async () => { + vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + const lookupFn = createPublicLookup(); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const requestInit = init as RequestInit & { dispatcher?: unknown }; + expect(requestInit.dispatcher).toBeDefined(); + expect(getDispatcherClassName(requestInit.dispatcher)).not.toBe("EnvHttpProxyAgent"); + return okResponse(); + }); + + const result = await fetchWithSsrFGuard({ + url: "https://operator.example/resource", + fetchImpl, + lookupFn, + policy: { allowedHostnames: ["operator.example"] }, + mode: GUARDED_FETCH_MODE.STRICT, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + await result.release(); + }); + + it("still uses env proxy when allowed hostnames do not match the target", async () => { + vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890"); + const lookupFn = createPublicLookup(); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const requestInit = init as RequestInit & { dispatcher?: unknown }; + expect(getDispatcherClassName(requestInit.dispatcher)).toBe("EnvHttpProxyAgent"); + return okResponse(); + }); + + const result = await fetchWithSsrFGuard({ + url: "https://public.example/resource", + fetchImpl, + lookupFn, + policy: { allowedHostnames: ["operator.example"] }, + mode: GUARDED_FETCH_MODE.STRICT, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + await result.release(); + }); + + it("routes through env proxy when trusted proxy mode is explicitly enabled", async () => { await runProxyModeDispatcherTest({ mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY, expectEnvProxy: true, diff --git a/src/infra/net/fetch-guard.ts b/src/infra/net/fetch-guard.ts index 09678ed495a..c6cb354c889 100644 --- a/src/infra/net/fetch-guard.ts +++ b/src/infra/net/fetch-guard.ts @@ -1,7 +1,8 @@ import type { Dispatcher } from "undici"; import { logWarn } from "../../logger.js"; import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js"; -import { hasProxyEnvConfigured } from "./proxy-env.js"; +import { normalizeHostname } from "./hostname.js"; +import { hasEnvHttpProxyConfigured, hasProxyEnvConfigured } from "./proxy-env.js"; import { closeDispatcher, createPinnedDispatcher, @@ -91,6 +92,18 @@ function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode return GUARDED_FETCH_MODE.STRICT; } +function keepsTrustedHostOnDirectPath(hostname: string, policy?: SsrFPolicy): boolean { + const normalizedHostname = normalizeHostname(hostname); + return ( + policy?.allowPrivateNetwork === true || + policy?.dangerouslyAllowPrivateNetwork === true || + (normalizedHostname !== "" && + (policy?.allowedHostnames ?? []).some( + (allowedHostname) => normalizeHostname(allowedHostname) === normalizedHostname, + )) + ); +} + function assertExplicitProxySupportsPinnedDns( url: URL, dispatcherPolicy?: PinnedDispatcherPolicy, @@ -183,7 +196,15 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise