fix: redact telegram media fetch errors

This commit is contained in:
Frank Yang
2026-03-14 01:59:28 +08:00
parent 653f54fef7
commit 724ca4c6e4
2 changed files with 74 additions and 13 deletions

View File

@@ -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<typeof fetchRemoteMedia>[0]["lookupFn"]
>;
}
describe("fetchRemoteMedia", () => {
type LookupFn = NonNullable<Parameters<typeof fetchRemoteMedia>[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<Parameters<typeof fetchRemoteMedia>[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<Parameters<typeof fetchRemoteMedia>[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<Parameters<typeof fetchRemoteMedia>[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(

View File

@@ -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<stri
}
}
function redactMediaUrl(url: string): string {
return redactSensitiveText(url);
}
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
const {
url,
@@ -99,6 +105,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
fallbackDispatcherPolicy,
shouldRetryFetchError,
} = options;
const sourceUrl = redactMediaUrl(url);
let res: Response;
let finalUrl = url;
@@ -129,7 +136,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
result = await runGuardedFetch(fallbackDispatcherPolicy);
} catch (fallbackErr) {
const combined = new Error(
`Primary fetch failed and fallback fetch also failed for ${url}`,
`Primary fetch failed and fallback fetch also failed for ${sourceUrl}`,
{ cause: fallbackErr },
);
(combined as Error & { primaryError?: unknown }).primaryError = err;
@@ -143,15 +150,19 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
finalUrl = result.finalUrl;
release = result.release;
} catch (err) {
throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}: ${String(err)}`, {
cause: err,
});
throw new MediaFetchError(
"fetch_failed",
`Failed to fetch media from ${sourceUrl}: ${formatErrorMessage(err)}`,
{
cause: err,
},
);
}
try {
if (!res.ok) {
const statusText = res.statusText ? ` ${res.statusText}` : "";
const redirected = finalUrl !== url ? ` (redirected to ${finalUrl})` : "";
const redirected = finalUrl !== url ? ` (redirected to ${redactMediaUrl(finalUrl)})` : "";
let detail = `HTTP ${res.status}${statusText}`;
if (!res.body) {
detail = `HTTP ${res.status}${statusText}; empty response body`;
@@ -163,7 +174,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
}
throw new MediaFetchError(
"http_error",
`Failed to fetch media from ${url}${redirected}: ${detail}`,
`Failed to fetch media from ${sourceUrl}${redirected}: ${redactSensitiveText(detail)}`,
);
}
@@ -173,7 +184,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
if (Number.isFinite(length) && length > 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<Fetc
onOverflow: ({ maxBytes, res }) =>
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<Fetc
}
throw new MediaFetchError(
"fetch_failed",
`Failed to fetch media from ${res.url || url}: ${String(err)}`,
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: ${formatErrorMessage(err)}`,
{ cause: err },
);
}