diff --git a/src/media/content-length.ts b/src/media/content-length.ts new file mode 100644 index 000000000000..75aee95973ae --- /dev/null +++ b/src/media/content-length.ts @@ -0,0 +1,14 @@ +export function parseMediaContentLength(raw: string | null): number | null { + if (raw === null) { + return null; + } + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new Error(`invalid content-length header: ${raw}`); + } + const size = Number(trimmed); + if (!Number.isSafeInteger(size)) { + throw new Error(`invalid content-length header: ${raw}`); + } + return size; +} diff --git a/src/media/fetch.test.ts b/src/media/fetch.test.ts index bf74e81f5cbb..1541f831e7d5 100644 --- a/src/media/fetch.test.ts +++ b/src/media/fetch.test.ts @@ -286,6 +286,28 @@ describe("readRemoteMediaBuffer", () => { expect(body.wasCanceled()).toBe(true); }); + it("rejects malformed content-length before remote buffer reads", async () => { + const body = makeCancelableStream([new Uint8Array([1, 2, 3, 4, 5])]); + const fetchImpl = vi.fn( + async () => + new Response(body.stream, { + status: 200, + headers: { "content-length": "1e9" }, + }), + ); + + await expect( + readRemoteMediaBuffer({ + url: "https://example.com/file.bin", + fetchImpl, + maxBytes: 4, + lookupFn: makeLookupFn(), + }), + ).rejects.toThrow("invalid content-length header: 1e9"); + + expect(body.wasCanceled()).toBe(true); + }); + it("applies a default stream limit when maxBytes is omitted", async () => { const fetchImpl = vi.fn( async () => @@ -632,6 +654,25 @@ describe("readRemoteMediaBuffer", () => { expect(body.wasCanceled()).toBe(true); }); + it("rejects malformed content-length before saving responses", async () => { + const body = makeCancelableStream([new Uint8Array([1, 2, 3, 4, 5])]); + + await expect( + saveResponseMedia( + new Response(body.stream, { + status: 200, + headers: { "content-length": "1e9" }, + }), + { + maxBytes: 4, + sourceUrl: "https://example.com/file.bin", + }, + ), + ).rejects.toThrow("invalid content-length header: 1e9"); + + expect(body.wasCanceled()).toBe(true); + }); + it("decodes URL path basenames when deriving remote media filenames", async () => { const fetchImpl = vi.fn( async () => diff --git a/src/media/fetch.ts b/src/media/fetch.ts index cc850f377ad9..935002df8ba2 100644 --- a/src/media/fetch.ts +++ b/src/media/fetch.ts @@ -9,6 +9,7 @@ import { retryAsync, type RetryOptions } from "../infra/retry.js"; import { isAbortError, isTransientNetworkError } from "../infra/unhandled-rejections.js"; import { redactSensitiveText } from "../logging/redact.js"; import { MAX_DOCUMENT_BYTES } from "./constants.js"; +import { parseMediaContentLength } from "./content-length.js"; import { basenameFromAnyPath, extnameFromAnyPath } from "./file-name.js"; import { detectMime, extensionForMime } from "./mime.js"; import { readResponseTextSnippet, readResponseWithLimit } from "./read-response-with-limit.js"; @@ -286,12 +287,21 @@ async function assertMediaContentLength(params: { sourceUrl: string; maxBytes: number; }): Promise { - const contentLength = params.res.headers.get("content-length"); - if (!contentLength) { + let length: number | null; + try { + length = parseMediaContentLength(params.res.headers.get("content-length")); + } catch (err) { + await discardIgnoredResponseBody(params.res); + throw new MediaFetchError( + "http_error", + `Failed to fetch media from ${params.sourceUrl}: ${formatErrorMessage(err)}`, + { cause: err }, + ); + } + if (length === null) { return; } - const length = Number(contentLength); - if (Number.isFinite(length) && length > params.maxBytes) { + if (length > params.maxBytes) { await discardIgnoredResponseBody(params.res); throw new MediaFetchError( "max_bytes", diff --git a/src/media/input-files.fetch-guard.test.ts b/src/media/input-files.fetch-guard.test.ts index 68eb20996b14..f215e544a987 100644 --- a/src/media/input-files.fetch-guard.test.ts +++ b/src/media/input-files.fetch-guard.test.ts @@ -299,6 +299,39 @@ describe("fetchWithGuard", () => { expect(release).toHaveBeenCalledTimes(1); }); + it("rejects malformed content-length before reading input files", async () => { + let canceled = false; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])); + }, + cancel() { + canceled = true; + }, + }); + const release = vi.fn(async () => {}); + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: new Response(stream, { + status: 200, + headers: { "content-length": "1e9", "content-type": "application/octet-stream" }, + }), + release, + finalUrl: "https://example.com/file.bin", + }); + + await expect( + fetchWithGuard({ + url: "https://example.com/file.bin", + maxBytes: 1024, + timeoutMs: 1000, + maxRedirects: 0, + }), + ).rejects.toThrow("invalid content-length header: 1e9"); + + expect(canceled).toBe(true); + expect(release).toHaveBeenCalledTimes(1); + }); + it("rejects oversized streamed payloads and cancels the stream", async () => { let canceled = false; let pulls = 0; diff --git a/src/media/input-files.ts b/src/media/input-files.ts index a89a8557c3d5..ad84fa934064 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -7,6 +7,7 @@ import { normalizeOptionalString, } from "../shared/string-coerce.js"; import { canonicalizeBase64, estimateBase64DecodedBytes } from "./base64.js"; +import { parseMediaContentLength } from "./content-length.js"; import { convertHeicToJpeg } from "./media-services.js"; import { detectMime } from "./mime.js"; import { extractPdfContent, type PdfExtractedImage } from "./pdf-extract.js"; @@ -196,13 +197,18 @@ export async function fetchWithGuard(params: { throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); } - const contentLength = response.headers.get("content-length"); - if (contentLength) { - const size = Number(contentLength); - if (Number.isFinite(size) && size > params.maxBytes) { - await discardIgnoredResponseBody(response); - throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`); - } + let contentLength: number | null; + try { + contentLength = parseMediaContentLength(response.headers.get("content-length")); + } catch (err) { + await discardIgnoredResponseBody(response); + throw err; + } + if (contentLength !== null && contentLength > params.maxBytes) { + await discardIgnoredResponseBody(response); + throw new Error( + `Content too large: ${contentLength} bytes (limit: ${params.maxBytes} bytes)`, + ); } const buffer = await readResponseWithLimit(response, params.maxBytes);