diff --git a/crates/media/src/gif_anim.rs b/crates/media/src/gif_anim.rs index 09c0cda34..d201c8b7e 100644 --- a/crates/media/src/gif_anim.rs +++ b/crates/media/src/gif_anim.rs @@ -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}"); + } } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index e35ff37fc..872e4fdfa 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -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())))), diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2a734b7d5..194eebc47 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -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) -> 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) -> 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"); + } } diff --git a/crates/media/src/webp_anim.rs b/crates/media/src/webp_anim.rs index 9420be1ed..f0784d1e3 100644 --- a/crates/media/src/webp_anim.rs +++ b/crates/media/src/webp_anim.rs @@ -157,6 +157,11 @@ pub(crate) fn load(bytes: &[u8]) -> Result { 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()); + } }