Skip to content

Commit 724ca4c

Browse files
committed
fix: redact telegram media fetch errors
1 parent 653f54f commit 724ca4c

File tree

2 files changed

+74
-13
lines changed

2 files changed

+74
-13
lines changed

src/media/fetch.test.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,21 @@ function makeStallingFetch(firstChunk: Uint8Array) {
2525
});
2626
}
2727

28+
function makeLookupFn() {
29+
return vi.fn(async () => [{ address: "149.154.167.220", family: 4 }]) as unknown as NonNullable<
30+
Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]
31+
>;
32+
}
33+
2834
describe("fetchRemoteMedia", () => {
29-
type LookupFn = NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
35+
const telegramToken = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcd";
36+
const redactedTelegramToken = `${telegramToken.slice(0, 6)}${telegramToken.slice(-4)}`;
37+
const telegramFileUrl = `https://api.telegram.org/file/bot${telegramToken}/photos/1.jpg`;
3038

3139
it("rejects when content-length exceeds maxBytes", async () => {
3240
const lookupFn = vi.fn(async () => [
3341
{ address: "93.184.216.34", family: 4 },
34-
]) as unknown as LookupFn;
42+
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
3543
const fetchImpl = async () =>
3644
new Response(makeStream([new Uint8Array([1, 2, 3, 4, 5])]), {
3745
status: 200,
@@ -51,7 +59,7 @@ describe("fetchRemoteMedia", () => {
5159
it("rejects when streamed payload exceeds maxBytes", async () => {
5260
const lookupFn = vi.fn(async () => [
5361
{ address: "93.184.216.34", family: 4 },
54-
]) as unknown as LookupFn;
62+
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
5563
const fetchImpl = async () =>
5664
new Response(makeStream([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]), {
5765
status: 200,
@@ -70,7 +78,7 @@ describe("fetchRemoteMedia", () => {
7078
it("aborts stalled body reads when idle timeout expires", async () => {
7179
const lookupFn = vi.fn(async () => [
7280
{ address: "93.184.216.34", family: 4 },
73-
]) as unknown as LookupFn;
81+
]) as unknown as NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
7482
const fetchImpl = makeStallingFetch(new Uint8Array([1, 2]));
7583

7684
await expect(
@@ -87,6 +95,48 @@ describe("fetchRemoteMedia", () => {
8795
});
8896
}, 5_000);
8997

98+
it("redacts Telegram bot tokens from fetch failure messages", async () => {
99+
const fetchImpl = vi.fn(async () => {
100+
throw new Error(`dial failed for ${telegramFileUrl}`);
101+
});
102+
103+
const error = await fetchRemoteMedia({
104+
url: telegramFileUrl,
105+
fetchImpl,
106+
lookupFn: makeLookupFn(),
107+
maxBytes: 1024,
108+
ssrfPolicy: {
109+
allowedHostnames: ["api.telegram.org"],
110+
allowRfc2544BenchmarkRange: true,
111+
},
112+
}).catch((err: unknown) => err as Error);
113+
114+
expect(error).toBeInstanceOf(Error);
115+
const errorText = error instanceof Error ? String(error) : "";
116+
expect(errorText).not.toContain(telegramToken);
117+
expect(errorText).toContain(`bot${redactedTelegramToken}`);
118+
});
119+
120+
it("redacts Telegram bot tokens from HTTP error messages", async () => {
121+
const fetchImpl = vi.fn(async () => new Response("unauthorized", { status: 401 }));
122+
123+
const error = await fetchRemoteMedia({
124+
url: telegramFileUrl,
125+
fetchImpl,
126+
lookupFn: makeLookupFn(),
127+
maxBytes: 1024,
128+
ssrfPolicy: {
129+
allowedHostnames: ["api.telegram.org"],
130+
allowRfc2544BenchmarkRange: true,
131+
},
132+
}).catch((err: unknown) => err as Error);
133+
134+
expect(error).toBeInstanceOf(Error);
135+
const errorText = error instanceof Error ? String(error) : "";
136+
expect(errorText).not.toContain(telegramToken);
137+
expect(errorText).toContain(`bot${redactedTelegramToken}`);
138+
});
139+
90140
it("blocks private IP literals before fetching", async () => {
91141
const fetchImpl = vi.fn();
92142
await expect(

src/media/fetch.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import path from "node:path";
2+
import { formatErrorMessage } from "../infra/errors.js";
23
import { fetchWithSsrFGuard, withStrictGuardedFetchMode } from "../infra/net/fetch-guard.js";
34
import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js";
5+
import { redactSensitiveText } from "../logging/redact.js";
46
import { detectMime, extensionForMime } from "./mime.js";
57
import { readResponseWithLimit } from "./read-response-with-limit.js";
68

@@ -84,6 +86,10 @@ async function readErrorBodySnippet(res: Response, maxChars = 200): Promise<stri
8486
}
8587
}
8688

89+
function redactMediaUrl(url: string): string {
90+
return redactSensitiveText(url);
91+
}
92+
8793
export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<FetchMediaResult> {
8894
const {
8995
url,
@@ -99,6 +105,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
99105
fallbackDispatcherPolicy,
100106
shouldRetryFetchError,
101107
} = options;
108+
const sourceUrl = redactMediaUrl(url);
102109

103110
let res: Response;
104111
let finalUrl = url;
@@ -129,7 +136,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
129136
result = await runGuardedFetch(fallbackDispatcherPolicy);
130137
} catch (fallbackErr) {
131138
const combined = new Error(
132-
`Primary fetch failed and fallback fetch also failed for ${url}`,
139+
`Primary fetch failed and fallback fetch also failed for ${sourceUrl}`,
133140
{ cause: fallbackErr },
134141
);
135142
(combined as Error & { primaryError?: unknown }).primaryError = err;
@@ -143,15 +150,19 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
143150
finalUrl = result.finalUrl;
144151
release = result.release;
145152
} catch (err) {
146-
throw new MediaFetchError("fetch_failed", `Failed to fetch media from ${url}: ${String(err)}`, {
147-
cause: err,
148-
});
153+
throw new MediaFetchError(
154+
"fetch_failed",
155+
`Failed to fetch media from ${sourceUrl}: ${formatErrorMessage(err)}`,
156+
{
157+
cause: err,
158+
},
159+
);
149160
}
150161

151162
try {
152163
if (!res.ok) {
153164
const statusText = res.statusText ? ` ${res.statusText}` : "";
154-
const redirected = finalUrl !== url ? ` (redirected to ${finalUrl})` : "";
165+
const redirected = finalUrl !== url ? ` (redirected to ${redactMediaUrl(finalUrl)})` : "";
155166
let detail = `HTTP ${res.status}${statusText}`;
156167
if (!res.body) {
157168
detail = `HTTP ${res.status}${statusText}; empty response body`;
@@ -163,7 +174,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
163174
}
164175
throw new MediaFetchError(
165176
"http_error",
166-
`Failed to fetch media from ${url}${redirected}: ${detail}`,
177+
`Failed to fetch media from ${sourceUrl}${redirected}: ${redactSensitiveText(detail)}`,
167178
);
168179
}
169180

@@ -173,7 +184,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
173184
if (Number.isFinite(length) && length > maxBytes) {
174185
throw new MediaFetchError(
175186
"max_bytes",
176-
`Failed to fetch media from ${url}: content length ${length} exceeds maxBytes ${maxBytes}`,
187+
`Failed to fetch media from ${sourceUrl}: content length ${length} exceeds maxBytes ${maxBytes}`,
177188
);
178189
}
179190
}
@@ -185,7 +196,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
185196
onOverflow: ({ maxBytes, res }) =>
186197
new MediaFetchError(
187198
"max_bytes",
188-
`Failed to fetch media from ${res.url || url}: payload exceeds maxBytes ${maxBytes}`,
199+
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: payload exceeds maxBytes ${maxBytes}`,
189200
),
190201
chunkTimeoutMs: readIdleTimeoutMs,
191202
})
@@ -196,7 +207,7 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise<Fetc
196207
}
197208
throw new MediaFetchError(
198209
"fetch_failed",
199-
`Failed to fetch media from ${res.url || url}: ${String(err)}`,
210+
`Failed to fetch media from ${redactMediaUrl(res.url || url)}: ${formatErrorMessage(err)}`,
200211
{ cause: err },
201212
);
202213
}

0 commit comments

Comments
 (0)