From ee254a91a25ae7a264ba48472042c43c6366e96a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 09:48:52 -0400 Subject: [PATCH] M2.6: fuzz findings in the bootstrap renderer: paint-nesting cap now covers Type 3 glyphs (stack overflow), hayro skips images over 2^28 px (hang); regression tests Co-Authored-By: Claude Opus 5.5 --- crates/render/src/raster.rs | 65 +++++++++++++++++++++++- vendor/README.md | 4 +- vendor/hayro-interpret/src/context.rs | 4 ++ vendor/hayro-interpret/src/font/type3.rs | 5 ++ vendor/hayro-interpret/src/pattern.rs | 5 +- vendor/hayro/src/renderer.rs | 8 +++ 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index aa4090a8ef..f2cb4f1eb4 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -466,7 +466,7 @@ trailer << /Root 1 0 R >> /// From `cargo xtask fuzz`: a tiling pattern with no /Resources inherits the page's, where /// its own name points back at it. It painted itself until the stack overflowed (vendored - /// hayro-interpret patch: `MAX_PATTERN_NESTING`). Ordinary patterns must still paint. + /// hayro-interpret patch: `MAX_PAINT_NESTING`). Ordinary patterns must still paint. #[test] fn self_referencing_tiling_pattern_terminates() { let pdf = |pattern_body: &str| { @@ -497,6 +497,69 @@ trailer << /Root 1 0 R >> assert_eq!(&red.rgba[((20 * 40 + 20) * 4)..][..4], &[255, 0, 0, 255], "a normal tiling pattern still paints"); } + /// From `cargo xtask fuzz`: a Type 3 font without /Resources inherits the page's, where its + /// own name is defined, and its glyph shows text in itself (vendored patch: + /// `MAX_PAINT_NESTING`). Must terminate; a normal Type 3 glyph must still paint. + #[test] + fn self_referencing_type3_glyph_terminates() { + let pdf = |proc_body: &str| { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length 31 >> stream +BT /F1 20 Tf 10 10 Td (A) Tj ET +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] + /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj +6 0 obj << /Length {} >> stream +{proc_body} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + proc_body.len() + ) + }; + let render = |doc: String| { + let mut r = PageRenderer::new(Arc::new(doc.into_bytes()), RenderConfig::default()); + r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }) + }; + let looped = render(pdf("1 0 d0 BT /F1 1 Tf (A) Tj ET 0 0 1 1 re f")); + assert_eq!((looped.width, looped.height), (40, 40)); + let plain = render(pdf("1 0 d0 0 0 1 1 re f")); + assert!(plain.error.is_none(), "{:?}", plain.error); + assert!(plain.rgba.chunks_exact(4).any(|p| p[0] < 128), "a normal Type 3 glyph still paints"); + } + + /// From `cargo xtask fuzz`: an inline image claiming /W 4294967295 over four bytes of data + /// hung in resampling (vendored hayro patch: `MAX_IMAGE_PIXELS`). + #[test] + fn absurd_image_dimensions_are_skipped() { + let content = "q 20 0 0 20 5 5 cm BI /W 4294967295 /H 2 /BPC 8 /CS /G ID \u{0}\u{ff}\u{ff}\u{0} EI Q 1 0 0 rg 0 0 4 4 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not hang the renderer"); + assert!(page.error.is_none(), "{:?}", page.error); + // The rest of the page still draws: the red square at the bottom left (y-down: last rows). + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); + } + /// From `cargo xtask fuzz`: a CID font whose /W range spans every u32 inserted billions of /// widths (vendored hayro-interpret patch: `MAX_CID`). Must finish quickly. #[test] diff --git a/vendor/README.md b/vendor/README.md index 0d31ef9084..a7f9417435 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -7,9 +7,9 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) tiling-pattern nesting cap `MAX_PATTERN_NESTING` (a self-referencing pattern overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | `RenderSettings::{x_offset, y_offset}` to render one tile of a large page | `raster::tests::tiles_match_full_render` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process | `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers` | Temporary: hayro is the bootstrap renderer (ADR-0004); M2.6 replaces it with our own devices. diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 64073571f8..96a42ecccc 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -18,6 +18,10 @@ use std::rc::Rc; /// Maximum nesting depth for interpreting `XObject`'s/patterns/streams. pub(crate) const MAX_NESTED_INTERPRETATION_DEPTH: u32 = 50; +/// PrintCraft patch: maximum nesting for content that is painted recursively by the device +/// (tiling patterns, Type 3 glyphs). Either can refer back to itself through resources it +/// inherits, which recursed until the stack overflowed (found by fuzzing). +pub(crate) const MAX_PAINT_NESTING: u32 = 16; /// A cache used by the interpreter. /// diff --git a/vendor/hayro-interpret/src/font/type3.rs b/vendor/hayro-interpret/src/font/type3.rs index 877f551ad3..e73eab58c8 100644 --- a/vendor/hayro-interpret/src/font/type3.rs +++ b/vendor/hayro-interpret/src/font/type3.rs @@ -123,6 +123,11 @@ impl<'a> Type3<'a> { paint: &Paint<'a>, device: &mut impl Device<'a>, ) -> Option<()> { + // PrintCraft patch: a glyph procedure may show text in this same font (through + // inherited resources), recursing until the stack overflowed. + if glyph.nesting_depth > crate::context::MAX_PAINT_NESTING { + return None; + } let mut state = glyph.state.clone(); let root_transform = transform * glyph_transform * self.matrix * Affine::scale(UNITS_PER_EM as f64); diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 3c01695f50..e972a3d57a 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -24,9 +24,6 @@ use kurbo::{Affine, BezPath, Rect, Shape}; use std::fmt::{Debug, Formatter}; use std::sync::Arc; - -/// PrintCraft patch: maximum nesting of tiling patterns (and the forms around them). -const MAX_PATTERN_NESTING: u32 = 16; /// A PDF pattern. #[derive(Debug, Clone)] pub enum Pattern<'a> { @@ -234,7 +231,7 @@ impl<'a> TilingPattern<'a> { // PrintCraft patch: a tiling pattern can paint itself (directly, or through resources it // inherits when its own are missing), which recursed until the stack overflowed. Bound // the nesting like XObjects do, tighter because every level rasterizes a tile. - if self.nesting_depth > MAX_PATTERN_NESTING { + if self.nesting_depth > crate::context::MAX_PAINT_NESTING { warn!("tiling pattern nesting depth exceeded"); return None; } diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 8e8824bcb1..013ca3b798 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -20,6 +20,9 @@ use vello_cpu::{ Image, ImageSource, Mask, PaintType, Pixmap, RenderContext, RenderSettings, peniko, }; + +/// PrintCraft patch: largest image drawn, in pixels (2^28 ≈ 268 MP, well above real page images). +const MAX_IMAGE_PIXELS: u64 = 1 << 28; pub(crate) struct Renderer { pub(crate) ctx: RenderContext, pub(crate) inside_pattern: bool, @@ -213,6 +216,11 @@ impl Renderer { }; let mut img_width = image_data.width(); let mut img_height = image_data.height(); + // PrintCraft patch: skip images with absurd dimensions (a fuzzed inline image claimed + // /W 4294967295 over four bytes of data; resampling it never finished). + if img_width == 0 || img_height == 0 || u64::from(img_width) * u64::from(img_height) > MAX_IMAGE_PIXELS { + return; + } let interpolate = image_data.interpolate(); if let Some(a) = &alpha_data