mirror of https://github.com/openclaw/openclaw.git
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:
parent
e394262bd8
commit
082778df1a
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue