fix(media): reject oversized image inputs before decode (#58226)

* fix(media): cap oversized image inputs

* chore(changelog): add media input guard note

* fix(media): address input guard review feedback

* fix(media): fail closed on unknown sips dimensions

* fix(media): avoid sips fallback in input guard
This commit is contained in:
Vincent Koc 2026-03-31 22:52:55 +09:00 committed by GitHub
parent aaf6077f27
commit 0ed4f8a72b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 341 additions and 3 deletions

View File

@ -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.

View File

@ -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;
}
}
});
});

View File

@ -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<Sharp>> {
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<ImageMetadata | null> {
return readImageMetadataFromHeader(buffer);
}
async function assertImagePixelLimit(buffer: Buffer): Promise<void> {
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<Buffer> {
}
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata | null> {
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<ImageMetadata |
if (width <= 0 || height <= 0) {
return null;
}
return { width, height };
return validateImagePixelLimit({ width, height });
} catch {
return null;
}
@ -288,6 +472,8 @@ async function sipsApplyOrientation(buffer: Buffer, orientation: number): Promis
* Falls back to original buffer if normalization fails.
*/
export async function normalizeExifOrientation(buffer: Buffer): Promise<Buffer> {
await assertImagePixelLimit(buffer);
if (prefersSips()) {
try {
const orientation = readJpegExifOrientation(buffer);
@ -316,6 +502,8 @@ export async function resizeToJpeg(params: {
quality: number;
withoutEnlargement?: boolean;
}): Promise<Buffer> {
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<Buffer> {
await assertImagePixelLimit(buffer);
if (prefersSips()) {
return await sipsConvertToJpeg(buffer);
}
@ -368,6 +558,8 @@ export async function convertHeicToJpeg(buffer: Buffer): Promise<Buffer> {
* Returns true if the image has alpha, false otherwise.
*/
export async function hasAlphaChannel(buffer: Buffer): Promise<boolean> {
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<Buffer> {
await assertImagePixelLimit(params.buffer);
const sharp = await loadSharp();
// Compression level 6 is a good balance (0=fastest, 9=smallest)
const compressionLevel = params.compressionLevel ?? 6;

51
src/media/test-helpers.ts Normal file
View File

@ -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])]);
}

View File

@ -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",

View File

@ -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
}
}