From 20c7cbbf783591c2608bd6823bf7d96ba2065da7 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Fri, 27 Mar 2026 13:39:24 -0700 Subject: [PATCH] Telegram: tighten media SSRF policy (#56004) * Telegram: tighten media SSRF policy * Telegram: restrict media downloads to configured hosts * Telegram: preserve custom media apiRoot hosts --- .../bot/delivery.resolve-media-retry.test.ts | 29 +++++++++++++++++-- .../src/bot/delivery.resolve-media.ts | 15 +++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts index 230dced44bc..c7de3067f97 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts @@ -155,8 +155,8 @@ async function expectTransientGetFileRetrySuccess() { expect.objectContaining({ url: `https://api.telegram.org/file/bot${BOT_TOKEN}/voice/file_0.oga`, ssrfPolicy: { - allowRfc2544BenchmarkRange: true, - allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: false, + hostnameAllowlist: ["api.telegram.org"], }, }), ); @@ -514,4 +514,29 @@ describe("resolveMedia original filename preservation", () => { ); expect(result).not.toBeNull(); }); + + it("allows a configured custom apiRoot host while keeping the hostname allowlist", async () => { + const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" }); + mockPdfFetchAndSave("file_42.pdf"); + + const ctx = makeCtx("document", getFile); + const result = await resolveMedia( + ctx, + MAX_MEDIA_BYTES, + BOT_TOKEN, + undefined, + "http://192.168.1.50:8081/custom-bot-api/", + ); + + expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect.objectContaining({ + ssrfPolicy: { + hostnameAllowlist: ["api.telegram.org", "192.168.1.50"], + allowedHostnames: ["192.168.1.50"], + allowRfc2544BenchmarkRange: false, + }, + }), + ); + expect(result).not.toBeNull(); + }); }); diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts index ed8b4b0815a..10d1a454c3b 100644 --- a/extensions/telegram/src/bot/delivery.resolve-media.ts +++ b/extensions/telegram/src/bot/delivery.resolve-media.ts @@ -20,21 +20,28 @@ const GrammyErrorCtor: typeof GrammyError | undefined = function buildTelegramMediaSsrfPolicy(apiRoot?: string) { const hostnames = ["api.telegram.org"]; + let allowedHostnames: string[] | undefined; if (apiRoot) { try { const customHost = new URL(apiRoot).hostname; if (customHost && !hostnames.includes(customHost)) { hostnames.push(customHost); + // A configured custom Bot API host is an explicit operator override and + // may legitimately live on a private network (for example, self-hosted + // Bot API or an internal reverse proxy). Keep that host reachable while + // still enforcing resolved-IP checks for the default public host. + allowedHostnames = [customHost]; } } catch { // invalid URL; fall through to default } } return { - // Telegram file downloads should trust the API hostname even when DNS/proxy - // resolution maps to private/internal ranges in restricted networks. - allowedHostnames: hostnames, - allowRfc2544BenchmarkRange: true, + // Restrict media downloads to the configured Telegram API hosts while still + // enforcing SSRF checks on the resolved and redirected targets. + hostnameAllowlist: hostnames, + ...(allowedHostnames ? { allowedHostnames } : {}), + allowRfc2544BenchmarkRange: false, }; }