From 6b0d1ed1c026782a927d8d43b1e061c1135e1e26 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 17:43:02 -0700 Subject: [PATCH 1/2] Crop Image: trim checks the image's RGBA size before decoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crop Image's trim (object.cropImage {trim: true}) decoded the image within MAX_RASTER_ALLOC (2 GB) and then converted it to RGBA. The decoder holds the decoded image, before conversion, to that allowance, so a gray or 1-bit image within it could take up to four times as much once converted. Trim now reads the image's size from its header first and, when width × height × 4 is over the allowance, fails before anything is decoded, with the check and message Load Texture uses: "an image of W × H pixels is too large to draw". Images within the allowance are trimmed as before. --- crates/engine/src/cmd/menucmds.rs | 15 ++++++++++++--- crates/engine/src/tests_linkspanel.rs | 2 +- crates/engine/src/tests_menucmds.rs | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 465e38851..23e49a039 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -664,9 +664,18 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { return Ok(json!({ "id": id.0, "width": im.width, "height": im.height, "trimmed": false })); } // Print rasters run to gigabytes decoded: the same allowance opening them has. - let mut reader = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) - .with_guessed_format() - .map_err(|_| bad(C, "the image's pixels can't be read"))?; + let reader = || { + image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .map_err(|_| bad(C, "the image's pixels can't be read")) + }; + // The image's size as RGBA, read from its header, is held to that allowance before anything + // is decoded (#1717). + let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; + if u64::from(w) * u64::from(h) * 4 > crate::cmd::fileio::MAX_RASTER_ALLOC { + return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); + } + let mut reader = reader()?; let mut limits = image::Limits::default(); limits.max_alloc = Some(crate::cmd::fileio::MAX_RASTER_ALLOC); reader.limits(limits); diff --git a/crates/engine/src/tests_linkspanel.rs b/crates/engine/src/tests_linkspanel.rs index fc0de2a8b..2899ce021 100644 --- a/crates/engine/src/tests_linkspanel.rs +++ b/crates/engine/src/tests_linkspanel.rs @@ -19,7 +19,7 @@ fn place_embedded(s: &mut Session, path: &str) -> NodeId { /// A PNG that says it is `w` × `h` 1-bit grey pixels (its header and no rows, which is all that /// placing and listing read). -fn png_header(w: u32, h: u32) -> Vec { +pub(crate) fn png_header(w: u32, h: u32) -> Vec { let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); let ihdr: Vec = [&b"IHDR"[..], &w.to_be_bytes(), &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat(); for chunk in [&ihdr[..], b"IDAT\x78\x9c", b"IEND"] { diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 410e3aac0..22642ec0c 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -2423,6 +2423,24 @@ fn crop_image_trim_cuts_to_the_opaque_pixels_as_they_are() { assert_eq!(bounds(&s, img), b); } +#[test] +fn crop_image_trim_checks_the_rgba_size_before_decoding() { + // 1-bit gray PNGs, header only: their samples are within the decoder's limit, and as RGBA + // (4 bytes a pixel) they are just over or just within the 2 GB allowance. + let trim = |w: u32, h: u32| { + let png = super::tests_linkspanel::png_header(w, h); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "wall.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.cropImage", &json!({"trim": true})).unwrap_err().to_string() + }; + let over = trim(23_171, 23_170); + assert!(over.contains("an image of 23171 × 23170 pixels is too large to draw"), "{over}"); + // One column fewer is within the allowance: it is decoded, and its missing rows can't be read. + let within = trim(23_170, 23_170); + assert!(within.contains("the image's pixels can't be read"), "{within}"); +} + #[test] fn crop_image_trim_leaves_a_cmyk_image_alone() { // CMYK images keep their ink amounts and have no alpha: nothing to cut, and not an error. From 0af1741e24bac24f0bf2774f8bca13b3c04670b2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 12:48:36 +0700 Subject: [PATCH 2/2] Crop Image: the RGBA size check can't overflow on a huge header --- crates/engine/src/cmd/menucmds.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 23e49a039..464def21b 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -672,7 +672,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { // The image's size as RGBA, read from its header, is held to that allowance before anything // is decoded (#1717). let (w, h) = reader()?.into_dimensions().map_err(|e| bad(C, format!("the image's pixels can't be read: {e}")))?; - if u64::from(w) * u64::from(h) * 4 > crate::cmd::fileio::MAX_RASTER_ALLOC { + if u64::from(w) * u64::from(h) > crate::cmd::fileio::MAX_RASTER_ALLOC / 4 { return Err(bad(C, format!("an image of {w} × {h} pixels is too large to draw"))); } let mut reader = reader()?;