fix(security): restore strict SSRF pinning

This commit is contained in:
Ayaan Zaidi
2026-03-31 09:41:19 +05:30
parent 28ede9a23e
commit 9d9ee0f313
3 changed files with 5 additions and 69 deletions

View File

@@ -119,7 +119,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.
- Tools/web_fetch: add an explicit trusted env-proxy path for proxy-only installs while keeping strict SSRF fetches on the pinned direct path, so trusted proxy routing does not weaken strict destination binding. (#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,56 +334,13 @@ describe("fetchWithSsrFGuard hardening", () => {
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it("routes through env proxy in strict mode via pinned env-proxy dispatcher", async () => {
it("ignores env proxy by default to preserve DNS-pinned destination binding", async () => {
await runProxyModeDispatcherTest({
mode: GUARDED_FETCH_MODE.STRICT,
expectEnvProxy: true,
expectEnvProxy: false,
});
});
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,

View File

@@ -1,8 +1,7 @@
import type { Dispatcher } from "undici";
import { logWarn } from "../../logger.js";
import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js";
import { normalizeHostname } from "./hostname.js";
import { hasEnvHttpProxyConfigured, hasProxyEnvConfigured } from "./proxy-env.js";
import { hasProxyEnvConfigured } from "./proxy-env.js";
import {
closeDispatcher,
createPinnedDispatcher,
@@ -92,18 +91,6 @@ 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,
@@ -196,15 +183,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
const { EnvHttpProxyAgent } = loadUndiciRuntimeDeps();
dispatcher = new EnvHttpProxyAgent();
} else if (params.pinDns !== false) {
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);
dispatcher = createPinnedDispatcher(pinned, params.dispatcherPolicy, params.policy);
}
const init: RequestInit & { dispatcher?: Dispatcher } = {