From 855d017d5b56b61bdf5a46c974a280f0eace7398 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:34:02 -0800 Subject: [PATCH] Images: limit reduced JPEG decodes by the full image size A reduced decode now applies the full decode's size limits before it reads any image data: - A file whose size pixel_size cannot read is refused, so the 256 Mpx check always has a size to compare. The JPEG decoder's own reading of the frame size is checked against the same limit. - A progressive JPEG keeps the coefficients of the full-size image until its last scan, whatever the output size. When those take more than 512 MiB (each component counted at full size, each side rounded up to 32 pixels), the reduced decode is refused and the full decode, with its own limits, applies. --- crates/images/src/jpeg.rs | 21 ++++++++++++-- crates/images/src/lib.rs | 12 ++++++-- crates/images/src/tests.rs | 58 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/images/src/jpeg.rs b/crates/images/src/jpeg.rs index fea0a1e64..6188a53fc 100644 --- a/crates/images/src/jpeg.rs +++ b/crates/images/src/jpeg.rs @@ -67,21 +67,38 @@ pub(crate) const MAX_SHRINK: u8 = 3; /// A YCbCr, RGB or grayscale JPEG decoded at 1/2^`shrink` of its size, each side rounded up, for /// `shrink` 1 to [`MAX_SHRINK`]: straight RGBA and whether the image has colour. `None` for CMYK, -/// 12-bit and lossless JPEGs, other formats and anything that doesn't decode. +/// 12-bit and lossless JPEGs, other formats and anything that doesn't decode. `None` as well for a +/// JPEG whose full size is over the full decode's limit, and for a progressive JPEG whose +/// full-size coefficients take more than [`MAX_BYTES`]; the full decode applies its own limits to +/// those. pub(crate) fn decode_reduced(bytes: &[u8], shrink: u8) -> Option<(image::RgbaImage, bool)> { - use jpeg_decoder::PixelFormat; + use jpeg_decoder::{CodingProcess, PixelFormat}; if !bytes.starts_with(&[0xFF, 0xD8]) || shrink == 0 || shrink > MAX_SHRINK { return None; } let mut d = jpeg_decoder::Decoder::new(bytes); + // This limits the output only, which is the reduced size. d.set_max_decoding_buffer_size(MAX_BYTES); d.read_info().ok()?; let info = d.info()?; + if crate::over_rgba_budget(u32::from(info.width), u32::from(info.height)) { + return None; + } let colour = match info.pixel_format { PixelFormat::RGB24 => true, PixelFormat::L8 => false, PixelFormat::L16 | PixelFormat::CMYK32 => return None, }; + if info.coding_process == CodingProcess::DctProgressive { + // A progressive JPEG holds every coefficient of the full-size image (two bytes each) until + // its last scan, whatever the output size. Each component is counted at the full size and + // each side is rounded up to the largest MCU (32 pixels): an upper bound. + let side = |n: u16| u64::from(n).next_multiple_of(32); + let components: u64 = if colour { 3 } else { 1 }; + if side(info.width) * side(info.height) * components * 2 > MAX_BYTES as u64 { + return None; + } + } let reduced = |n: u16| u16::try_from(u32::from(n).div_ceil(1 << shrink)).ok(); let (w, h) = d.scale(reduced(info.width)?, reduced(info.height)?).ok()?; let px = d.decode().ok()?; diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs index 530ca8097..5754e8788 100644 --- a/crates/images/src/lib.rs +++ b/crates/images/src/lib.rs @@ -407,7 +407,12 @@ const MAX_RGBA_BYTES: u64 = 1 << 30; /// Whether a raster's header gives it more pixels than [`decode_rgba`] decodes. pub fn too_large_to_decode(bytes: &[u8]) -> bool { // `image` limits only its own output, which can be a quarter of the RGBA copy or less. - pixel_size(bytes).is_some_and(|(w, h)| u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES) + pixel_size(bytes).is_some_and(|(w, h)| over_rgba_budget(w, h)) +} + +/// Whether a `w` × `h` raster takes more than [`MAX_RGBA_BYTES`] as RGBA. +fn over_rgba_budget(w: u32, h: u32) -> bool { + u64::from(w).saturating_mul(u64::from(h)).saturating_mul(4) > MAX_RGBA_BYTES } /// Straight (non-premultiplied) RGBA8 of a raster in display sRGB: an embedded ICC profile is @@ -448,9 +453,12 @@ pub const MAX_REDUCED_SHRINK: u8 = jpeg::MAX_SHRINK; /// [`decode_rgba`] of a JPEG at 1/2^`shrink` of its size (`shrink` 1 to [`MAX_REDUCED_SHRINK`], each /// side rounded up), read from its compressed blocks without a full-size decode. The embedded ICC /// profile is applied as [`decode_rgba`] applies it. `None` where only a full decode applies: other -/// formats, CMYK, 12-bit and lossless JPEGs, and files [`decode_rgba`] refuses as too large. +/// formats, CMYK, 12-bit and lossless JPEGs, files whose size [`pixel_size`] cannot read, and files +/// [`decode_rgba`] refuses as too large. pub fn decode_rgba_reduced(bytes: &[u8], shrink: u8) -> Option { use colour::{DisplayColour, Pixels, to_display_rgba}; + // The size check must have a size to check: a header only the JPEG decoder reads is refused. + pixel_size(bytes)?; if too_large_to_decode(bytes) { return None; } diff --git a/crates/images/src/tests.rs b/crates/images/src/tests.rs index ae5435a62..184e3c9c9 100644 --- a/crates/images/src/tests.rs +++ b/crates/images/src/tests.rs @@ -611,3 +611,61 @@ fn reduced_decoding_is_for_jpegs_and_survives_damaged_ones() { } } } + +#[test] +fn reduced_decoding_refuses_a_jpeg_whose_size_the_size_check_cannot_read() { + let jpeg = gradient_jpeg(64, 48, false); + assert_eq!(decode_rgba_reduced(&jpeg, 1).unwrap().dimensions(), (32, 24)); + // A stray byte after the start marker: the JPEG decoder skips it, but `pixel_size` doesn't + // recognize the file, so `too_large_to_decode` has no size to check. + let mut stray = jpeg.clone(); + stray.insert(2, 0); + assert_eq!(pixel_size(&stray), None); + assert!(decode_rgba_reduced(&stray, 1).is_none()); + assert!(decode_rgba(&stray).is_none()); +} + +/// A progressive JPEG with only its DC scan, `w` × `h` with `components` 1×1-sampled components: +/// every coefficient is zero (a flat mid-gray image). +fn progressive_jpeg(w: u16, h: u16, components: u8) -> Vec { + let mut out = vec![0xFF, 0xD8]; + // One quantization table of ones. + out.extend([0xFF, 0xDB, 0, 67, 0]); + out.extend([1; 64]); + // Start of frame, progressive DCT. + out.extend([0xFF, 0xC2, 0, 8 + 3 * components, 8]); + out.extend(h.to_be_bytes()); + out.extend(w.to_be_bytes()); + out.push(components); + for id in 1..=components { + out.extend([id, 0x11, 0]); + } + // A DC table with one code, "0", for a difference of 0. + out.extend([0xFF, 0xC4, 0, 20, 0x00, 1]); + out.extend([0; 15]); + out.push(0); + // The DC scan of every component (Ss, Se, Ah/Al all 0), then zero bits. + out.extend([0xFF, 0xDA, 0, 6 + 2 * components, components]); + for id in 1..=components { + out.extend([id, 0x00]); + } + out.extend([0, 0, 0]); + out.extend([0; 16]); + out.extend([0xFF, 0xD9]); + out +} + +#[test] +fn reduced_decoding_refuses_progressive_jpegs_whose_full_size_coefficients_are_over_the_limit() { + let small = decode_rgba_reduced(&progressive_jpeg(64, 48, 3), 3).unwrap(); + assert_eq!(small.dimensions(), (8, 6)); + // 10,240 × 8,800 is within the RGBA limit, but its three components' coefficients take + // 10,240 × 8,800 × 3 × 2 bytes, over 512 MiB, whatever the output size. + let large = progressive_jpeg(10_240, 8_800, 3); + assert_eq!(pixel_size(&large), Some((10_240, 8_800))); + assert!(!too_large_to_decode(&large)); + assert!(decode_rgba_reduced(&large, 3).is_none()); + // One component of the same size stays under the limit. + let gray = decode_rgba_reduced(&progressive_jpeg(10_240, 8_800, 1), 3).unwrap(); + assert_eq!(gray.dimensions(), (1_280, 1_100)); +}