From 8c665a19e251f6dcdbffd263952599b2bb6931d7 Mon Sep 17 00:00:00 2001 From: Ikki Nishihara Date: Sat, 10 Oct 2026 17:29:44 +0900 Subject: [PATCH] PDF: bound what a FlateDecode stream may inflate to inflate used the unbounded miniz_oxide functions, and File::parse decodes every object stream eagerly. Use the limit variants (1 GiB per stream, 256 MiB for object streams, a quarter of that on 32-bit targets) and bound the truncated-stream salvage buffer too. Co-Authored-By: Claude Sonnet 5.5 --- crates/pdf/src/object.rs | 54 +++++++++++++++++++++++++++++++------- crates/pdf/src/tests.rs | 56 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index ffd9f9bc..fce77a75 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -304,9 +304,28 @@ pub fn parse_num(w: &[u8]) -> Option { // ------------------------------------------------------------------ filters +/// The most bytes one FlateDecode filter may inflate to. Deflate expands up to about 1000:1, so +/// unbounded, a few MB of PDF ask for gigabytes and a failed allocation aborts the process. The +/// largest image `image::decode` accepts (64 Mi pixels) of four 16-bit components is 512 MiB; +/// 1 GiB leaves room for more components and is PdfCraft's ceiling for one stream. 32-bit +/// targets (the web build) get a quarter: their whole address space is 4 GiB, and the inflater's +/// growing buffer can reserve about twice the limit. +const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 }; + +/// The tighter bound for object streams, which [`File::parse`] decodes eagerly (all of them, +/// before any page is chosen). They hold only other objects' dictionaries, arrays, numbers and +/// strings, so real ones are far smaller; PdfCraft uses 256 MiB for the object and +/// cross-reference streams it decodes while loading. A quarter on 32-bit targets, as above. +const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 }; + /// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT, /// JBIG2) give `None`. pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { + decode_stream_within(file, d, raw, MAX_INFLATED) +} + +/// [`decode_stream`] with each FlateDecode filter's output bounded by `max` bytes. +pub fn decode_stream_within(file: &File, d: &Dict, raw: &[u8], max: usize) -> Option> { let filters: Vec = match d.get("Filter").map(|f| file.resolve(f)) { None => vec![], Some(Obj::Name(n)) => vec![n.clone()], @@ -321,7 +340,7 @@ pub fn decode_stream(file: &File, d: &Dict, raw: &[u8]) -> Option> { let mut data = raw.to_vec(); for (i, f) in filters.iter().enumerate() { data = match f.as_str() { - "FlateDecode" | "Fl" => inflate(&data)?, + "FlateDecode" | "Fl" => inflate(&data, max)?, "ASCIIHexDecode" | "AHx" => ascii_hex(&data), "ASCII85Decode" | "A85" => ascii85(&data), "LZWDecode" | "LZW" => { @@ -358,14 +377,29 @@ fn ccitt_params(file: &File, d: Option<&Dict>) -> crate::ccitt::Params { p } -pub fn inflate(data: &[u8]) -> Option> { - miniz_oxide::inflate::decompress_to_vec_zlib(data).ok().or_else(|| miniz_oxide::inflate::decompress_to_vec(data).ok()).or_else(|| { - // Truncated streams: keep what inflates. - let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib); - let mut out = vec![0u8; data.len() * 8 + 1024]; - let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish); - (r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec()) - }) +/// Inflate zlib data (or raw deflate) to at most `max` bytes. A stream that inflates to more is +/// undecodable (`None`), not salvaged as a truncated one. +pub fn inflate(data: &[u8], max: usize) -> Option> { + use miniz_oxide::inflate::{TINFLStatus, decompress_to_vec_with_limit, decompress_to_vec_zlib_with_limit}; + for attempt in [decompress_to_vec_zlib_with_limit, decompress_to_vec_with_limit] { + match attempt(data, max) { + Ok(v) => return Some(v), + Err(e) if e.status == TINFLStatus::HasMoreOutput => return None, + Err(_) => {} + } + } + // Truncated streams: keep what inflates. + let mut d = miniz_oxide::inflate::stream::InflateState::new_boxed(miniz_oxide::DataFormat::Zlib); + let mut out = vec![0u8; salvage_len(data.len(), max)]; + let r = miniz_oxide::inflate::stream::inflate(&mut d, data, &mut out, miniz_oxide::MZFlush::Finish); + (r.bytes_written > 0).then(|| out[..r.bytes_written].to_vec()) +} + +/// The buffer [`inflate`] salvages a truncated stream of `data_len` bytes into: eight times that +/// and 1 KiB, at most `max`. The zlib attempt already stopped within `max`, so the bound loses +/// nothing, while without it a Flate filter after another could ask for eight times `max`. +pub fn salvage_len(data_len: usize, max: usize) -> usize { + data_len.saturating_mul(8).saturating_add(1024).min(max) } fn ascii_hex(data: &[u8]) -> Vec { @@ -655,7 +689,7 @@ impl File { }) .collect(); for (d, raw) in streams { - let Some(dec) = decode_stream(&f, &d, &raw) else { continue }; + let Some(dec) = decode_stream_within(&f, &d, &raw, MAX_OBJECT_STREAM) else { continue }; let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize; let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize; let mut lx = Lexer::new(&dec, 0); diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index dac34bee..d5d2aa18 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -147,7 +147,61 @@ fn eps_postscript_subset() { fn filters_decode() { assert_eq!(object::ascii85(b"<~87cURD]i,\"Ebo80~>"), b"Hello World!".to_vec()); let z = miniz_oxide::deflate::compress_to_vec_zlib(b"abc", 6); - assert_eq!(object::inflate(&z).unwrap(), b"abc"); + assert_eq!(object::inflate(&z, 1 << 20).unwrap(), b"abc"); +} + +#[test] +fn flate_bomb_is_bounded() { + // A decompression bomb in miniature: 16 MiB of zeros deflate to about 16 KB. + let zeros = vec![0u8; 16 << 20]; + for bomb in [miniz_oxide::deflate::compress_to_vec_zlib(&zeros, 6), miniz_oxide::deflate::compress_to_vec(&zeros, 6)] { + assert!(bomb.len() < 64 << 10); + // Past the limit, as zlib and as raw deflate, it is undecodable: not inflated in full + // and not salvaged as a truncated stream. + assert_eq!(object::inflate(&bomb, 1 << 20).map(|v| v.len()), None); + assert_eq!(object::inflate(&bomb, 32 << 20).map(|v| v.len()), Some(16 << 20)); + } +} + +#[test] +fn truncated_flate_keeps_what_inflates() { + let mut x = 1u32; + let data: Vec = (0..20_000) + .map(|_| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + (x >> 16) as u8 + }) + .collect(); + let z = miniz_oxide::deflate::compress_to_vec_zlib(&data, 6); + let part = object::inflate(&z[..z.len() / 2], 1 << 20).unwrap(); + assert!(!part.is_empty() && part.len() < data.len()); + assert_eq!(part, data[..part.len()]); +} + +#[test] +fn salvage_buffer_is_bounded_by_the_limit() { + // Small inputs keep eight times their size and 1 KiB. + assert_eq!(object::salvage_len(0, 1 << 20), 1024); + assert_eq!(object::salvage_len(10_000, 1 << 20), 81_024); + // Large ones stop at the limit (`1 << 28` fits a 32-bit `usize` too), without overflowing. + assert_eq!(object::salvage_len(1 << 28, 1 << 20), 1 << 20); + assert_eq!(object::salvage_len(usize::MAX, 1 << 20), 1 << 20); +} + +#[test] +fn nested_bomb_in_the_second_flate_filter_is_refused() { + // `/Filter [/FlateDecode /FlateDecode]`: the first filter inflates, within the limit, to a zlib + // bomb (padded to just under the limit) that the second would inflate to 16 MiB. The second + // stops at the limit, so the stream is undecodable. + const MAX: usize = 1 << 20; + let flate = object::Obj::Name("FlateDecode".into()); + let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect(); + let file = object::File::parse(b""); + let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6); + nested.resize(MAX - 1, 0); + let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6); + assert_eq!(object::inflate(&raw, MAX).map(|v| v.len()), Some(MAX - 1)); + assert_eq!(object::decode_stream_within(&file, &chain, &raw, MAX).map(|v| v.len()), None); } // ------------------------------------------------------------------ M13.6: text, images,