From 13d766e0746bc31b1bc55668bfa3fb009429b385 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:29:32 -0400 Subject: [PATCH] security: bound vendored hayro CCITT rows of unknown height and JPEG 2000 codestream size (#255) * security: bound vendored hayro CCITT rows of unknown height and JPEG 2000 codestream size - CCITT: `/Rows 0` decoded to the end of the data; a bit of 2D code can repeat a whole row, so a small stream produced gigabytes. Rows are now capped by the output limit (in the stream's 1-bit rows) and 2^28 pixels. - JPEG 2000: the decoder allocated its output from the codestream's own dimensions, infallibly. Over 2^28 pixels or the output limit (at least 64 MiB) is refused before decoding, and the buffer is reserved fallibly. - SOURCE.json local hashes updated (ascii_85.rs's had not been after #253). Co-Authored-By: Claude Opus 5.5 * fix(ccitt): pass the output limit in the columns test --------- Co-authored-by: Claude Opus 5.5 --- vendor/README.craft-reader.md | 6 ++++ vendor/hayro-syntax/SOURCE.json | 8 ++--- vendor/hayro-syntax/src/filter/ccitt.rs | 14 +++++++- vendor/hayro-syntax/src/filter/jpx.rs | 44 +++++++++++++++++++++++-- vendor/hayro-syntax/src/filter/mod.rs | 32 +++++++++++++++--- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 1a560e73..55821de4 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -175,6 +175,12 @@ comma. The unit tests of all these items come with them; the CCITT, Type 3 and c raster tests are adapted in `tests/upstream_raster.rs` and the text contracts in `tests/upstream_text.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not ported. +- A CCITT image with `/Rows 0` (unknown height) decodes at most the rows that fit the stream's + output limit (counted in 1-bit rows) and 2^28 pixels; a known height is clamped the same way. + Before, it decoded to the end of the data, and a bit of 2D code can repeat a whole row. +- A JPEG 2000 image over 2^28 pixels, or over its output limit (at least 64 MiB), by the + codestream's own dimensions is refused before decoding, and its buffer is reserved fallibly: + the image dictionary's smaller size no longer stands in for the size the decoder allocates. This remains the patched 0.7 backend. It retains the existing Bradford Lab conversion, floating component precision and codec versions, rather than claiming full 0.8 diff --git a/vendor/hayro-syntax/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index d2234ad3..bacbb5fe 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -146,7 +146,7 @@ "path": "src/filter/ccitt.rs", "source_sha256": "45d7eb89934d28cff97a00c7b264010bccfd14f3d511e1307ad50a9fef3b53c8", "source_git_blob": "685d680d2fbadce608a9bd3ca925c2ccc6c5d641", - "sha256": "b0fef2271417c3009a1de569c9573b05dd464ff76c1a5f7220f634bb5feb44e2" + "sha256": "67d17a339639eb0fef4cc6c745b579c6ba9ae3f2f67fd3bacb1bf1389aa5ffab" }, { "path": "src/filter/dct.rs", @@ -162,9 +162,9 @@ }, { "path": "src/filter/jpx.rs", - "source_sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82", + "source_sha256": "c9f068629c9cbe79af6793c3c125df4631ca1455691cb1335b44ff43cb1b4056", "source_git_blob": "74efd8b31e2b2935ff2bbceda2c1ffd36ea9c20d", - "sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82" + "sha256": "c9f068629c9cbe79af6793c3c125df4631ca1455691cb1335b44ff43cb1b4056" }, { "path": "src/filter/lzw_flate.rs", @@ -176,7 +176,7 @@ "path": "src/filter/mod.rs", "source_sha256": "0af7145ab16b15b7a60c4b57efecb9bd5fd0cfdc9bb5f9388abb695386e63e49", "source_git_blob": "06bda29487d62c25dc39c1ee109091cb17e3867f", - "sha256": "e33e74ee55dcc850db9eb64811c56b4ffe1932908ab9684dfba07f4277b1eea9" + "sha256": "e43d7d309a4993f4a1f698208997122431b4a8b0db838bb07d4358ccbc00f02f" }, { "path": "src/filter/run_length.rs", diff --git a/vendor/hayro-syntax/src/filter/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index b83e2c41..a7921a5d 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -8,10 +8,12 @@ use alloc::vec::Vec; use core::iter; use hayro_ccitt::{DecodeSettings, Decoder, DecoderContext, EncodingMode}; +/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`). pub(crate) fn decode( data: &[u8], params: &Dict<'_>, image_params: &ImageDecodeParams, + limit: usize, ) -> Option> { let k = params.get::(K).unwrap_or(0); @@ -52,6 +54,16 @@ pub(crate) fn decode( if !super::ccitt_size_ok(settings.columns, settings.rows) { return None; } + // PdfCraft patch: the decoder stops after `rows` rows; with `/Rows 0` (unknown) it ran to the + // end of the data, and a bit of 2D code can repeat a whole row, so a small stream produced + // gigabytes. Decode at most the rows that fit the output limit and the pixel cap. The limit + // counts the stream's own 1-bit rows (`decode_limit`), whatever depth the rows are widened to. + let mut settings = settings; + let fit = super::ccitt_max_rows(settings.columns, settings.columns.div_ceil(8), limit); + settings.rows = if settings.rows == 0 { fit } else { settings.rows.min(fit) }; + if settings.rows == 0 { + 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 @@ -181,7 +193,7 @@ mod tests { #[test] fn columns_past_u32_max_are_refused_not_wrapped() { let params = ImageDecodeParams { width: 8, height: 4, ..Default::default() }; - let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), ¶ms); + let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), ¶ms, 1 << 20); assert!(decoded(b"<< /K -1 /Columns 8 >>").is_some()); // 2^32 + 8 would wrap to 8 columns. assert!(decoded(b"<< /K -1 /Columns 4294967304 >>").is_none()); diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 74efd8b3..9feb7074 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -17,7 +17,15 @@ impl ImageColorSpace { } } -pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option> { +/// PdfCraft patch: the most pixels a JPEG 2000 image may decode to, as for JBIG2. +const MAX_PIXELS: u64 = 1 << 28; + +/// PdfCraft patch: the least output `limit` a JPEG 2000 image is held to: the limit comes from the +/// image dictionary's size, which a decoder may round differently from the codestream's. +const MIN_LIMIT: usize = 64 << 20; + +/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`). +pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams, limit: usize) -> Option> { use crate::object::stream::ImageColorSpace; let settings = DecodeSettings { @@ -58,7 +66,23 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option MAX_PIXELS { + return None; + } + let channels = usize::from(cs.num_components()) + usize::from(has_alpha); + let len = (width as usize).checked_mul(height as usize)?.checked_mul(channels)?; + let output_limit = limit.max(MIN_LIMIT); + if len > output_limit { + return None; + } + let mut bitmap = Vec::new(); + bitmap.try_reserve_exact(len).ok()?; + bitmap.resize(len, 0); + let mut context = hayro_jpeg2000::DecoderContext::default(); + image.decode_into(&mut bitmap, &mut context).ok()?; let (mut data, mut alpha) = if !has_alpha { (bitmap, None) @@ -82,6 +106,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option output_limit { + return None; + } data = scale(&data, bpc, cs.num_components(), width, height)?; alpha = alpha.and_then(|alpha| scale(&alpha, bpc, cs.num_components(), width, height)); } @@ -198,7 +234,9 @@ fn scale( .checked_mul(num_components as usize)? .checked_mul(bit_per_component as usize)?; let input_len = bits_per_row.div_ceil(8).checked_mul(height as usize)?; - let mut input = vec![0; input_len]; + let mut input = Vec::new(); + input.try_reserve_exact(input_len).ok()?; + input.resize(input_len, 0); let mut writer = BitWriter::new(&mut input, bit_per_component)?; let components_per_row = (num_components as usize).checked_mul(width as usize)?; diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index e3622795..c6ee6ed2 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -110,11 +110,11 @@ impl Filter { #[cfg(feature = "images")] Self::DctDecode => dct::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] - Self::CcittFaxDecode => ccitt::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), + 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), #[cfg(feature = "images")] - Self::JpxDecode => jpx::decode(data, image_params).ok_or(DecodeFailure::ImageDecode), + Self::JpxDecode => jpx::decode(data, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(not(feature = "images"))] Self::DctDecode | Self::CcittFaxDecode | Self::Jbig2Decode | Self::JpxDecode => { warn!("image decoding is not supported (enable the `images` feature)"); @@ -168,6 +168,30 @@ pub(crate) fn decode_limit(params: &ImageDecodeParams, last: bool, limits: crate /// real fax lines are a few thousand pixels wide. pub fn ccitt_size_ok(columns: u32, rows: u32) -> bool { const MAX_COLUMNS: u32 = 1 << 20; - const MAX_PIXELS: u64 = 1 << 28; - columns > 0 && columns <= MAX_COLUMNS && u64::from(columns) * u64::from(rows.max(1)) <= MAX_PIXELS + columns > 0 && columns <= MAX_COLUMNS && u64::from(columns) * u64::from(rows.max(1)) <= MAX_CCITT_PIXELS +} + +/// PdfCraft patch: the most pixels a CCITT image may decode to. +const MAX_CCITT_PIXELS: u64 = 1 << 28; + +/// PdfCraft patch: the most rows of `columns` pixels, `row_bytes` bytes each once decoded, that fit +/// both `limit` bytes and the pixel cap; at least one when one row fits. +pub(crate) fn ccitt_max_rows(columns: u32, row_bytes: u32, limit: usize) -> u32 { + let by_pixels = MAX_CCITT_PIXELS / u64::from(columns.max(1)); + let by_bytes = u64::try_from(limit).unwrap_or(u64::MAX) / u64::from(row_bytes.max(1)); + u32::try_from(by_pixels.min(by_bytes)).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod ccitt_tests { + use super::ccitt_max_rows; + + /// PdfCraft patch: an image of unknown height decodes at most the rows that fit both the output + /// limit (in 1-bit rows) and the 2^28-pixel cap. + #[test] + fn unknown_ccitt_rows_fit_the_limit_and_the_pixel_cap() { + assert_eq!(ccitt_max_rows(1728, 216, 216 * 100), 100); + assert_eq!(ccitt_max_rows(1 << 20, 1 << 17, usize::MAX), 1 << 8); + assert_eq!(ccitt_max_rows(1728, 216, 100), 0); + } }