collective

Browse / github.com/storytold/gridcraft / patch

Security: print decodes pictures within explicit decoder limits (part of #2179)

Security: print decodes pictures within explicit decoder limits (part of #2179) Part of #2179, covering the print/PDF path. The chart-view path in `chartview.rs` is not in this PR. **Problem.** `decode_image` in `crates/engine/src/cmd/print/objects.rs`: 1. capped pixels (60M) using `io::image_size`, a separate hand-written header sniffer; 2. then decoded with `image::load_from_memory`, which has default limits and no side limit; 3. then made a second full copy with `to_rgba8`. The sniffer isn't the decoder's own reader. For JPEG it reads a 0xFF fill byte as a segment marker with a length, so a crafted file can show it one size and the decoder another. Reproduced on a r

Status
open
Score
0.00
Adopters
0 other operators
Author
Higgs32584 (imported from pull request #2721, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+100 −17 in 2 files
Review
pending
Shared
2026-10-28
Id
f16a408d1247ab7c0c4903fe

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/print/objects.rs crates/engine/src/cmd/print/tests.rs

diff --git a/crates/engine/src/cmd/print/objects.rs b/crates/engine/src/cmd/print/objects.rs--- a/crates/engine/src/cmd/print/objects.rs+++ b/crates/engine/src/cmd/print/objects.rs@@ -10,17 +10,8 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32     if w <= 0.0 || h <= 0.0 || data.len() > 16 * 1024 * 1024 {         return;     }-    let Some((iw, ih)) = crate::io::image_size(data) else { return };-    if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 {-        return;-    }-    let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return };-    let mut limits = image::Limits::default();-    limits.max_image_width = Some(8192);-    limits.max_image_height = Some(8192);-    limits.max_alloc = Some(64 * 1024 * 1024);-    reader.limits(limits);-    let Ok(image) = reader.decode() else { return };+    let Some(image) = decode_limited(data, CELL_PICTURE_CAPS) else { return };+    let (iw, ih) = (image.width(), image.height());     let image =         image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();     let fit = (w / iw as f32).min(h / ih as f32);@@ -33,15 +24,44 @@ fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) {     (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) } -/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels.-fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u32)> {-    let (w, h) = crate::io::image_size(data)?;-    if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 {+/// How large a picture the print path decodes: longest side, pixels, and bytes the decoder+/// may allocate (which counts the bit depth and channels the header declares).+#[derive(Clone, Copy)]+pub(super) struct DecodeCaps {+    pub(super) max_side: u32,+    pub(super) max_pixels: u64,+    pub(super) max_alloc: u64,+}++/// In-cell pictures: the same limits `insert.picture` accepts.+const CELL_PICTURE_CAPS: DecodeCaps = DecodeCaps { max_side: 8192, max_pixels: 16_000_000, max_alloc: 64 * 1024 * 1024 };+/// Floating pictures come from any opened file. 256 MiB holds a 60-megapixel 8-bit RGBA+/// picture, but not a header declaring as many 16-bit pixels (twice that, before conversion).+pub(super) const FLOATING_PICTURE_CAPS: DecodeCaps = DecodeCaps { max_side: 16_384, max_pixels: 60_000_000, max_alloc: 256 * 1024 * 1024 };++/// Decodes a picture within `caps`. The size comes from the decoder's own header reader (not a+/// separate sniffer that a crafted file can disagree with), and the decoder is given the same+/// limits, so a picture over them is refused before its pixels are allocated.+pub(super) fn decode_limited(data: &[u8], caps: DecodeCaps) -> Option<image::DynamicImage> {+    let format = image::guess_format(data).ok()?;+    let (w, h) = image::ImageReader::with_format(std::io::Cursor::new(data), format).into_dimensions().ok()?;+    if w == 0 || h == 0 || w > caps.max_side || h > caps.max_side || u64::from(w) * u64::from(h) > caps.max_pixels {         return None;     }-    let img = image::load_from_memory(data).ok()?;+    let mut reader = image::ImageReader::with_format(std::io::Cursor::new(data), format);+    let mut limits = image::Limits::default();+    limits.max_image_width = Some(caps.max_side);+    limits.max_image_height = Some(caps.max_side);+    limits.max_alloc = Some(caps.max_alloc);+    reader.limits(limits);+    reader.decode().ok()+}++/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels.+pub(super) fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u32)> {+    let img = decode_limited(data, FLOATING_PICTURE_CAPS)?;     let img = if img.width() > max_w.max(1) || img.height() > max_h.max(1) { img.thumbnail(max_w.max(1), max_h.max(1)) } else { img };-    let rgba = img.to_rgba8();+    let rgba = img.into_rgba8();     let (pw, ph) = (rgba.width(), rgba.height());     Some((rgba.into_raw(), pw, ph)) }diff --git a/crates/engine/src/cmd/print/tests.rs b/crates/engine/src/cmd/print/tests.rs--- a/crates/engine/src/cmd/print/tests.rs+++ b/crates/engine/src/cmd/print/tests.rs@@ -565,3 +565,66 @@ fn rotated_wrapped_text_prints_rotated() {     assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");     assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}"); }++fn png_of(img: image::DynamicImage) -> Vec<u8> {+    let mut png = Vec::new();+    img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();+    png+}++/// Rewrites a PNG's IHDR size (and its CRC) so the header declares `w`×`h` pixels.+fn forge_png_size(png: &mut [u8], w: u32, h: u32) {+    let ihdr = png.windows(4).position(|c| c == b"IHDR").unwrap();+    png[ihdr + 4..ihdr + 8].copy_from_slice(&w.to_be_bytes());+    png[ihdr + 8..ihdr + 12].copy_from_slice(&h.to_be_bytes());+    let mut crc = !0u32;+    for &b in &png[ihdr..ihdr + 17] {+        crc ^= u32::from(b);+        for _ in 0..8 {+            crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };+        }+    }+    png[ihdr + 17..ihdr + 21].copy_from_slice(&(!crc).to_be_bytes());+}++#[test]+fn print_decodes_pictures_within_byte_limits() {+    use super::objects::{DecodeCaps, FLOATING_PICTURE_CAPS, decode_image, decode_limited};+    // The byte limit counts the bit depth and channels the header declares, not just pixels:+    // 64×64 is 16 KiB as 8-bit RGBA but 32 KiB as 16-bit RGBA.+    let rgba8 = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::new(64, 64)));+    let rgba16 = png_of(image::DynamicImage::ImageRgba16(image::ImageBuffer::new(64, 64)));+    let caps = DecodeCaps { max_side: 1024, max_pixels: 1_000_000, max_alloc: 24 * 1024 };+    assert!(decode_limited(&rgba8, caps).is_some());+    assert!(decode_limited(&rgba16, caps).is_none());+    assert!(decode_limited(&rgba8, DecodeCaps { max_side: 32, ..caps }).is_none());+    assert!(decode_limited(&rgba8, DecodeCaps { max_pixels: 64 * 64 - 1, ..caps }).is_none());+    assert!(decode_limited(b"not a picture", caps).is_none());++    // A header declaring 7000×7000 16-bit RGBA pixels (49 megapixels, under the pixel cap, but+    // about 392 MB) is refused from its header, before anything that size is allocated.+    let mut bomb = rgba16.clone();+    forge_png_size(&mut bomb, 7000, 7000);+    assert_eq!(image::ImageReader::with_format(std::io::Cursor::new(&bomb), image::ImageFormat::Png).into_dimensions().unwrap(), (7000, 7000));+    assert!(decode_limited(&bomb, FLOATING_PICTURE_CAPS).is_none());+    assert!(decode_image(&bomb, 100, 100).is_none());+    assert!(decode_image(&rgba16, 100, 100).is_some_and(|(px, w, h)| (w, h) == (64, 64) && px.len() == 64 * 64 * 4));++    // Printing a sheet holding such a picture leaves it out instead of failing.+    let mut s = s();+    let d = s.doc_mut().unwrap();+    let wb = std::sync::Arc::make_mut(&mut d.wb);+    let id = wb.next_object_id();+    wb.sheet_mut(0).unwrap().images.push(Image {+        id,+        anchor: Anchor { cell: CellRef::new(1, 1), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },+        data: bomb.into(),+        mime: "image/png".into(),+        alt: String::new(),+    });+    s.execute("cell.set", json!({"cell": "A1", "input": "Report"})).unwrap();+    let (pdf, pages) = pdf_of(&mut s, json!({}));+    assert_eq!(pages, 1);+    assert!(contains(&pdf, "(Report) Tj"));+    assert!(!contains(&pdf, "/Subtype /Image"));+}