From 6ed0e6684876196d0bdf7759ca9251ee5ec19980 Mon Sep 17 00:00:00 2001 From: pechnikov2026-star Date: Sun, 11 Oct 2026 20:33:02 +0300 Subject: [PATCH] render: don't let a hostile page-tree /Count abort inspection (#956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * render: don't let a hostile page-tree /Count abort inspection Found by cargo xtask fuzz on Windows (seed 42): a 355-byte PDF whose /Type /Pages node carries /Count 4294967295 aborted pdfcraft-cli check-one with a 48 GiB allocation (4294967295 x 12 bytes) - an abort, which the engine's catch_unwind guard cannot catch. AGENTS.md ยง4 requires a malformed PDF to produce an actable error, never a crash. The lopdf Structure impl delegated get_pages to lopdf's own page-tree walk, which trusts /Count and pre-allocates by it. The trait's default walk (depth cap, seen-set, checked page numbering) handles the same input fine, so the override is simply dropped and the bounded walk counts the real pages. Comes with a synthetic regression test (/Count 4294967295, one real page); cargo test -p pdfcraft-render, clippy -D warnings, fmt --check and an end-to-end check-one run all pass. * Drop the duplicated use super::* in the structure tests --------- Co-authored-by: pechnikov2026-star Co-authored-by: Brandon Thomas --- crates/render/src/structure.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/render/src/structure.rs b/crates/render/src/structure.rs index 79ff3159..018de5a6 100644 --- a/crates/render/src/structure.rs +++ b/crates/render/src/structure.rs @@ -115,9 +115,9 @@ impl Structure for lopdf::Document { fn encrypted(&self) -> bool { self.is_encrypted() || self.was_encrypted() || self.trailer.has(b"Encrypt") } - fn get_pages(&self) -> BTreeMap { - self.get_pages() - } + // `get_pages` is not overridden: lopdf's own page-tree walk trusts /Count and pre-allocates + // by it, so a hostile /Count (a fuzz finding: 4294967295) aborted the process with a 48 GiB + // allocation. The trait's bounded default walk counts the real pages instead. fn get_page_fonts(&self, page: ObjectId) -> Result, &Dictionary>> { self.get_page_fonts(page) } @@ -207,6 +207,29 @@ fn convert(object: &CosObject, depth: usize) -> Result { mod tests { use super::*; + /// A hostile page-tree /Count must not abort the process: lopdf's own `get_pages` + /// pre-allocates by it (fuzz finding: /Count 4294967295 -> a 48 GiB allocation), + /// the bounded default walk counts the real pages instead. + #[test] + fn a_hostile_page_count_does_not_abort_inspection() { + let pdf = b"%PDF-1.7 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 4294967295 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> +endobj +trailer +<< /Size 4 /Root 1 0 R >> +%%EOF"; + let doc = lopdf::Document::load_mem(&pdf[..]).unwrap(); + let pages = Structure::get_pages(&doc); + assert_eq!(pages.len(), 1, "the walk counts the one real page, not /Count"); + } + #[test] fn inspection_loads_only_requested_objects_in_a_compressed_document() { let mut source = lopdf::Document::with_version("1.7");