From 21f1012ce2ef3d90c2f6e867dd84c0edc13d6401 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 13:11:41 +0200 Subject: [PATCH] Codecs: refuse implausible PNG/WebP headers before allocating (#3691) (#3706) A tiny file declaring a huge image reserved the full output buffer before any data was checked. Add Limits::check_plausible (compression-ratio guard) and a fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default max_alloc to 1 GiB. Co-authored-by: Claude Opus 5.5 --- crates/codecs/src/codecs/png.rs | 8 ++++++-- crates/codecs/src/codecs/webp.rs | 7 +++++-- crates/codecs/src/options.rs | 29 +++++++++++++++++++++++++++-- crates/codecs/tests/limits.rs | 24 ++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 636238a7b..2544a1066 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -7,7 +7,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits, PngCompression}; +use crate::options::{EncodeOptions, Limits, PngCompression, alloc_zeroed}; const F: Format = Format::Png; pub(crate) const XMP_KEYWORD: &str = "XML:com.adobe.xmp"; @@ -29,6 +29,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result let (w, h) = info.size(); // Worst case output: 4 channels x 16 bit. limits.check_bytes(w, h, 8)?; + // zlib never beats 1032:1; refuse a file too small for the declared image before the + // output buffer exists. Measured on the stored samples (before palette/bit expansion). + let stored = u64::from(w).saturating_mul(u64::from(h)).saturating_mul(info.bits_per_pixel() as u64).div_ceil(8); + Limits::check_plausible(stored, bytes.len(), 1100)?; } let mut reader = decoder.read_info().map_err(map_png_err)?; // APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it. @@ -47,7 +51,7 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result d => return Err(err(format!("unexpected output bit depth {d:?}"))), }; let size = reader.output_buffer_size().ok_or_else(|| err("image too large"))?; - let mut buf = vec![0u8; size]; + let mut buf = alloc_zeroed::(size)?; let out = reader.next_frame(&mut buf).map_err(map_png_err)?; buf.truncate(out.buffer_size()); // Collect ancillary chunks after IDAT. Tolerate malformed tails once pixels diff --git a/crates/codecs/src/codecs/webp.rs b/crates/codecs/src/codecs/webp.rs index c26c10c8c..36473041d 100644 --- a/crates/codecs/src/codecs/webp.rs +++ b/crates/codecs/src/codecs/webp.rs @@ -9,7 +9,7 @@ use crate::Format; use crate::error::CodecError; use crate::fidelity::Plan; use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType}; -use crate::options::{EncodeOptions, Limits}; +use crate::options::{EncodeOptions, Limits, alloc_zeroed}; const F: Format = Format::WebP; @@ -23,8 +23,11 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result let (w, h) = dec.dimensions(); let layout = if dec.has_alpha() { ChannelLayout::Rgba } else { ChannelLayout::Rgb }; limits.check(w, h, layout, SampleType::U8)?; + // VP8L back-references and VP8 skipped macroblocks compress flat areas extremely well, so the + // ratio is generous; it still stops a header-only file from asking for a gigabyte. + Limits::check_plausible(u64::from(w) * u64::from(h) * layout.channels() as u64, bytes.len(), 1 << 16)?; let size = dec.output_buffer_size().ok_or_else(|| err("image too large"))?; - let mut buf = vec![0u8; size]; + let mut buf = alloc_zeroed::(size)?; dec.read_image(&mut buf).map_err(err)?; let icc = dec.icc_profile().ok().flatten(); let exif = dec.exif_metadata().ok().flatten(); diff --git a/crates/codecs/src/options.rs b/crates/codecs/src/options.rs index 79b288f34..9337f1e6f 100644 --- a/crates/codecs/src/options.rs +++ b/crates/codecs/src/options.rs @@ -21,8 +21,10 @@ pub struct Limits { impl Default for Limits { fn default() -> Self { // 2^30 pixels (e.g. 32768²) and, on 64-bit targets, 8 GiB: enough for the large - // documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). - let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 2 << 30 }; + // documents Photoshop users open (a 20000² RGBA 16-bit image is 3.2 GB). On 32-bit + // targets (wasm32 has a 4 GiB address space shared with the document, history and + // the decoder's own buffers) 1 GiB is the most one buffer can safely ask for. + let max_alloc = if cfg!(target_pointer_width = "64") { 8 << 30 } else { 1 << 30 }; Limits { max_width: 1 << 18, max_height: 1 << 18, max_pixels: 1 << 30, max_alloc } } } @@ -60,6 +62,19 @@ impl Limits { Ok(()) } + /// Decompression-bomb guard for formats that compress: refuse a file too small to hold + /// `declared_bytes` of (stored, pre-expansion) image data, given that the compression + /// never beats `max_ratio`:1. Called before the output buffer is allocated, so a + /// header-only hostile file cannot make the decoder reserve gigabytes. A fixed 1 MiB of + /// slack keeps tiny images and headers with large metadata unaffected. + pub(crate) fn check_plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> Result<(), CodecError> { + let capacity = (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20); + if declared_bytes > capacity { + return Err(CodecError::LimitExceeded(format!("a {file_len} byte file cannot hold {declared_bytes} bytes of image data"))); + } + Ok(()) + } + pub(crate) fn alloc_usize(&self) -> usize { usize::try_from(self.max_alloc).unwrap_or(usize::MAX) } @@ -149,3 +164,13 @@ impl Default for EncodeOptions { } } } + +/// A zero-filled buffer of `len` elements, allocated fallibly: an allocation failure becomes +/// [`CodecError::LimitExceeded`] instead of aborting the process (which `catch_unwind` cannot +/// stop). +pub(crate) fn alloc_zeroed(len: usize) -> Result, CodecError> { + let mut v = Vec::new(); + v.try_reserve_exact(len).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {len} elements for the image")))?; + v.resize(len, T::default()); + Ok(v) +} diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/limits.rs index 3613b0871..6969fea92 100644 --- a/crates/codecs/tests/limits.rs +++ b/crates/codecs/tests/limits.rs @@ -93,6 +93,30 @@ fn png_bomb_header_rejected_before_alloc() { assert!(is_limit(decode(&b))); } +#[test] +fn png_header_at_the_default_limit_needs_a_plausible_file_size() { + // 32768 x 32768 x RGBA16 is exactly the default max_alloc: it passes the dimension limits, + // but a ~100 byte file cannot hold it (zlib tops out near 1032:1), so it is refused before + // the 8 GiB output buffer is allocated. + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&32768u32.to_be_bytes()); + ihdr.extend_from_slice(&32768u32.to_be_bytes()); + ihdr.extend_from_slice(&[16, 6, 0, 0, 0]); + let mut b = b"\x89PNG\r\n\x1a\n".to_vec(); + b.extend(png_chunk(b"IHDR", &ihdr)); + b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01])); + b.extend(png_chunk(b"IEND", &[])); + assert!(is_limit(decode(&b))); +} + +#[test] +fn png_highly_compressible_image_still_decodes() { + let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap(); + let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap(); + assert!(b.len() < 100_000, "{}", b.len()); + assert_eq!(decode(&b).unwrap().width(), 3000); +} + const PNG_TEXT_BUDGET: usize = 64 << 10; fn png_text_decode(bytes: &[u8], max_alloc: usize) -> Result {