diff --git a/CHANGELOG.md b/CHANGELOG.md index 7694e4e68f4..84548459b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,7 @@ Docs: https://docs.openclaw.ai - Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant. - Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan. - Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant. +- Media/images: reject oversized decoded image inputs before metadata and resize backends run, so tiny compressed image bombs fail early instead of exhausting gateway memory. (#58226) Thanks @AntAISecurityLab and @vincentkoc. - Voice Call/media stream: cap inbound WebSocket frame size before `start` validation so oversized pre-start frames are dropped before JSON parsing. Thanks @Kazamayc and @vincentkoc. - Pairing: enforce pending request limits per account instead of per shared channel queue, so one account's outstanding pairing challenges no longer block new pairing on other accounts. Thanks @smaeljaish771 and @vincentkoc. - Exec approvals: unwrap `caffeinate` and `sandbox-exec` before persisting allow-always trust so later shell payload changes still require a fresh approval. Thanks @tdjackey and @vincentkoc. diff --git a/src/media/image-ops.input-guard.test.ts b/src/media/image-ops.input-guard.test.ts new file mode 100644 index 00000000000..fde3462b07a --- /dev/null +++ b/src/media/image-ops.input-guard.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { getImageMetadata, MAX_IMAGE_INPUT_PIXELS, resizeToJpeg } from "./image-ops.js"; +import { createPngBufferWithDimensions } from "./test-helpers.js"; + +describe("image input pixel guard", () => { + const oversizedPng = createPngBufferWithDimensions({ width: 8_000, height: 4_000 }); + const overflowedPng = createPngBufferWithDimensions({ + width: 4_294_967_295, + height: 4_294_967_295, + }); + + it("returns null metadata for images above the pixel limit", async () => { + await expect(getImageMetadata(oversizedPng)).resolves.toBeNull(); + expect(8_000 * 4_000).toBeGreaterThan(MAX_IMAGE_INPUT_PIXELS); + }); + + it("rejects oversized images before resize work starts", async () => { + await expect( + resizeToJpeg({ + buffer: oversizedPng, + maxSide: 2_048, + quality: 80, + }), + ).rejects.toThrow(/pixel input limit/i); + }); + + it("rejects overflowed pixel counts before resize work starts", async () => { + await expect( + resizeToJpeg({ + buffer: overflowedPng, + maxSide: 2_048, + quality: 80, + }), + ).rejects.toThrow(/pixel input limit/i); + }); + + it("fails closed when sips cannot determine image dimensions", async () => { + const previousBackend = process.env.OPENCLAW_IMAGE_BACKEND; + process.env.OPENCLAW_IMAGE_BACKEND = "sips"; + try { + await expect( + resizeToJpeg({ + buffer: Buffer.from("not-an-image"), + maxSide: 2_048, + quality: 80, + }), + ).rejects.toThrow(/unable to determine image dimensions/i); + } finally { + if (previousBackend === undefined) { + delete process.env.OPENCLAW_IMAGE_BACKEND; + } else { + process.env.OPENCLAW_IMAGE_BACKEND = previousBackend; + } + } + }); +}); diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index 3403d25bcb2..4c917a97768 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -11,6 +11,7 @@ export type ImageMetadata = { }; export const IMAGE_REDUCE_QUALITY_STEPS = [85, 75, 65, 55, 45, 35] as const; +export const MAX_IMAGE_INPUT_PIXELS = 25_000_000; export function buildImageResizeSideGrid(maxSide: number, sideStart: number): number[] { return [sideStart, 1800, 1600, 1400, 1200, 1000, 800] @@ -33,7 +34,181 @@ function prefersSips(): boolean { async function loadSharp(): Promise<(buffer: Buffer) => ReturnType> { const mod = (await import("sharp")) as unknown as { default?: Sharp }; const sharp = mod.default ?? (mod as unknown as Sharp); - return (buffer) => sharp(buffer, { failOnError: false }); + return (buffer) => + sharp(buffer, { + failOnError: false, + limitInputPixels: MAX_IMAGE_INPUT_PIXELS, + }); +} + +function isPositiveImageDimension(value: number): boolean { + return Number.isInteger(value) && value > 0; +} + +function buildImageMetadata(width: number, height: number): ImageMetadata | null { + if (!isPositiveImageDimension(width) || !isPositiveImageDimension(height)) { + return null; + } + return { width, height }; +} + +function readPngMetadata(buffer: Buffer): ImageMetadata | null { + if (buffer.length < 24) { + return null; + } + if ( + buffer[0] !== 0x89 || + buffer[1] !== 0x50 || + buffer[2] !== 0x4e || + buffer[3] !== 0x47 || + buffer[4] !== 0x0d || + buffer[5] !== 0x0a || + buffer[6] !== 0x1a || + buffer[7] !== 0x0a || + buffer.toString("ascii", 12, 16) !== "IHDR" + ) { + return null; + } + return buildImageMetadata(buffer.readUInt32BE(16), buffer.readUInt32BE(20)); +} + +function readGifMetadata(buffer: Buffer): ImageMetadata | null { + if (buffer.length < 10) { + return null; + } + const signature = buffer.toString("ascii", 0, 6); + if (signature !== "GIF87a" && signature !== "GIF89a") { + return null; + } + return buildImageMetadata(buffer.readUInt16LE(6), buffer.readUInt16LE(8)); +} + +function readWebpMetadata(buffer: Buffer): ImageMetadata | null { + if ( + buffer.length < 30 || + buffer.toString("ascii", 0, 4) !== "RIFF" || + buffer.toString("ascii", 8, 12) !== "WEBP" + ) { + return null; + } + const chunkType = buffer.toString("ascii", 12, 16); + if (chunkType === "VP8X") { + if (buffer.length < 30) { + return null; + } + return buildImageMetadata(1 + buffer.readUIntLE(24, 3), 1 + buffer.readUIntLE(27, 3)); + } + if (chunkType === "VP8 ") { + if (buffer.length < 30) { + return null; + } + return buildImageMetadata(buffer.readUInt16LE(26) & 0x3fff, buffer.readUInt16LE(28) & 0x3fff); + } + if (chunkType === "VP8L") { + if (buffer.length < 25 || buffer[20] !== 0x2f) { + return null; + } + const bits = buffer[21] | (buffer[22] << 8) | (buffer[23] << 16) | (buffer[24] << 24); + return buildImageMetadata((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1); + } + return null; +} + +function readJpegMetadata(buffer: Buffer): ImageMetadata | null { + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null; + } + + let offset = 2; + while (offset + 8 < buffer.length) { + while (offset < buffer.length && buffer[offset] === 0xff) { + offset++; + } + if (offset >= buffer.length) { + return null; + } + + const marker = buffer[offset]; + offset++; + if (marker === 0xd8 || marker === 0xd9) { + continue; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { + continue; + } + if (offset + 1 >= buffer.length) { + return null; + } + + const segmentLength = buffer.readUInt16BE(offset); + if (segmentLength < 2 || offset + segmentLength > buffer.length) { + return null; + } + + const isStartOfFrame = + marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (isStartOfFrame) { + if (segmentLength < 7 || offset + 6 >= buffer.length) { + return null; + } + return buildImageMetadata(buffer.readUInt16BE(offset + 5), buffer.readUInt16BE(offset + 3)); + } + + offset += segmentLength; + } + + return null; +} + +function readImageMetadataFromHeader(buffer: Buffer): ImageMetadata | null { + return ( + readPngMetadata(buffer) ?? + readGifMetadata(buffer) ?? + readWebpMetadata(buffer) ?? + readJpegMetadata(buffer) + ); +} + +function countImagePixels(meta: ImageMetadata): number | null { + const pixels = meta.width * meta.height; + return Number.isSafeInteger(pixels) ? pixels : null; +} + +function exceedsImagePixelLimit(meta: ImageMetadata): boolean { + return meta.width > Math.floor(MAX_IMAGE_INPUT_PIXELS / meta.height); +} + +function createImagePixelLimitError(meta: ImageMetadata): Error { + const pixelCount = countImagePixels(meta); + const detail = + pixelCount === null + ? `${meta.width}x${meta.height}` + : `${meta.width}x${meta.height} (${pixelCount} pixels)`; + return new Error( + `Image dimensions exceed the ${MAX_IMAGE_INPUT_PIXELS.toLocaleString("en-US")} pixel input limit: ${detail}`, + ); +} + +function validateImagePixelLimit(meta: ImageMetadata): ImageMetadata { + if (exceedsImagePixelLimit(meta)) { + throw createImagePixelLimitError(meta); + } + return meta; +} + +async function readImageMetadataForLimit(buffer: Buffer): Promise { + return readImageMetadataFromHeader(buffer); +} + +async function assertImagePixelLimit(buffer: Buffer): Promise { + const meta = await readImageMetadataForLimit(buffer); + if (!meta) { + if (prefersSips()) { + throw new Error("Unable to determine image dimensions; refusing to process"); + } + return; + } + validateImagePixelLimit(meta); } /** @@ -215,6 +390,15 @@ async function sipsConvertToJpeg(buffer: Buffer): Promise { } export async function getImageMetadata(buffer: Buffer): Promise { + const metadataForLimit = await readImageMetadataForLimit(buffer).catch(() => null); + if (metadataForLimit) { + try { + return validateImagePixelLimit(metadataForLimit); + } catch { + return null; + } + } + if (prefersSips()) { return await sipsMetadataFromBuffer(buffer).catch(() => null); } @@ -230,7 +414,7 @@ export async function getImageMetadata(buffer: Buffer): Promise { + await assertImagePixelLimit(buffer); + if (prefersSips()) { try { const orientation = readJpegExifOrientation(buffer); @@ -316,6 +502,8 @@ export async function resizeToJpeg(params: { quality: number; withoutEnlargement?: boolean; }): Promise { + await assertImagePixelLimit(params.buffer); + if (prefersSips()) { // Normalize EXIF orientation BEFORE resizing (sips resize doesn't auto-rotate) const normalized = await normalizeExifOrientationSips(params.buffer); @@ -356,6 +544,8 @@ export async function resizeToJpeg(params: { } export async function convertHeicToJpeg(buffer: Buffer): Promise { + await assertImagePixelLimit(buffer); + if (prefersSips()) { return await sipsConvertToJpeg(buffer); } @@ -368,6 +558,8 @@ export async function convertHeicToJpeg(buffer: Buffer): Promise { * Returns true if the image has alpha, false otherwise. */ export async function hasAlphaChannel(buffer: Buffer): Promise { + await assertImagePixelLimit(buffer); + try { const sharp = await loadSharp(); const meta = await sharp(buffer).metadata(); @@ -390,6 +582,8 @@ export async function resizeToPng(params: { compressionLevel?: number; withoutEnlargement?: boolean; }): Promise { + await assertImagePixelLimit(params.buffer); + const sharp = await loadSharp(); // Compression level 6 is a good balance (0=fastest, 9=smallest) const compressionLevel = params.compressionLevel ?? 6; diff --git a/src/media/test-helpers.ts b/src/media/test-helpers.ts new file mode 100644 index 00000000000..def65012d3a --- /dev/null +++ b/src/media/test-helpers.ts @@ -0,0 +1,51 @@ +export function createPngBufferWithDimensions(params: { width: number; height: number }): Buffer { + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdrLength = Buffer.from([0x00, 0x00, 0x00, 0x0d]); + const ihdrType = Buffer.from("IHDR", "ascii"); + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(params.width, 0); + ihdrData.writeUInt32BE(params.height, 4); + ihdrData[8] = 8; + ihdrData[9] = 6; + const ihdrCrc = Buffer.alloc(4); + const iend = Buffer.from([ + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + return Buffer.concat([signature, ihdrLength, ihdrType, ihdrData, ihdrCrc, iend]); +} + +export function createJpegBufferWithDimensions(params: { width: number; height: number }): Buffer { + if (params.width > 0xffff || params.height > 0xffff) { + throw new Error("Synthetic JPEG helper only supports 16-bit dimensions"); + } + + const app0 = Buffer.from([ + 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, + 0x00, 0x00, + ]); + const sof0 = Buffer.from([ + 0xff, + 0xc0, + 0x00, + 0x11, + 0x08, + params.height >> 8, + params.height & 0xff, + params.width >> 8, + params.width & 0xff, + 0x03, + 0x01, + 0x11, + 0x00, + 0x02, + 0x11, + 0x00, + 0x03, + 0x11, + 0x00, + ]); + const sos = Buffer.from([ + 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, + ]); + return Buffer.concat([Buffer.from([0xff, 0xd8]), app0, sof0, sos, Buffer.from([0xff, 0xd9])]); +} diff --git a/src/media/web-media.test.ts b/src/media/web-media.test.ts index 021cfcde845..070616e3727 100644 --- a/src/media/web-media.test.ts +++ b/src/media/web-media.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; +import { createJpegBufferWithDimensions, createPngBufferWithDimensions } from "./test-helpers.js"; let loadWebMedia: typeof import("./web-media.js").loadWebMedia; @@ -10,13 +11,19 @@ const TINY_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; let fixtureRoot = ""; +let oversizedJpegFile = ""; let tinyPngFile = ""; beforeAll(async () => { ({ loadWebMedia } = await import("./web-media.js")); fixtureRoot = await fs.mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "web-media-core-")); tinyPngFile = path.join(fixtureRoot, "tiny.png"); + oversizedJpegFile = path.join(fixtureRoot, "oversized.jpg"); await fs.writeFile(tinyPngFile, Buffer.from(TINY_PNG_BASE64, "base64")); + await fs.writeFile( + oversizedJpegFile, + createJpegBufferWithDimensions({ width: 6_000, height: 5_000 }), + ); }); afterAll(async () => { @@ -88,6 +95,24 @@ describe("loadWebMedia", () => { await expectLoadedWebMediaCase(createUrl()); }); + it("rejects oversized pixel-count images before decode/resize backends run", async () => { + const oversizedPngFile = path.join(fixtureRoot, "oversized.png"); + await fs.writeFile( + oversizedPngFile, + createPngBufferWithDimensions({ width: 8_000, height: 4_000 }), + ); + + await expect(loadWebMedia(oversizedPngFile, createLocalWebMediaOptions())).rejects.toThrow( + /pixel input limit/i, + ); + }); + + it("preserves pixel-limit errors for oversized JPEG optimization", async () => { + await expect(loadWebMedia(oversizedJpegFile, createLocalWebMediaOptions())).rejects.toThrow( + /pixel input limit/i, + ); + }); + it.each([ { name: "rejects remote-host file URLs before filesystem checks", diff --git a/src/media/web-media.ts b/src/media/web-media.ts index 91dc474ef28..b6d2a10b01d 100644 --- a/src/media/web-media.ts +++ b/src/media/web-media.ts @@ -9,6 +9,7 @@ import { fetchRemoteMedia } from "./fetch.js"; import { convertHeicToJpeg, hasAlphaChannel, + MAX_IMAGE_INPUT_PIXELS, optimizeImageToPng, resizeToJpeg, } from "./image-ops.js"; @@ -78,6 +79,13 @@ function formatCapReduce(label: string, cap: number, size: number): string { return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`; } +function isPixelLimitError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes(`${MAX_IMAGE_INPUT_PIXELS.toLocaleString("en-US")} pixel input limit`) + ); +} + function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean { if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) { return true; @@ -404,7 +412,10 @@ export async function optimizeImageToJpeg( quality, }; } - } catch { + } catch (error) { + if (isPixelLimitError(error)) { + throw error; + } // Continue trying other size/quality combinations } }