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 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-12 07:11:41 -04:00
1 parent b564f2ca9c
commit 21f1012ce2
4 files changed
+62 -6

No files matched your search

+6 -2
View File
@@ -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<Image, CodecError>
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<Image, CodecError>
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::<u8>(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
+5 -2
View File
@@ -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<Image, CodecError>
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::<u8>(size)?;
dec.read_image(&mut buf).map_err(err)?;
let icc = dec.icc_profile().ok().flatten();
let exif = dec.exif_metadata().ok().flatten();
+27 -2
View File
@@ -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<T: Copy + Default>(len: usize) -> Result<Vec<T>, 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)
}
+24
View File
@@ -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<Image, CodecError> {