From 843fa6ee7f31b5f80d09beadee511ef96c62bcc7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 21:30:49 -0500 Subject: [PATCH] fix(create): image export checks a JPEG's Extended XMP chunks before decoding it Image import already refused to hand the decoder a JPEG with more than 1024 Extended XMP chunks, which the pinned zune-jpeg re-sorts and rescans after every marker. Export decoded JPEG images and masks through zune-jpeg and image without that check. The check is now shared (upright_image::decodable_metadata) and runs before every export decoder entry point: jpeg_size, which jpeg_of_size goes through for RGB, grey and mask JPEGs, and cmyk_jpeg_samples. --- crates/create/src/extract.rs | 10 ++++++++- crates/create/src/tests.rs | 31 ++++++++++++++++++++++++++ crates/create/src/upright_image.rs | 35 +++++++++++++++++++----------- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index d5103071e6..11f0057821 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -297,9 +297,14 @@ thread_local! { } /// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary -/// says: an image of one pixel can hold a header of 500 megapixels. +/// says: an image of one pixel can hold a header of 500 megapixels. `None`, as for a JPEG that +/// can't be read, when its metadata would take the decoder quadratic time +/// ([`decodable_metadata`](crate::upright_image::decodable_metadata)). fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> { use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions}; + if !crate::upright_image::decodable_metadata(data) { + return None; + } let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; @@ -335,6 +340,9 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec Option> { use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + if !crate::upright_image::decodable_metadata(data) { + return None; + } let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); dec.decode_headers().ok()?; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index a7a5611ffc..59a60a29f0 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -844,6 +844,37 @@ fn damaged_masks_and_sizes_never_panic() { } } +#[test] +fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_exported_without_decoding() { + // Export decodes a JPEG with a soft mask, or a JPEG soft mask, to make a PNG with alpha: a JPEG + // whose Extended XMP chunks would take the decoder quadratic time is written as it is instead, + // and such a mask is left out, without reaching the decoder. + let flood = crate::upright_image::tests::with_extended_xmp_chunks(&real_jpeg(), 5_000); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mask = doc.add(Object::Stream(Stream::from_raw(image_dict(4, 2, "DeviceGray", 8), vec![0; 8]))); + let mut d = image_dict(4, 2, "DeviceGray", 8); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::from_raw(d, flood.clone()) + }); + crate::extract::JPEGS_DECODED.with(|n| n.set(0)); + let out = extract_images(&doc, &[0], 0); + assert_eq!((out.images[0].extension, &out.images[0].data), ("jpg", &flood)); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut md = image_dict(4, 2, "DeviceGray", 8); + md.set(b"Filter".to_vec(), Object::name("DCTDecode")); + let mask = doc.add(Object::Stream(Stream::from_raw(md, flood.clone()))); + let mut d = image_dict(4, 2, "DeviceRGB", 8); + d.set(b"SMask".to_vec(), Object::Ref(mask)); + Stream::from_raw(d, vec![10; 24]) + }); + assert_eq!(alphas(&exported_rgba(&doc).1), [255; 8]); + assert_eq!(crate::extract::JPEGS_DECODED.with(std::cell::Cell::get), 0); +} + #[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]] { diff --git a/crates/create/src/upright_image.rs b/crates/create/src/upright_image.rs index b89c517190..ab012e3faa 100644 --- a/crates/create/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -30,7 +30,7 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { } fn turned(bytes: &[u8]) -> Option> { - if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS { + if !bytes.starts_with(&[0xFF, 0xD8]) || !decodable_metadata(bytes) { return None; } #[cfg(test)] @@ -65,6 +65,13 @@ fn turned(bytes: &[u8]) -> Option> { /// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number. const MAX_EXTENDED_XMP_CHUNKS: usize = 1024; +/// Whether JPEG `bytes` may be handed to a decoder (`image`'s or `zune-jpeg`'s, which `image` uses), +/// headers included: not when it carries more than [`MAX_EXTENDED_XMP_CHUNKS`]. Every path that +/// decodes a JPEG from a file checks this first: image import here, and image export. +pub(crate) fn decodable_metadata(bytes: &[u8]) -> bool { + extended_xmp_chunks(bytes) <= MAX_EXTENDED_XMP_CHUNKS +} + /// How many Extended XMP chunks (APP1 segments) come before the image data. fn extended_xmp_chunks(bytes: &[u8]) -> usize { const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; @@ -120,7 +127,7 @@ fn jfif_density(bytes: &[u8]) -> Option { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::tests::phone_jpeg; @@ -138,22 +145,24 @@ mod tests { assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } + /// `jpeg` with `n` empty chunks of one never-complete Extended XMP packet after its SOI. + pub(crate) fn with_extended_xmp_chunks(jpeg: &[u8], n: usize) -> Vec { + let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); + seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID + seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length + seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset + let mut chunk = vec![0xFF, 0xE1]; + chunk.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); + chunk.extend_from_slice(&seg); + [&jpeg[..2], b"\0".as_slice(), &chunk.repeat(n), &jpeg[2..]].concat() + } + #[test] fn a_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() { // Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every // chunk so far after each marker, quadratic in their number. - let chunk = |n: usize| { - let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec(); - seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID - seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length - seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset - let mut out = vec![0xFF, 0xE1]; - out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes()); - out.extend_from_slice(&seg); - out.repeat(n) - }; let photo = phone_jpeg(6, 144); - let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat(); + let with = |n: usize| with_extended_xmp_chunks(&photo, n); DECODES.with(|c| c.set(0)); assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo"); assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_)));