mirror of https://github.com/openclaw/openclaw.git
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:
parent
aaf6077f27
commit
0ed4f8a72b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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])]);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue