fix: reject malformed media content length

This commit is contained in:
Peter Steinberger
2026-05-28 13:15:25 -04:00
parent f3a23f8f5d
commit b2fc8af1b1
5 changed files with 115 additions and 11 deletions
+14
View File
@@ -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;
}
+41
View File
@@ -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 () =>
+14 -4
View File
@@ -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<void> {
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",
+33
View File
@@ -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<Uint8Array>({
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;
+13 -7
View File
@@ -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);