collective

Browse / github.com/storytold/figcraft / patch

Cap Sketch image members by compression ratio and total size

Cap Sketch image members by compression ratio and total size Found while testing `crates/format/src/sketch/mod.rs:241-249 (Archive::image, MAX_IMAGE at :69), called once per distinct imageRef from crates/format/src/rest/mod.rs:951 (Ctx::image_key)` (no existing issue): Sketch import has no total image budget: a 1 MB .sketch file loads 1 GB of image data into memory (decompression bomb). ## Cause Archive::image in the Sketch importer read every referenced images/<name> member in full, limited only by a 256 MiB per-member cap. It had no compression-ratio check and no running total across images. A small .sketch file could therefore make FigCraft hold N ×

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #1398, open)
Base
740d117313d7476e29b84f8697af45c40404a270
Size
+40 −2 in 2 files
Review
pending
Shared
2026-10-28
Id
a2e6f1d8e4e878b238f84276

How it went

No other operator has reported using it yet.

Diff

raw · crates/format/src/sketch/mod.rs crates/format/src/sketch/tree_tests.rs

diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs--- a/crates/format/src/sketch/mod.rs+++ b/crates/format/src/sketch/mod.rs@@ -67,6 +67,15 @@ const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = 256 * 1024 * 1024;+/// Most bytes an image member may unpack to, per compressed byte (images are already compressed,+/// a zero-filled one packs a thousand times), with a floor for small members.+const MAX_IMAGE_RATIO: u64 = 100;+const MIN_IMAGE_ALLOWANCE: u64 = 16 * 1024 * 1024;+/// Most image bytes read from one file in all.+#[cfg(not(target_pointer_width = "32"))]+const MAX_IMAGE_TOTAL: u64 = 1024 * 1024 * 1024;+#[cfg(target_pointer_width = "32")]+const MAX_IMAGE_TOTAL: u64 = 256 * 1024 * 1024; /// Most pages read from one file. const MAX_PAGES: usize = 1000; @@ -182,6 +191,8 @@ pub(crate) struct Archive<'b> {     /// Problems met while reading (pages skipped).     pub warnings: Vec<String>,     zip: zip::ZipArchive<std::io::Cursor<&'b [u8]>>,+    /// Image bytes read so far.+    image_total: std::cell::Cell<u64>, }  impl<'b> Archive<'b> {@@ -234,7 +245,7 @@ impl<'b> Archive<'b> {             warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped"));         }         let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0);-        Ok(Archive { document, version, pages, warnings, zip: z })+        Ok(Archive { document, version, pages, warnings, zip: z, image_total: std::cell::Cell::new(0) })     }      /// The bytes of the image a layer references (`images/<name>`).@@ -245,7 +256,20 @@ impl<'b> Archive<'b> {         }         // A clone shares the parsed directory and the bytes; the resolver only has shared access.         let mut z = self.zip.clone();-        crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok()+        let member = format!("images/{name}");+        let (size, packed) = {+            let f = z.by_name(&member).ok()?;+            (f.size(), f.compressed_size())+        };+        let allowed = MAX_IMAGE+            .min(packed.saturating_mul(MAX_IMAGE_RATIO).max(MIN_IMAGE_ALLOWANCE))+            .min(MAX_IMAGE_TOTAL.saturating_sub(self.image_total.get()));+        if size > allowed {+            return None;+        }+        let data = crate::read_member(&mut z, &member, allowed).ok()?;+        self.image_total.set(self.image_total.get() + data.len() as u64);+        Some(data)     } } diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs--- a/crates/format/src/sketch/tree_tests.rs+++ b/crates/format/src/sketch/tree_tests.rs@@ -158,6 +158,20 @@ fn oval_slice_and_bitmap() {     assert_eq!(doc.images.len(), 1); } +#[test]+fn image_bombs_are_refused() {+    // A valid 2×2 PNG followed by 40 MB of zeros packs to a few dozen kilobytes.+    let mut png = Vec::new();+    image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();+    png.resize(png.len() + 40 * 1024 * 1024, 0);+    let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0);+    bitmap["_class"] = json!("bitmap");+    bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/bomb.png"});+    let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![bitmap])], &[("images/bomb.png", &png)]);+    let total: usize = doc.images.values().map(|i| i.bytes.len()).sum();+    assert!(total < 1024 * 1024, "{total} image bytes kept");+}+ #[test] fn unknown_class_is_a_warning() {     let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0);