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 <noreply@anthropic.com>

* fix(ccitt): pass the output limit in the columns test

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dylan M. Taylor
2026-10-24 04:29:32 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7895f5ed79
commit 13d766e074
5 changed files with 92 additions and 12 deletions
+6
View File
@@ -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
+4 -4
View File
@@ -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",
+13 -1
View File
@@ -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<FilterResult<'static>> {
let k = params.get::<i32>(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(), &params);
let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), &params, 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());
+41 -3
View File
@@ -17,7 +17,15 @@ impl ImageColorSpace {
}
}
pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterResult<'static>> {
/// 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<FilterResult<'static>> {
use crate::object::stream::ImageColorSpace;
let settings = DecodeSettings {
@@ -58,7 +66,23 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
},
};
let has_alpha = image.has_alpha();
let bitmap = image.decode().ok()?;
// PdfCraft patch: the decoder sizes its output from the codestream's own dimensions, not the
// image dictionary's, and allocates it infallibly. Refuse more than `MAX_PIXELS` pixels or
// more than `limit` bytes before decoding, and reserve the buffer fallibly.
if u64::from(width) * u64::from(height) > 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<FilterRe
// The decoded image is always 8-bit, so if necessary we have to rescale
// ourselves.
if bpc != 8 {
// PdfCraft patch: the rescaled buffer can be larger than the 8-bit bitmap (up to 32 bits
// per component), so bound it, with alpha, by the same output limit.
let bits_per_row = (width as usize)
.checked_mul(usize::from(cs.num_components()))?
.checked_mul(usize::from(bpc))?;
let scaled_len = bits_per_row
.div_ceil(8)
.checked_mul(height as usize)?
.checked_mul(if alpha.is_some() { 2 } else { 1 })?;
if scaled_len > 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)?;
+28 -4
View File
@@ -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);
}
}