From 724ca4c6e4dffa1d03470acb939284e5e304e382 Mon Sep 17 00:00:00 2001 From: Frank Yang Date: Sat, 14 Mar 2026 01:59:28 +0800 Subject: [PATCH] fix: redact telegram media fetch errors --- src/media/fetch.test.ts | 58 ++++++++++++++++++++++++++++++++++++++--- src/media/fetch.ts | 29 ++++++++++++++------- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/media/fetch.test.ts b/src/media/fetch.test.ts index 00966e26a34..4498ca4b550 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -25,13 +25,21 @@ function makeStallingFetch(firstChunk: Uint8Array) { }); } +function makeLookupFn() { + return vi.fn(async () => [{ address: "149.154.167.220", family: 4 }]) as unknown as NonNullable< + Parameters[0]["lookupFn"] + >; +} + describe("fetchRemoteMedia", () => { - type LookupFn = NonNullable[0]["lookupFn"]>; + const telegramToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd"; + const redactedTelegramToken = `${telegramToken.slice(0, 6)}…${telegramToken.slice(-4)}`; + const telegramFileUrl = `https://api.telegram.org/file/bot${telegramToken}/photos/1.jpg`; it("rejects when content-length exceeds maxBytes", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, - ]) as unknown as LookupFn; + ]) as unknown as NonNullable[0]["lookupFn"]>; const fetchImpl = async () => new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), { status: 200, @@ -51,7 +59,7 @@ describe("fetchRemoteMedia", () => { it("rejects when streamed payload exceeds maxBytes", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, - ]) as unknown as LookupFn; + ]) as unknown as NonNullable[0]["lookupFn"]>; const fetchImpl = async () => new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), { status: 200, @@ -70,7 +78,7 @@ describe("fetchRemoteMedia", () => { it("aborts stalled body reads when idle timeout expires", async () => { const lookupFn = vi.fn(async () => [ { address: "93.184.216.34", family: 4 }, - ]) as unknown as LookupFn; + ]) as unknown as NonNullable[0]["lookupFn"]>; const fetchImpl = makeStallingFetch(new Uint8Array([1, 2])); await expect( @@ -87,6 +95,48 @@ describe("fetchRemoteMedia", () => { }); }, 5_000); + it("redacts Telegram bot tokens from fetch failure messages", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error(`dial failed for ${telegramFileUrl}`); + }); + + const error = await fetchRemoteMedia({ + url: telegramFileUrl, + fetchImpl, + lookupFn: makeLookupFn(), + maxBytes: 1024, + ssrfPolicy: { + allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: true, + }, + }).catch((err: unknown) => err as Error); + + expect(error).toBeInstanceOf(Error); + const errorText = error instanceof Error ? String(error) : ""; + expect(errorText).not.toContain(telegramToken); + expect(errorText).toContain(`bot${redactedTelegramToken}`); + }); + + it("redacts Telegram bot tokens from HTTP error messages", async () => { + const fetchImpl = vi.fn(async () => new Response("unauthorized", { status: 401 })); + + const error = await fetchRemoteMedia({ + url: telegramFileUrl, + fetchImpl, + lookupFn: makeLookupFn(), + maxBytes: 1024, + ssrfPolicy: { + allowedHostnames: ["api.telegram.org"], + allowRfc2544BenchmarkRange: true, + }, + }).catch((err: unknown) => err as Error); + + expect(error).toBeInstanceOf(Error); + const errorText = error instanceof Error ? String(error) : ""; + expect(errorText).not.toContain(telegramToken); + expect(errorText).toContain(`bot${redactedTelegramToken}`); + }); + it("blocks private IP literals before fetching", async () => { const fetchImpl = vi.fn(); await expect( diff --git a/src/media/fetch.ts b/src/media/fetch.ts index 432b419da89..020ac8040bd 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -1,6 +1,8 @@ import path from "node:path"; +import { formatErrorMessage } from "../infra/errors.js"; import { fetchWithSsrFGuard, withStrictGuardedFetchMode } 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"; import { readResponseWithLimit } from "./read-response-with-limit.js"; @@ -84,6 +86,10 @@ async function readErrorBodySnippet(res: Response, maxChars = 200): Promise { const { url, @@ -99,6 +105,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise maxBytes) { throw new MediaFetchError( "max_bytes", - `Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`, + `Failed to fetch media from ${sourceUrl}: content length ${length} exceeds maxBytes ${maxBytes}`, ); } } @@ -185,7 +196,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise new MediaFetchError( "max_bytes", - `Failed to fetch media from ${res.url || url}: payload exceeds maxBytes ${maxBytes}`, + `Failed to fetch media from ${redactMediaUrl(res.url || url)}: payload exceeds maxBytes ${maxBytes}`, ), chunkTimeoutMs: readIdleTimeoutMs, }) @@ -196,7 +207,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise