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.
This commit is contained in:
Dylan M. Taylor
2026-11-02 20:44:12 -05:00
parent b047c2f7a6
commit 5b4f71f988
4 changed files with 53 additions and 8 deletions
+3 -3
View File
@@ -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",
+46 -1
View File
@@ -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<FilterResult<'static>> {
// 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(), &params).is_none());
assert!(decode(&data, &Dict::empty(), &params, 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);
}
}
+1 -1
View File
@@ -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"))]
+3 -3
View File
@@ -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<Cow<'a, [u8]>, DecodeFailure> {
self.decode_within(&ImageDecodeParams::default(), max)
.map(|r| r.data)