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 <noreply@anthropic.com>
This commit is contained in:
Ikki Nishihara
2026-10-10 17:29:44 +09:00
co-authored by Claude Sonnet 5.5
parent 813c7c4650
commit 8c665a19e2
2 changed files with 99 additions and 11 deletions
+44 -10
View File
@@ -304,9 +304,28 @@ pub fn parse_num(w: &[u8]) -> Option<f64> {
// ------------------------------------------------------------------ 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<Vec<u8>> {
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<Vec<u8>> {
let filters: Vec<String> = 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8> {
@@ -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);
+55 -1
View File
@@ -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<u8> = (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,