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 <kiryl.kavalenka@whiparound.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Kiryl Kavalenka 2026-03-31 16:10:45 +13:00 committed by GitHub
parent e394262bd8
commit 082778df1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 70 additions and 5 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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<G
const { EnvHttpProxyAgent } = loadUndiciRuntimeDeps();
dispatcher = new EnvHttpProxyAgent();
} else if (params.pinDns !== false) {
dispatcher = createPinnedDispatcher(pinned, params.dispatcherPolicy, params.policy);
const protocol = parsedUrl.protocol === "http:" ? "http" : "https";
const useEnvProxy =
hasEnvHttpProxyConfigured(protocol) &&
!params.dispatcherPolicy?.mode &&
!keepsTrustedHostOnDirectPath(parsedUrl.hostname, params.policy);
const dispatcherPolicy: PinnedDispatcherPolicy | undefined = useEnvProxy
? Object.assign({}, params.dispatcherPolicy, { mode: "env-proxy" as const })
: params.dispatcherPolicy;
dispatcher = createPinnedDispatcher(pinned, dispatcherPolicy, params.policy);
}
const init: RequestInit & { dispatcher?: Dispatcher } = {