From d1b4dbffc31e6abf45097abe15973c957b66a0f4 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:09:20 +1000 Subject: [PATCH] fix: bound default media response reads --- src/media/fetch.test.ts | 26 ++++++++++++++++++++++++-- src/media/fetch.ts | 28 +++++++++++++++------------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/media/fetch.test.ts b/src/media/fetch.test.ts index e372dc7660e..07f3ac2b729 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -11,9 +11,11 @@ vi.mock("../infra/net/fetch-guard.js", () => ({ }), })); -type FetchRemoteMedia = typeof import("./fetch.js").fetchRemoteMedia; +type FetchModule = typeof import("./fetch.js"); +type FetchRemoteMedia = FetchModule["fetchRemoteMedia"]; type LookupFn = NonNullable[0]["lookupFn"]>; let fetchRemoteMedia: FetchRemoteMedia; +let defaultFetchMediaMaxBytes: number; function makeStream(chunks: Uint8Array[]) { return new ReadableStream({ @@ -177,7 +179,9 @@ describe("fetchRemoteMedia", () => { const botFileUrl = `https://files.example.test/file/bot${botToken}/photos/1.jpg`; beforeAll(async () => { - ({ fetchRemoteMedia } = await import("./fetch.js")); + const fetchModule = await import("./fetch.js"); + fetchRemoteMedia = fetchModule.fetchRemoteMedia; + defaultFetchMediaMaxBytes = fetchModule.DEFAULT_FETCH_MEDIA_MAX_BYTES; }); beforeEach(() => { @@ -223,6 +227,24 @@ describe("fetchRemoteMedia", () => { await expectRemoteMediaMaxBytesError({ fetchImpl, maxBytes: 4 }); }); + it("applies a default stream limit when maxBytes is omitted", async () => { + const fetchImpl = vi.fn( + async () => + new Response(makeStream([new Uint8Array([1])]), { + status: 200, + headers: { "content-length": String(defaultFetchMediaMaxBytes + 1) }, + }), + ); + + await expect( + fetchRemoteMedia({ + url: "https://example.com/file.bin", + fetchImpl, + lookupFn: makeLookupFn(), + }), + ).rejects.toThrow(`exceeds maxBytes ${defaultFetchMediaMaxBytes}`); + }); + it.each([ { name: "redacts bot tokens from fetch failure messages", diff --git a/src/media/fetch.ts b/src/media/fetch.ts index 50a8d031fa6..668d57f41de 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -7,9 +7,12 @@ import { } from "../infra/net/fetch-guard.js"; import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ssrf.js"; import { redactSensitiveText } from "../logging/redact.js"; +import { MAX_DOCUMENT_BYTES } from "./constants.js"; import { detectMime, extensionForMime } from "./mime.js"; import { readResponseTextSnippet, readResponseWithLimit } from "./read-response-with-limit.js"; +export const DEFAULT_FETCH_MEDIA_MAX_BYTES = MAX_DOCUMENT_BYTES; + type FetchMediaResult = { buffer: Buffer; contentType?: string; @@ -209,29 +212,28 @@ export async function fetchRemoteMedia(options: FetchMediaOptions): Promise maxBytes) { + if (Number.isFinite(length) && length > effectiveMaxBytes) { throw new MediaFetchError( "max_bytes", - `Failed to fetch media from ${sourceUrl}: content length ${length} exceeds maxBytes ${maxBytes}`, + `Failed to fetch media from ${sourceUrl}: content length ${length} exceeds maxBytes ${effectiveMaxBytes}`, ); } } let buffer: Buffer; try { - buffer = maxBytes - ? await readResponseWithLimit(res, maxBytes, { - onOverflow: ({ maxBytes, res }) => - new MediaFetchError( - "max_bytes", - `Failed to fetch media from ${redactMediaUrl(res.url || url)}: payload exceeds maxBytes ${maxBytes}`, - ), - chunkTimeoutMs: readIdleTimeoutMs, - }) - : Buffer.from(await res.arrayBuffer()); + buffer = await readResponseWithLimit(res, effectiveMaxBytes, { + onOverflow: ({ maxBytes, res }) => + new MediaFetchError( + "max_bytes", + `Failed to fetch media from ${redactMediaUrl(res.url || url)}: payload exceeds maxBytes ${maxBytes}`, + ), + chunkTimeoutMs: readIdleTimeoutMs, + }); } catch (err) { if (err instanceof MediaFetchError) { throw err;