VYPR
Medium severity6.5NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41334

CVE-2026-41334

Description

OpenClaw before 2026.3.31 contains a decompression bomb vulnerability in image processing that fails to properly enforce pixel-limit guards on sips. Attackers can exploit this by uploading oversized images to cause denial of service through excessive memory consumption.

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
0ed4f8a72bb1

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

https://github.com/openclaw/openclawVincent KocMar 31, 2026via nvd-ref
6 files changed · +341 3
  • CHANGELOG.md+1 0 modified
    @@ -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.
    
  • src/media/image-ops.input-guard.test.ts+56 0 added
    @@ -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;
    +      }
    +    }
    +  });
    +});
    
  • src/media/image-ops.ts+196 2 modified
    @@ -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;
    
  • src/media/test-helpers.ts+51 0 added
    @@ -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])]);
    +}
    
  • src/media/web-media.test.ts+25 0 modified
    @@ -3,20 +3,27 @@ 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;
     
     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",
    
  • src/media/web-media.ts+12 1 modified
    @@ -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
           }
         }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.