From 5b4f71f988e9cba3e494e2e747e166b3ece72ca5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:44:12 -0500 Subject: [PATCH] hayro-syntax: hold a JBIG2 page to the stream's decode limit The JBIG2 filter ignored the limit every other filter gets, so a stream decoded with decoded_within (an output intent's 16 MiB ICC profile in craft-prepress) could still build a page up to 2^28 one-byte pixels. The page header's size is now checked against the limit before any output is allocated: the returned bytes when the stream has no image dimensions, the 1-bit packed size for an image of known size. --- vendor/hayro-syntax/SOURCE.json | 6 +-- vendor/hayro-syntax/src/filter/jbig2.rs | 47 +++++++++++++++++++++++- vendor/hayro-syntax/src/filter/mod.rs | 2 +- vendor/hayro-syntax/src/object/stream.rs | 6 +-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index 7588abfc..772cc2d1 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -158,7 +158,7 @@ "path": "src/filter/jbig2.rs", "source_sha256": "0ac3c16e02cb13f2d5fe401edd35949320662906c3906cb39fe743acd5e32b09", "source_git_blob": "d87253f536e6b8ac18323f1f06164b516ad775df", - "sha256": "4ce7d4c4f33528f5922395c99ddf2da67d15f166bfa593c43df5e5ebf75b3669" + "sha256": "294de5b38201a095c0dcde97ddbba2563d33874deede190b31869d3bcb110fd1" }, { "path": "src/filter/jpx.rs", @@ -176,7 +176,7 @@ "path": "src/filter/mod.rs", "source_sha256": "0af7145ab16b15b7a60c4b57efecb9bd5fd0cfdc9bb5f9388abb695386e63e49", "source_git_blob": "06bda29487d62c25dc39c1ee109091cb17e3867f", - "sha256": "e43d7d309a4993f4a1f698208997122431b4a8b0db838bb07d4358ccbc00f02f" + "sha256": "727fd21fe968d0ce9de54ea0c50658af43658289f9b38b27fbc74596e48d0d4b" }, { "path": "src/filter/run_length.rs", @@ -278,7 +278,7 @@ "path": "src/object/stream.rs", "source_sha256": "f5229eb2f52c9918830027c93112e042a9e97d34b5fec72f97dcc7de8c5410ac", "source_git_blob": "a80f0730cba317ed3771cee61d6a7ab18de296d7", - "sha256": "05928b3008cd06098220ee7cdfd03ebc6351c205d947491be37c77cd091dc78e" + "sha256": "02151a961322606ed3de7fa5000faf7463fed72b5c59c596954ff00a2267799a" }, { "path": "src/object/string.rs", diff --git a/vendor/hayro-syntax/src/filter/jbig2.rs b/vendor/hayro-syntax/src/filter/jbig2.rs index a0ab784b..54751c2f 100644 --- a/vendor/hayro-syntax/src/filter/jbig2.rs +++ b/vendor/hayro-syntax/src/filter/jbig2.rs @@ -18,10 +18,14 @@ const MAX_PIXELS: u64 = 1 << 28; /// /// The `params` dictionary may contain a `JBIG2Globals` entry pointing to /// a stream with shared symbol dictionaries. +/// +/// PdfCraft patch: `limit` is the most bytes the page may decode to (see `filter::decode_limit`), +/// checked against the page header's dimensions before any output is allocated. pub(crate) fn decode( data: &[u8], params: &Dict<'_>, image_params: &ImageDecodeParams, + limit: usize, ) -> Option> { // PdfCraft patch: globals are plain segment data. A globals stream filtered as an image // could name further globals of its own, recursing once per stream in the chain. @@ -45,6 +49,24 @@ pub(crate) fn decode( return None; } + // PdfCraft patch: the page's bit-packed size (also what `hayro_jbig2` holds for the page + // bitmap) and the size of the output built below. An image of known size has a limit in its + // dictionary's 1-bit pixels, so its packed size is checked, and the 8-bit output it is widened + // to stays within `MAX_PIXELS`. A stream decoded without image dimensions, such as an output + // intent's ICC profile, gets back every byte built, so the output itself must fit. + let packed_len = (image.width() as usize).div_ceil(8).checked_mul(image.height() as usize)?; + let luma_len = (image.width() as usize).checked_mul(image.height() as usize)?; + let sized_image = image_params.bpc.is_some() + && image_params.num_components.is_some() + && image_params.width != 0 + && image_params.height != 0; + let output_len = if image_params.is_indexed { packed_len } else { luma_len }; + if (if sized_image { packed_len } else { output_len }) > limit { + warn!("embedded JBIG2 page decodes past the stream's limit"); + + return None; + } + // Whenever possible (if we don't have an indexed color space), we convert // the data as 8-bit instead of 1-bit, so that it can be easier converted // into an RGBA8 image. @@ -150,7 +172,30 @@ mod tests { let data = page_information(65_535, 65_535); for is_indexed in [false, true] { let params = ImageDecodeParams { is_indexed, bpc: Some(1), num_components: Some(1), target_dimension: None, width: 1, height: 1 }; - assert!(decode(&data, &Dict::empty(), ¶ms).is_none()); + assert!(decode(&data, &Dict::empty(), ¶ms, usize::MAX).is_none()); } } + + /// PdfCraft patch: a page within the pixel cap but past the caller's limit is refused before + /// its output is allocated. Without image dimensions (an ICC profile decoded with a 16 MiB + /// limit) the 8-bit output must fit; an image of known size is measured in 1-bit pixels. + #[test] + fn a_page_past_the_limit_is_refused() { + let data = page_information(16_384, 16_384); + let untyped = ImageDecodeParams::default(); + assert!(decode(&data, &Dict::empty(), &untyped, 16 << 20).is_none()); + let sized = ImageDecodeParams { is_indexed: false, bpc: Some(1), num_components: Some(1), target_dimension: None, width: 16_384, height: 16_384 }; + assert!(decode(&data, &Dict::empty(), &sized, (16_384 / 8 * 16_384) - 1).is_none()); + } + + #[test] + fn a_page_within_the_limit_decodes() { + let data = page_information(64, 64); + let untyped = ImageDecodeParams::default(); + let out = decode(&data, &Dict::empty(), &untyped, 64 * 64).expect("64 x 64 page"); + assert_eq!(out.data.len(), 64 * 64); + assert!(decode(&data, &Dict::empty(), &untyped, 64 * 64 - 1).is_none()); + let sized = ImageDecodeParams { is_indexed: false, bpc: Some(1), num_components: Some(1), target_dimension: None, width: 64, height: 64 }; + assert_eq!(decode(&data, &Dict::empty(), &sized, 2 * 64 / 8 * 64).expect("sized page").data.len(), 64 * 64); + } } diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index c6ee6ed2..9da38e20 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -112,7 +112,7 @@ impl Filter { #[cfg(feature = "images")] Self::CcittFaxDecode => ccitt::decode(data, params, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] - Self::Jbig2Decode => jbig2::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), + Self::Jbig2Decode => jbig2::decode(data, params, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] Self::JpxDecode => jpx::decode(data, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(not(feature = "images"))] diff --git a/vendor/hayro-syntax/src/object/stream.rs b/vendor/hayro-syntax/src/object/stream.rs index 7de3b297..5818fcbf 100644 --- a/vendor/hayro-syntax/src/object/stream.rs +++ b/vendor/hayro-syntax/src/object/stream.rs @@ -201,9 +201,9 @@ impl<'a> Stream<'a> { /// PdfCraft patch: the decoded data of the stream, for callers that budget what a page /// holds. Flate, LZW and RunLength stop at `max` bytes (and at - /// this PDF's ordinary stream limit in any case). A stream without filters is returned - /// as it is; ASCIIHex and ASCII85 barely expand, and the image codecs stop at their own pixel - /// limits. + /// this PDF's ordinary stream limit in any case), and a JBIG2 page that would decode past it + /// is refused. A stream without filters is returned as it is; ASCIIHex and ASCII85 barely + /// expand, and the other image codecs stop at their own pixel limits. pub fn decoded_within(&self, max: usize) -> Result, DecodeFailure> { self.decode_within(&ImageDecodeParams::default(), max) .map(|r| r.data)