Skip to content

Commit 082778d

Browse files
kkav004Kiryl Kavalenkaobviyus
authored
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 <[email protected]> Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent e394262 commit 082778d

File tree

3 files changed

+70
-5
lines changed

3 files changed

+70
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai
118118
- 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.
119119
- 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.
120120
- 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.
121+
- 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.
121122
- 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.
122123
- 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.
123124
- 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.

src/infra/net/fetch-guard.ssrf.test.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,14 +334,57 @@ describe("fetchWithSsrFGuard hardening", () => {
334334
expect(fetchImpl).toHaveBeenCalledTimes(1);
335335
});
336336

337-
it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => {
337+
it("routes through env proxy in strict mode via pinned env-proxy dispatcher", async () => {
338338
await runProxyModeDispatcherTest({
339339
mode: GUARDED_FETCH_MODE.STRICT,
340-
expectEnvProxy: false,
340+
expectEnvProxy: true,
341+
});
342+
});
343+
344+
it("keeps allowed hostnames on the direct pinned path when env proxy is configured", async () => {
345+
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
346+
const lookupFn = createPublicLookup();
347+
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
348+
const requestInit = init as RequestInit & { dispatcher?: unknown };
349+
expect(requestInit.dispatcher).toBeDefined();
350+
expect(getDispatcherClassName(requestInit.dispatcher)).not.toBe("EnvHttpProxyAgent");
351+
return okResponse();
352+
});
353+
354+
const result = await fetchWithSsrFGuard({
355+
url: "https://operator.example/resource",
356+
fetchImpl,
357+
lookupFn,
358+
policy: { allowedHostnames: ["operator.example"] },
359+
mode: GUARDED_FETCH_MODE.STRICT,
360+
});
361+
362+
expect(fetchImpl).toHaveBeenCalledTimes(1);
363+
await result.release();
364+
});
365+
366+
it("still uses env proxy when allowed hostnames do not match the target", async () => {
367+
vi.stubEnv("HTTP_PROXY", "http://127.0.0.1:7890");
368+
const lookupFn = createPublicLookup();
369+
const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
370+
const requestInit = init as RequestInit & { dispatcher?: unknown };
371+
expect(getDispatcherClassName(requestInit.dispatcher)).toBe("EnvHttpProxyAgent");
372+
return okResponse();
373+
});
374+
375+
const result = await fetchWithSsrFGuard({
376+
url: "https://public.example/resource",
377+
fetchImpl,
378+
lookupFn,
379+
policy: { allowedHostnames: ["operator.example"] },
380+
mode: GUARDED_FETCH_MODE.STRICT,
341381
});
382+
383+
expect(fetchImpl).toHaveBeenCalledTimes(1);
384+
await result.release();
342385
});
343386

344-
it("uses env proxy only when dangerous proxy bypass is explicitly enabled", async () => {
387+
it("routes through env proxy when trusted proxy mode is explicitly enabled", async () => {
345388
await runProxyModeDispatcherTest({
346389
mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY,
347390
expectEnvProxy: true,

src/infra/net/fetch-guard.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Dispatcher } from "undici";
22
import { logWarn } from "../../logger.js";
33
import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js";
4-
import { hasProxyEnvConfigured } from "./proxy-env.js";
4+
import { normalizeHostname } from "./hostname.js";
5+
import { hasEnvHttpProxyConfigured, hasProxyEnvConfigured } from "./proxy-env.js";
56
import {
67
closeDispatcher,
78
createPinnedDispatcher,
@@ -91,6 +92,18 @@ function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode
9192
return GUARDED_FETCH_MODE.STRICT;
9293
}
9394

95+
function keepsTrustedHostOnDirectPath(hostname: string, policy?: SsrFPolicy): boolean {
96+
const normalizedHostname = normalizeHostname(hostname);
97+
return (
98+
policy?.allowPrivateNetwork === true ||
99+
policy?.dangerouslyAllowPrivateNetwork === true ||
100+
(normalizedHostname !== "" &&
101+
(policy?.allowedHostnames ?? []).some(
102+
(allowedHostname) => normalizeHostname(allowedHostname) === normalizedHostname,
103+
))
104+
);
105+
}
106+
94107
function assertExplicitProxySupportsPinnedDns(
95108
url: URL,
96109
dispatcherPolicy?: PinnedDispatcherPolicy,
@@ -183,7 +196,15 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
183196
const { EnvHttpProxyAgent } = loadUndiciRuntimeDeps();
184197
dispatcher = new EnvHttpProxyAgent();
185198
} else if (params.pinDns !== false) {
186-
dispatcher = createPinnedDispatcher(pinned, params.dispatcherPolicy, params.policy);
199+
const protocol = parsedUrl.protocol === "http:" ? "http" : "https";
200+
const useEnvProxy =
201+
hasEnvHttpProxyConfigured(protocol) &&
202+
!params.dispatcherPolicy?.mode &&
203+
!keepsTrustedHostOnDirectPath(parsedUrl.hostname, params.policy);
204+
const dispatcherPolicy: PinnedDispatcherPolicy | undefined = useEnvProxy
205+
? Object.assign({}, params.dispatcherPolicy, { mode: "env-proxy" as const })
206+
: params.dispatcherPolicy;
207+
dispatcher = createPinnedDispatcher(pinned, dispatcherPolicy, params.policy);
187208
}
188209

189210
const init: RequestInit & { dispatcher?: Dispatcher } = {

0 commit comments

Comments
 (0)