From aa6eb261f34e5bed0a180ff2dc6d70df272b2193 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:14:27 -0500 Subject: [PATCH] fix(create): decode an exported image or mask no further than its samples need Image export decoded every lossless image and mask with the generic 1 GiB allowance, so a few KB of Flate on an image of one pixel could inflate to a gigabyte before unpack read the first byte. Each stage is now bounded by what the image's size, components and depth need, as Optimize and redaction already do. --- crates/create/src/extract.rs | 26 ++++++++++++++++++++++---- crates/create/src/tests.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index d5103071e6..375d14fab1 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -136,8 +136,26 @@ fn filters(s: &Stream) -> Vec> { /// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`. /// A JBIG2 page has to be the image's size, which the caller checked against the export's limit, /// before the page is decoded. -fn decoded(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result, String> { - budget.decode(doc, s, MAX_DECODED).map_err(|e| e.to_string()) +/// +/// No stage may produce more than [`allowance`] gives for an image of `w` × `h` with `n` +/// components of `bpc` bits: what is past the samples is never read, so a few KB of Flate can't +/// ask for a gigabyte for an image of one pixel. +fn decoded(doc: &Document, s: &Stream, (w, h, n, bpc): (usize, usize, usize, usize), budget: &mut ImageBudget) -> Result, String> { + let max = allowance(s, w, h, n, bpc).ok_or("the image is too large to export")?; + budget.decode(doc, s, max).map_err(|e| e.to_string()) +} + +/// How far image `s` of `w` × `h` with `n` components of `bpc` bits is decoded, as Optimize and +/// redaction allow: up to three bytes a sample byte and a PNG predictor's byte a row (ASCIIHex text +/// under Flate, its digits spaced), or, for a fax image, the code its decoder reads, at most about a +/// byte a pixel and each row's end. More can only be a decompression bomb. Never less than the +/// stream's own bytes, which are in memory already, nor more than [`MAX_DECODED`]. +fn allowance(s: &Stream, w: usize, h: usize, n: usize, bpc: usize) -> Option { + let max = match s.filters().last() { + Some((pdfcraft_filters::Filter::CcittFax, _)) => w.checked_mul(h)?.checked_add(h.checked_mul(4)?)?, + _ => w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8).checked_mul(h)?.checked_add(h)?.checked_mul(3)?, + }; + Some(max.checked_add(4096)?.max(s.raw.len()).min(MAX_DECODED)) } /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft @@ -199,7 +217,6 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat space = decoded; (samples, 8) } else { - let data = decoded(doc, s, budget)?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, @@ -209,6 +226,7 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat 16 => 16, other => return Err(format!("{other} bits per component isn't supported")), }; + let data = decoded(doc, s, (w, h, space.components(), bpc), budget)?; (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc) }; let n = space.components(); @@ -567,7 +585,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut Image _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&decoded(doc, m, budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + unpack(&decoded(doc, m, (mw, mh, 1, bpc), budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index a7a5611ffc..dd292c1a40 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -844,6 +844,32 @@ fn damaged_masks_and_sizes_never_panic() { } } +#[test] +fn image_data_far_past_its_samples_is_not_decoded() { + // A grey image of one pixel whose Flate stream inflates to 64 MiB: only the first byte is a + // sample, so the export stops decoding long before the rest and leaves the image out. + let bomb = |w: i64, h: i64, len: usize| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| Stream::flate(image_dict(w, h, "DeviceGray", 8), &vec![7u8; len])); + doc + }; + let out = extract_images(&bomb(1, 1, 64 << 20), &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "{out:?}"); + // Data a little past the samples, as encoders leave, still exports. + let (ext, rgba) = exported_rgba(&bomb(4, 2, 4 * 2 + 64)); + assert_eq!((ext, rgba), ("png", [7, 7, 7, 255].repeat(8))); + + // The same for a soft mask: it is not decoded, and the image exports without it. + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let m = doc.add(Object::Stream(Stream::flate(image_dict(1, 1, "DeviceGray", 8), &vec![0u8; 64 << 20]))); + let mut d = image_dict(4, 2, "DeviceRGB", 8); + d.set(b"SMask".to_vec(), Object::Ref(m)); + Stream::from_raw(d, vec![10; 24]) + }); + assert_eq!(alphas(&exported_rgba(&doc).1), [255; 8]); +} + #[test] fn indexed_image_export_clamps_samples_to_hival() { for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] {