fix(telegram): trust explicit proxy DNS for media downloads (#66461)

This commit is contained in:
Vincent Koc
2026-04-14 10:42:33 +01:00
committed by GitHub
parent 6ee8e194c0
commit e58d50b7a8
8 changed files with 193 additions and 20 deletions

View File

@@ -1018,6 +1018,75 @@ describe("fetchWithSsrFGuard hardening", () => {
await result.release();
});
it("skips target DNS pinning in trusted explicit-proxy mode after hostname-policy checks", async () => {
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: agentCtor,
EnvHttpProxyAgent: envHttpProxyAgentCtor,
ProxyAgent: proxyAgentCtor,
fetch: vi.fn(async () => okResponse()),
};
const lookupFn: LookupFn = vi.fn(async (hostname: string) => {
if (hostname === "localhost") {
return [{ address: "127.0.0.1", family: 4 }];
}
throw new Error(`unexpected target DNS lookup for ${hostname}`);
}) as unknown as LookupFn;
const fetchImpl = vi.fn(async () => okResponse());
const result = await fetchWithSsrFGuard({
url: "https://api.telegram.org/file/bot123/photos/test.jpg",
fetchImpl,
lookupFn,
mode: GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY,
policy: { hostnameAllowlist: ["api.telegram.org"] },
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://localhost:6152",
allowPrivateProxy: true,
},
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
expect(lookupFn).toHaveBeenCalledOnce();
expect(lookupFn).toHaveBeenCalledWith("localhost", { all: true });
await result.release();
});
it("still blocks off-allowlist targets in trusted explicit-proxy mode", async () => {
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: agentCtor,
EnvHttpProxyAgent: envHttpProxyAgentCtor,
ProxyAgent: proxyAgentCtor,
fetch: vi.fn(async () => okResponse()),
};
const lookupFn: LookupFn = vi.fn(async (hostname: string) => {
if (hostname === "localhost") {
return [{ address: "127.0.0.1", family: 4 }];
}
throw new Error(`unexpected target DNS lookup for ${hostname}`);
}) as unknown as LookupFn;
const fetchImpl = vi.fn(async () => okResponse());
await expect(
fetchWithSsrFGuard({
url: "https://cdn.telegram.org/file/bot123/photos/test.jpg",
fetchImpl,
lookupFn,
mode: GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY,
policy: { hostnameAllowlist: ["api.telegram.org"] },
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://localhost:6152",
allowPrivateProxy: true,
},
}),
).rejects.toThrow(/allowlist|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
expect(lookupFn).toHaveBeenCalledOnce();
expect(lookupFn).toHaveBeenCalledWith("localhost", { all: true });
});
it("still blocks explicit proxy on localhost when allowPrivateProxy is false", async () => {
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
Agent: agentCtor,

View File

@@ -10,6 +10,7 @@ import {
type DispatcherAwareRequestInit,
} from "./runtime-fetch.js";
import {
assertHostnameAllowedWithPolicy,
closeDispatcher,
createPinnedDispatcher,
resolvePinnedHostnameWithPolicy,
@@ -29,6 +30,7 @@ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Respo
export const GUARDED_FETCH_MODE = {
STRICT: "strict",
TRUSTED_ENV_PROXY: "trusted_env_proxy",
TRUSTED_EXPLICIT_PROXY: "trusted_explicit_proxy",
} as const;
export type GuardedFetchMode = (typeof GUARDED_FETCH_MODE)[keyof typeof GUARDED_FETCH_MODE];
@@ -89,6 +91,12 @@ export function withTrustedEnvProxyGuardedFetchMode(
return { ...params, mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY };
}
export function withTrustedExplicitProxyGuardedFetchMode(
params: GuardedFetchPresetOptions,
): GuardedFetchOptions {
return { ...params, mode: GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY };
}
function resolveGuardedFetchMode(params: GuardedFetchOptions): GuardedFetchMode {
if (params.mode) {
return params.mode;
@@ -318,12 +326,24 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
let dispatcher: Dispatcher | null = null;
try {
assertExplicitProxySupportsPinnedDns(parsedUrl, params.dispatcherPolicy, params.pinDns);
const usesTrustedExplicitProxyMode =
mode === GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY &&
params.dispatcherPolicy?.mode === "explicit-proxy";
assertExplicitProxySupportsPinnedDns(
parsedUrl,
params.dispatcherPolicy,
usesTrustedExplicitProxyMode ? false : params.pinDns,
);
await assertExplicitProxyAllowed(params.dispatcherPolicy, params.lookupFn, params.policy);
const canUseTrustedEnvProxy =
mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && hasProxyEnvConfigured();
if (canUseTrustedEnvProxy) {
dispatcher = createHttp1EnvHttpProxyAgent();
} else if (usesTrustedExplicitProxyMode) {
// Explicit proxy targets are still checked against the caller's hostname
// policy, but the proxy does the DNS resolution for the final target.
assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy);
} else if (params.pinDns === false) {
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
lookupFn: params.lookupFn,

View File

@@ -191,6 +191,33 @@ function assertAllowedHostOrIpOrThrow(hostnameOrIp: string, policy?: SsrFPolicy)
}
}
function resolveHostnamePolicyChecks(
hostname: string,
policy?: SsrFPolicy,
): {
normalized: string;
skipPrivateNetworkChecks: boolean;
} {
const normalized = normalizeHostname(hostname);
if (!normalized) {
throw new Error("Invalid hostname");
}
const hostnameAllowlist = normalizeHostnameAllowlist(policy?.hostnameAllowlist);
const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, policy);
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
}
if (!skipPrivateNetworkChecks) {
// Fail fast for literal hosts/IPs before any DNS lookup side-effects.
assertAllowedHostOrIpOrThrow(normalized, policy);
}
return { normalized, skipPrivateNetworkChecks };
}
function assertAllowedResolvedAddressesOrThrow(
results: readonly LookupAddress[],
policy?: SsrFPolicy,
@@ -323,22 +350,10 @@ export async function resolvePinnedHostnameWithPolicy(
hostname: string,
params: { lookupFn?: LookupFn; policy?: SsrFPolicy } = {},
): Promise<PinnedHostname> {
const normalized = normalizeHostname(hostname);
if (!normalized) {
throw new Error("Invalid hostname");
}
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
const skipPrivateNetworkChecks = shouldSkipPrivateNetworkChecks(normalized, params.policy);
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
}
if (!skipPrivateNetworkChecks) {
// Phase 1: fail fast for literal hosts/IPs before any DNS lookup side-effects.
assertAllowedHostOrIpOrThrow(normalized, params.policy);
}
const { normalized, skipPrivateNetworkChecks } = resolveHostnamePolicyChecks(
hostname,
params.policy,
);
const lookupFn = params.lookupFn ?? dnsLookup;
const results = normalizeLookupResults(
@@ -367,6 +382,10 @@ export async function resolvePinnedHostnameWithPolicy(
};
}
export function assertHostnameAllowedWithPolicy(hostname: string, policy?: SsrFPolicy): string {
return resolveHostnamePolicyChecks(hostname, policy).normalized;
}
export async function resolvePinnedHostname(
hostname: string,
lookupFn: LookupFn = dnsLookup,

View File

@@ -5,6 +5,10 @@ const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
withStrictGuardedFetchMode: <T>(params: T) => params,
withTrustedExplicitProxyGuardedFetchMode: <T>(params: T) => ({
...params,
mode: "trusted_explicit_proxy",
}),
}));
type FetchRemoteMedia = typeof import("./fetch.js").fetchRemoteMedia;
@@ -286,4 +290,34 @@ describe("fetchRemoteMedia", () => {
await expectBoundedErrorBodyCase(testCase.fetchImpl);
});
it("uses trusted explicit-proxy mode when the caller opts in for proxy-side DNS", async () => {
const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
await fetchRemoteMedia({
url: "https://api.telegram.org/file/bot123/photos/test.jpg",
fetchImpl,
lookupFn: makeLookupFn(),
trustExplicitProxyDns: true,
dispatcherAttempts: [
{
dispatcherPolicy: {
mode: "explicit-proxy",
proxyUrl: "http://localhost:8888",
allowPrivateProxy: true,
},
},
],
});
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
mode: "trusted_explicit_proxy",
dispatcherPolicy: expect.objectContaining({
mode: "explicit-proxy",
proxyUrl: "http://localhost:8888",
}),
}),
);
});
});

View File

@@ -1,6 +1,10 @@
import path from "node:path";
import { formatErrorMessage } from "../infra/errors.js";
import { fetchWithSsrFGuard, withStrictGuardedFetchMode } from "../infra/net/fetch-guard.js";
import {
fetchWithSsrFGuard,
withStrictGuardedFetchMode,
withTrustedExplicitProxyGuardedFetchMode,
} from "../infra/net/fetch-guard.js";
import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
import { redactSensitiveText } from "../logging/redact.js";
import { detectMime, extensionForMime } from "./mime.js";
@@ -44,6 +48,11 @@ type FetchMediaOptions = {
lookupFn?: LookupFn;
dispatcherAttempts?: FetchDispatcherAttempt[];
shouldRetryFetchError?: (error: unknown) => boolean;
/**
* Allow an operator-configured explicit proxy to resolve target DNS after
* hostname-policy checks instead of forcing local pinned-DNS first.
*/
trustExplicitProxyDns?: boolean;
};
function stripQuotes(value: string): string {
@@ -106,6 +115,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
lookupFn,
dispatcherAttempts,
shouldRetryFetchError,
trustExplicitProxyDns,
} = options;
const sourceUrl = redactMediaUrl(url);
@@ -118,7 +128,9 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
: [{ dispatcherPolicy: undefined, lookupFn }];
const runGuardedFetch = async (attempt: FetchDispatcherAttempt) =>
await fetchWithSsrFGuard(
withStrictGuardedFetchMode({
(trustExplicitProxyDns && attempt.dispatcherPolicy?.mode === "explicit-proxy"
? withTrustedExplicitProxyGuardedFetchMode
: withStrictGuardedFetchMode)({
url,
fetchImpl,
init: requestInit,