mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: reject malformed media content length
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user