Media: check still, EXR channel, GIF and WebP canvas sizes before allocating

* fix(media): check still, EXR channel, GIF and WebP canvas sizes before allocating

* address review: GIF frame allocation limit, lower wasm32 still limit, no EXR fallback after a size-limit error

* address review: the 2^25-pixel still limit also applies on 32-bit native targets

* Media: raise the still pixel limit to 2^28 (16384 x 16384) on 64-bit targets
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 06:29:46 -05:00
1 parent 4d6036265b
commit a287044644
4 files changed
+109 -1

No files matched your search

+37 -1
View File
@@ -93,7 +93,18 @@ impl Anim {
if crate::webp_anim::is_webp(bytes) {
return crate::webp_anim::load(bytes);
}
let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
let mut dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
// A frame may be far larger than the canvas, and the decoder allocates its buffer before
// decoding it: no single buffer may pass what all the frames may hold.
let mut limits = image::Limits::no_limits();
limits.max_alloc = Some(MAX_BYTES as u64);
image::ImageDecoder::set_limits(&mut dec, limits).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?;
// The decoder allocates the full logical canvas for the first frame, however small the
// frames themselves are: check it before decoding any.
let (w, h) = image::ImageDecoder::dimensions(&dec);
if u64::from(w) * u64::from(h) * 4 > MAX_BYTES as u64 {
return Err(MediaError::Unsupported(format!("GIF canvas of {w}×{h} is too large to hold in memory")));
}
let (mut frames, mut starts) = (Vec::new(), Vec::new());
let (mut at, mut size) = (0i64, 0usize);
for frame in dec.into_frames() {
@@ -179,6 +190,17 @@ mod tests {
assert_eq!(colour_at(&pool, &f, 5.0), [0.0, 0.0, 1.0]);
}
#[test]
fn a_frame_far_larger_than_the_canvas_is_refused_before_it_is_allocated() {
// A 1×1 canvas and a 20000×20000 frame (1.6 GB of RGBA); the data itself is not there.
let mut bytes = b"GIF89a".to_vec();
// (a two-entry global colour table, so the frame gets as far as its buffer)
bytes.extend([1, 0, 1, 0, 0x80, 0, 0, 0, 0, 0, 255, 255, 255]);
bytes.extend([0x2c, 0, 0, 0, 0, 0x20, 0x4e, 0x20, 0x4e, 0, 2, 0]);
let e = crate::gif_anim::Anim::load(&bytes).err().expect("refused").to_string();
assert!(e.to_lowercase().contains("memory"), "{e}");
}
#[test]
fn a_zero_delay_is_ten_hundredths_like_in_browsers() {
let f = probe_bytes("a.gif", gif(&[(RED, 0), (GREEN, 1)]).into()).unwrap();
@@ -212,4 +234,18 @@ mod tests {
assert!(pool.frame_at(&f, Tick::ZERO).is_err());
let _ = probe_bytes("a.gif", bytes.into());
}
#[test]
fn a_huge_canvas_is_refused_before_decoding() {
// A 65535×65535 logical screen (16 GiB of RGBA) holding one 1×1 frame.
let mut out = Vec::new();
{
let mut enc = gif::Encoder::new(&mut out, u16::MAX, u16::MAX, &[]).unwrap();
for _ in 0..2 {
enc.write_frame(&gif::Frame::from_rgb(1, 1, &RED)).unwrap();
}
}
let e = super::Anim::load(&out).err().unwrap();
assert!(e.to_string().contains("too large"), "{e}");
}
}
+2
View File
@@ -1011,6 +1011,8 @@ impl Inner {
crate::exr_channels::count_decode(false);
match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) {
Ok(img) => Ok(dynamic_to_image(&img, op)),
// Over the size limit: the layered fallback would allocate its planes anyway.
Err(e @ image::ImageError::Limits(_)) => Err(MediaError::Decode(format!("{path}: {e}"))),
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
Err(e) => crate::exr_channels::layered_picture(path, bytes, &file, op, shared)
.map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))),
+49
View File
@@ -9,6 +9,15 @@ use image::metadata::Orientation;
use crate::{MediaError, Result, ext_of, rate_from_fc};
/// Most pixels a still may decode to: 4 GiB as the renderer's float RGBA (2^28, 16384 × 16384;
/// 150-megapixel medium-format frames, stitched panoramas and large scans decode). A browser tab's
/// wasm32 heap can't hold one allocation that large (and the decoded input beside it): 512 MiB
/// there, and on 32-bit targets, where one allocation may not pass `isize::MAX` (2 GiB).
#[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))]
pub(crate) const MAX_STILL_PIXELS: u64 = 1 << 28;
#[cfg(any(target_arch = "wasm32", target_pointer_width = "32"))]
pub(crate) const MAX_STILL_PIXELS: u64 = 1 << 25;
/// Frame rate given to image sequences on import (After Effects' default preference).
pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30;
@@ -305,6 +314,12 @@ pub(crate) fn load_oriented(bytes: &[u8], fmt: Option<image::ImageFormat>) -> im
None => image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?,
};
let mut dec = reader.into_decoder()?;
// `from_decoder` skips the reader's allocation limit, and some decoders (BMP) allocate
// whatever size their header claims: refuse a still the renderer could not hold as floats.
let (w, h) = dec.dimensions();
if u64::from(w) * u64::from(h) > MAX_STILL_PIXELS {
return Err(image::ImageError::Limits(image::error::LimitError::from_kind(image::error::LimitErrorKind::DimensionError)));
}
let orientation = dec.orientation().unwrap_or(Orientation::NoTransforms);
let mut img = image::DynamicImage::from_decoder(dec)?;
img.apply_orientation(orientation);
@@ -378,6 +393,23 @@ fn averaged_rate(timescale: u32, durations: impl Iterator<Item = u32>) -> Option
mod tests {
use super::*;
#[test]
fn a_still_larger_than_the_renderer_holds_is_refused_before_decoding() {
// A 24-bit BMP header claiming 20000×20000 pixels (within the BMP decoder's own limit)
// over no pixel data.
let mut bmp = b"BM".to_vec();
bmp.extend(54u32.to_le_bytes());
bmp.extend([0u8; 4]);
bmp.extend(54u32.to_le_bytes());
bmp.extend(40u32.to_le_bytes());
bmp.extend(20_000i32.to_le_bytes());
bmp.extend(20_000i32.to_le_bytes());
bmp.extend(1u16.to_le_bytes());
bmp.extend(24u16.to_le_bytes());
bmp.extend([0u8; 24]);
assert!(matches!(load_oriented(&bmp, Some(image::ImageFormat::Bmp)), Err(image::ImageError::Limits(_))));
}
/// #2592: an encoder-delayed H.264 track's edit list shows 75 frames at 25 fps (3 s at a
/// 1000 timescale), though its media runs a frame longer; empty edits and no edits leave it.
#[test]
@@ -469,4 +501,21 @@ mod tests {
assert_eq!(averaged_rate(u32::MAX, [u32::MAX; 300].into_iter()), None);
assert_eq!(video_rate(b"not a movie"), None);
}
/// A 150-megapixel still (a medium-format frame) is not refused for its size: only its (absent)
/// pixel data fails, not the dimension check. A still past the limit is.
#[cfg(not(any(target_arch = "wasm32", target_pointer_width = "32")))]
#[test]
fn stills_up_to_the_limit_pass_the_size_check() {
let bmp = |w: u32, h: u32| {
let mut b = vec![b'B', b'M', 0, 0, 0, 0, 0, 0, 0, 0, 54, 0, 0, 0, 40, 0, 0, 0];
b.extend_from_slice(&w.to_le_bytes());
b.extend_from_slice(&h.to_le_bytes());
b.extend_from_slice(&[1, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
b
};
let limit = |e: image::ImageError| matches!(e, image::ImageError::Limits(_));
assert!(!load_oriented(&bmp(14_000, 10_800), None).err().is_some_and(limit), "150 MP passes the check");
assert!(load_oriented(&bmp(20_000, 20_000), None).err().is_some_and(limit), "400 MP is refused");
}
}
+21
View File
@@ -157,6 +157,11 @@ pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
return Err(MediaError::Unsupported(format!("animated WebP of {} frames of {}×{} is too large to hold in memory", h.delays.len(), h.width, h.height)));
}
let dec = WebPDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("WebP: {e}")))?;
// The budget above used the canvas this file's header scan found; the decoder must agree, or
// a second VP8X header could make it allocate a far larger canvas.
if image::ImageDecoder::dimensions(&dec) != (h.width, h.height) {
return Err(MediaError::Decode("WebP: conflicting canvas sizes".into()));
}
let (mut frames, mut delays, mut size) = (Vec::new(), Vec::new(), 0usize);
for frame in dec.into_frames() {
let frame = match frame {
@@ -326,4 +331,20 @@ mod tests {
let e = super::load(&bytes).err().unwrap();
assert!(e.to_string().contains("too large"), "{e}");
}
#[test]
fn conflicting_canvas_headers_are_refused() {
// The first VP8X claims 16384×16384; a second one, which the header scan keeps, 1×1.
let mut bytes = webp(1, 1, &[(RED, 50); 2]);
bytes[24..27].copy_from_slice(&u24(16383));
bytes[27..30].copy_from_slice(&u24(16383));
let mut small = vec![0x12, 0, 0, 0];
small.extend_from_slice(&u24(0));
small.extend_from_slice(&u24(0));
let second = chunk(b"VP8X", &small);
bytes.splice(30..30, second.iter().copied());
let riff = u32::try_from(bytes.len() - 8).unwrap();
bytes[4..8].copy_from_slice(&riff.to_le_bytes());
assert!(super::load(&bytes).is_err());
}
}