Skip to content

Commit e58d50b

Browse files
authored
fix(telegram): trust explicit proxy DNS for media downloads (#66461)
1 parent 6ee8e19 commit e58d50b

8 files changed

Lines changed: 193 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
2525
- Browser/SSRF: preserve explicit strict browser navigation mode for legacy `browser.ssrfPolicy.allowPrivateNetwork: false` configs by normalizing the legacy alias to the canonical strict marker instead of silently widening those installs to the default non-strict hostname-navigation path.
2626
- Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with `ERR_MODULE_NOT_FOUND` at runtime. (#66420) Thanks @obviyus.
2727
- Media-understanding/proxy env: auto-upgrade provider HTTP helper requests to trusted env-proxy mode only when `HTTP_PROXY`/`HTTPS_PROXY` is active and the target is not bypassed by `NO_PROXY`, so remote media-understanding and transcription requests stop failing local DNS pre-resolution in proxy-only environments without widening SSRF bypasses. (#52162) Thanks @mjamiv and @vincentkoc.
28+
- Telegram/media downloads: let Telegram media fetches trust an operator-configured explicit proxy for target DNS resolution after hostname-policy checks, so proxy-backed installs stop failing `could not download media` on Bot API file downloads after the DNS-pinning regression. (#66245) Thanks @dawei41468 and @vincentkoc.
2829
- Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.
2930
- Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when `afterTurn` is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.
3031
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.

extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,15 @@ describe("resolveMedia getFile retry", () => {
332332
it("uses caller-provided fetch impl for file downloads", async () => {
333333
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
334334
const callerFetch = vi.fn() as unknown as typeof fetch;
335-
const dispatcherAttempts = [{ dispatcherPolicy: { mode: "direct" as const } }];
335+
const dispatcherAttempts = [
336+
{
337+
dispatcherPolicy: {
338+
mode: "explicit-proxy" as const,
339+
proxyUrl: "http://localhost:6152",
340+
allowPrivateProxy: true,
341+
},
342+
},
343+
];
336344
const callerTransport = {
337345
fetch: callerFetch,
338346
sourceFetch: callerFetch,
@@ -357,6 +365,7 @@ describe("resolveMedia getFile retry", () => {
357365
expect.objectContaining({
358366
fetchImpl: callerFetch,
359367
dispatcherAttempts,
368+
trustExplicitProxyDns: true,
360369
shouldRetryFetchError: expect.any(Function),
361370
readIdleTimeoutMs: 30_000,
362371
ssrfPolicy: {

extensions/telegram/src/bot/delivery.resolve-media.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,14 @@ function resolveRequiredTelegramTransport(transport?: TelegramTransport): Telegr
157157
/** Default idle timeout for Telegram media downloads (30 seconds). */
158158
const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
159159

160+
function usesTrustedTelegramExplicitProxy(transport: TelegramTransport): boolean {
161+
return (
162+
transport.dispatcherAttempts?.some(
163+
(attempt) => attempt.dispatcherPolicy?.mode === "explicit-proxy",
164+
) ?? false
165+
);
166+
}
167+
160168
function resolveTrustedLocalTelegramRoot(
161169
filePath: string,
162170
trustedLocalFileRoots?: readonly string[],
@@ -225,6 +233,7 @@ async function downloadAndSaveTelegramFile(params: {
225233
url,
226234
fetchImpl: transport.sourceFetch,
227235
dispatcherAttempts: transport.dispatcherAttempts,
236+
trustExplicitProxyDns: usesTrustedTelegramExplicitProxy(transport),
228237
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
229238
filePathHint: params.filePath,
230239
maxBytes: params.maxBytes,

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,75 @@ describe("fetchWithSsrFGuard hardening", () => {
10181018
await result.release();
10191019
});
10201020

1021+
it("skips target DNS pinning in trusted explicit-proxy mode after hostname-policy checks", async () => {
1022+
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
1023+
Agent: agentCtor,
1024+
EnvHttpProxyAgent: envHttpProxyAgentCtor,
1025+
ProxyAgent: proxyAgentCtor,
1026+
fetch: vi.fn(async () => okResponse()),
1027+
};
1028+
const lookupFn: LookupFn = vi.fn(async (hostname: string) => {
1029+
if (hostname === "localhost") {
1030+
return [{ address: "127.0.0.1", family: 4 }];
1031+
}
1032+
throw new Error(`unexpected target DNS lookup for ${hostname}`);
1033+
}) as unknown as LookupFn;
1034+
const fetchImpl = vi.fn(async () => okResponse());
1035+
1036+
const result = await fetchWithSsrFGuard({
1037+
url: "https://api.telegram.org/file/bot123/photos/test.jpg",
1038+
fetchImpl,
1039+
lookupFn,
1040+
mode: GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY,
1041+
policy: { hostnameAllowlist: ["api.telegram.org"] },
1042+
dispatcherPolicy: {
1043+
mode: "explicit-proxy",
1044+
proxyUrl: "http://localhost:6152",
1045+
allowPrivateProxy: true,
1046+
},
1047+
});
1048+
1049+
expect(fetchImpl).toHaveBeenCalledTimes(1);
1050+
expect(lookupFn).toHaveBeenCalledOnce();
1051+
expect(lookupFn).toHaveBeenCalledWith("localhost", { all: true });
1052+
await result.release();
1053+
});
1054+
1055+
it("still blocks off-allowlist targets in trusted explicit-proxy mode", async () => {
1056+
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
1057+
Agent: agentCtor,
1058+
EnvHttpProxyAgent: envHttpProxyAgentCtor,
1059+
ProxyAgent: proxyAgentCtor,
1060+
fetch: vi.fn(async () => okResponse()),
1061+
};
1062+
const lookupFn: LookupFn = vi.fn(async (hostname: string) => {
1063+
if (hostname === "localhost") {
1064+
return [{ address: "127.0.0.1", family: 4 }];
1065+
}
1066+
throw new Error(`unexpected target DNS lookup for ${hostname}`);
1067+
}) as unknown as LookupFn;
1068+
const fetchImpl = vi.fn(async () => okResponse());
1069+
1070+
await expect(
1071+
fetchWithSsrFGuard({
1072+
url: "https://cdn.telegram.org/file/bot123/photos/test.jpg",
1073+
fetchImpl,
1074+
lookupFn,
1075+
mode: GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY,
1076+
policy: { hostnameAllowlist: ["api.telegram.org"] },
1077+
dispatcherPolicy: {
1078+
mode: "explicit-proxy",
1079+
proxyUrl: "http://localhost:6152",
1080+
allowPrivateProxy: true,
1081+
},
1082+
}),
1083+
).rejects.toThrow(/allowlist|blocked/i);
1084+
1085+
expect(fetchImpl).not.toHaveBeenCalled();
1086+
expect(lookupFn).toHaveBeenCalledOnce();
1087+
expect(lookupFn).toHaveBeenCalledWith("localhost", { all: true });
1088+
});
1089+
10211090
it("still blocks explicit proxy on localhost when allowPrivateProxy is false", async () => {
10221091
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
10231092
Agent: agentCtor,

src/infra/net/fetch-guard.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type DispatcherAwareRequestInit,
1111
} from "./runtime-fetch.js";
1212
import {
13+
assertHostnameAllowedWithPolicy,
1314
closeDispatcher,
1415
createPinnedDispatcher,
1516
resolvePinnedHostnameWithPolicy,
@@ -29,6 +30,7 @@ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respo
2930
export const GUARDED_FETCH_MODE = {
3031
STRICT: "strict",
3132
TRUSTED_ENV_PROXY: "trusted_env_proxy",
33+
TRUSTED_EXPLICIT_PROXY: "trusted_explicit_proxy",
3234
} as const;
3335

3436
export type GuardedFetchMode = (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE];
@@ -89,6 +91,12 @@ export function withTrustedEnvProxyGuardedFetchMode(
8991
return { ...params, mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY };
9092
}
9193

94+
export function withTrustedExplicitProxyGuardedFetchMode(
95+
params: GuardedFetchPresetOptions,
96+
): GuardedFetchOptions {
97+
return { ...params, mode: GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY };
98+
}
99+
92100
function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode {
93101
if (params.mode) {
94102
return params.mode;
@@ -318,12 +326,24 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
318326

319327
let dispatcher: Dispatcher | null = null;
320328
try {
321-
assertExplicitProxySupportsPinnedDns(parsedUrl, params.dispatcherPolicy, params.pinDns);
329+
const usesTrustedExplicitProxyMode =
330+
mode === GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY &&
331+
params.dispatcherPolicy?.mode === "explicit-proxy";
332+
assertExplicitProxySupportsPinnedDns(
333+
parsedUrl,
334+
params.dispatcherPolicy,
335+
usesTrustedExplicitProxyMode ? false : params.pinDns,
336+
);
322337
await assertExplicitProxyAllowed(params.dispatcherPolicy, params.lookupFn, params.policy);
323338
const canUseTrustedEnvProxy =
324339
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured();
325340
if (canUseTrustedEnvProxy) {
326341
dispatcher = createHttp1EnvHttpProxyAgent();
342+
} else if (usesTrustedExplicitProxyMode) {
343+
// Explicit proxy targets are still checked against the caller's hostname
344+
// policy, but the proxy does the DNS resolution for the final target.
345+
assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);
346+
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
327347
} else if (params.pinDns === false) {
328348
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
329349
lookupFn: params.lookupFn,

src/infra/net/ssrf.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,33 @@ function assertAllowedHostOrIpOrThrow(hostnameOrIp: string, policy?: SsrFPolicy)
191191
}
192192
}
193193

194+
function resolveHostnamePolicyChecks(
195+
hostname: string,
196+
policy?: SsrFPolicy,
197+
): {
198+
normalized: string;
199+
skipPrivateNetworkChecks: boolean;
200+
} {
201+
const normalized = normalizeHostname(hostname);
202+
if (!normalized) {
203+
throw new Error("Invalid hostname");
204+
}
205+
206+
const hostnameAllowlist = normalizeHostnameAllowlist(policy?.hostnameAllowlist);
207+
const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, policy);
208+
209+
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
210+
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
211+
}
212+
213+
if (!skipPrivateNetworkChecks) {
214+
// Fail fast for literal hosts/IPs before any DNS lookup side-effects.
215+
assertAllowedHostOrIpOrThrow(normalized, policy);
216+
}
217+
218+
return { normalized, skipPrivateNetworkChecks };
219+
}
220+
194221
function assertAllowedResolvedAddressesOrThrow(
195222
results: readonly LookupAddress[],
196223
policy?: SsrFPolicy,
@@ -323,22 +350,10 @@ export async function resolvePinnedHostnameWithPolicy(
323350
hostname: string,
324351
params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {},
325352
): Promise<PinnedHostname> {
326-
const normalized = normalizeHostname(hostname);
327-
if (!normalized) {
328-
throw new Error("Invalid hostname");
329-
}
330-
331-
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
332-
const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, params.policy);
333-
334-
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
335-
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
336-
}
337-
338-
if (!skipPrivateNetworkChecks) {
339-
// Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects.
340-
assertAllowedHostOrIpOrThrow(normalized, params.policy);
341-
}
353+
const { normalized, skipPrivateNetworkChecks } = resolveHostnamePolicyChecks(
354+
hostname,
355+
params.policy,
356+
);
342357

343358
const lookupFn = params.lookupFn ?? dnsLookup;
344359
const results = normalizeLookupResults(
@@ -367,6 +382,10 @@ export async function resolvePinnedHostnameWithPolicy(
367382
};
368383
}
369384

385+
export function assertHostnameAllowedWithPolicy(hostname: string, policy?: SsrFPolicy): string {
386+
return resolveHostnamePolicyChecks(hostname, policy).normalized;
387+
}
388+
370389
export async function resolvePinnedHostname(
371390
hostname: string,
372391
lookupFn: LookupFn = dnsLookup,

src/media/fetch.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
55
vi.mock("../infra/net/fetch-guard.js", () => ({
66
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
77
withStrictGuardedFetchMode: <T>(params: T) => params,
8+
withTrustedExplicitProxyGuardedFetchMode: <T>(params: T) => ({
9+
...params,
10+
mode: "trusted_explicit_proxy",
11+
}),
812
}));
913

1014
type FetchRemoteMedia = typeof import("./fetch.js").fetchRemoteMedia;
@@ -286,4 +290,34 @@ describe("fetchRemoteMedia", () => {
286290

287291
await expectBoundedErrorBodyCase(testCase.fetchImpl);
288292
});
293+
294+
it("uses trusted explicit-proxy mode when the caller opts in for proxy-side DNS", async () => {
295+
const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
296+
297+
await fetchRemoteMedia({
298+
url: "https://api.telegram.org/file/bot123/photos/test.jpg",
299+
fetchImpl,
300+
lookupFn: makeLookupFn(),
301+
trustExplicitProxyDns: true,
302+
dispatcherAttempts: [
303+
{
304+
dispatcherPolicy: {
305+
mode: "explicit-proxy",
306+
proxyUrl: "http://localhost:8888",
307+
allowPrivateProxy: true,
308+
},
309+
},
310+
],
311+
});
312+
313+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
314+
expect.objectContaining({
315+
mode: "trusted_explicit_proxy",
316+
dispatcherPolicy: expect.objectContaining({
317+
mode: "explicit-proxy",
318+
proxyUrl: "http://localhost:8888",
319+
}),
320+
}),
321+
);
322+
});
289323
});

src/media/fetch.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import path from "node:path";
22
import { formatErrorMessage } from "../infra/errors.js";
3-
import { fetchWithSsrFGuard, withStrictGuardedFetchMode } from "../infra/net/fetch-guard.js";
3+
import {
4+
fetchWithSsrFGuard,
5+
withStrictGuardedFetchMode,
6+
withTrustedExplicitProxyGuardedFetchMode,
7+
} from "../infra/net/fetch-guard.js";
48
import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
59
import { redactSensitiveText } from "../logging/redact.js";
610
import { detectMime, extensionForMime } from "./mime.js";
@@ -44,6 +48,11 @@ type FetchMediaOptions = {
4448
lookupFn?: LookupFn;
4549
dispatcherAttempts?: FetchDispatcherAttempt[];
4650
shouldRetryFetchError?: (error: unknown) => boolean;
51+
/**
52+
* Allow an operator-configured explicit proxy to resolve target DNS after
53+
* hostname-policy checks instead of forcing local pinned-DNS first.
54+
*/
55+
trustExplicitProxyDns?: boolean;
4756
};
4857

4958
function stripQuotes(value: string): string {
@@ -106,6 +115,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
106115
lookupFn,
107116
dispatcherAttempts,
108117
shouldRetryFetchError,
118+
trustExplicitProxyDns,
109119
} = options;
110120
const sourceUrl = redactMediaUrl(url);
111121

@@ -118,7 +128,9 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
118128
: [{ dispatcherPolicy: undefined, lookupFn }];
119129
const runGuardedFetch = async (attempt: FetchDispatcherAttempt) =>
120130
await fetchWithSsrFGuard(
121-
withStrictGuardedFetchMode({
131+
(trustExplicitProxyDns && attempt.dispatcherPolicy?.mode === "explicit-proxy"
132+
? withTrustedExplicitProxyGuardedFetchMode
133+
: withStrictGuardedFetchMode)({
122134
url,
123135
fetchImpl,
124136
init: requestInit,

0 commit comments

Comments
 (0)