From 4b8e69981e785b2c1a2253651811726f82a7c841 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 16:50:49 -0400 Subject: [PATCH] Audit: PDF import and export bounds --- crates/pdf/src/encrypt.rs | 10 ++++++++++ crates/pdf/src/export.rs | 6 +++++- crates/pdf/src/import.rs | 12 ++++++++++-- crates/pdf/src/import_color.rs | 6 +++++- crates/pdf/src/import_scan.rs | 16 +++++++++++++++- crates/pdf/src/import_text.rs | 7 ++++++- crates/pdf/src/print/mod.rs | 4 ++++ 7 files changed, 55 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index 39a9ff48b..f506ec9d7 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -113,6 +113,13 @@ impl SecuritySettings { standard.label() ))); } + // Revision 2 has one printing bit: it can't say "low resolution only". + if revision(c) == 2 && self.printing == Printing::Low && !self.permissions_password.is_empty() { + return Err(PdfError::BadSetting(format!( + "{} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing", + c.label() + ))); + } if self.open_password == self.permissions_password { return Err(PdfError::BadSetting("the open and permissions passwords must differ".into())); } @@ -146,6 +153,9 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec { if s.protected() && s.plaintext_metadata && revision(set.compatibility) <= 3 { out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label())); } + if s.protected() && set.compatibility == Compatibility::Pdf13 { + out.push(format!("{} encrypts with a 40-bit key, which can be searched in minutes: choose PDF 1.6 or later for stronger protection", set.compatibility.label())); + } out } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index e093247dd..60f4353e3 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -47,11 +47,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result(mut write: impl FnMut(&mut HashSet) -> Result) -> Result { + // The most runs repeated: each is a whole export, and a document of many images that don't + // decode would make them quadratic. Past it the writer's error is the result. + const MAX_RETRIES: usize = 16; let mut left_out = HashSet::new(); + let mut retries = 0; loop { let known = left_out.len(); match write(&mut left_out) { - Err(_) if left_out.len() > known => {} + Err(_) if left_out.len() > known && retries < MAX_RETRIES => retries += 1, done => return done, } } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index c161f47c1..d434abedb 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -483,6 +483,10 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p let list = b.missing_fonts.join(", "); b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}")); } + // Before anything walks the tree by recursion (`fix_next_id` does): nested clips can be deeper + // than the stack goes. + let mut depth_notes = vec![]; + doc.limit_depth_noting(&mut depth_notes); doc.fix_next_id(); doc.reserve_ids(b.next); @@ -494,7 +498,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p } } } - doc.limit_depth_noting(&mut warnings); + warnings.extend(depth_notes); let user_unit = picked.first().and_then(|&n| pages.get(n)).map_or(1.0, |page| f64::from(page.user_unit())); Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit }) } @@ -982,12 +986,16 @@ fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> Stro kurbo::Join::Bevel => LineJoin::Bevel, }; st.miter_limit = p.miter_limit as f64; - let dash = Dash { pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(), offset: p.dash_offset as f64 * scale, align_corners: false }; + // (Every stroke keeps its own copy: a dash array has a few entries, and one with thousands is cut.) + let dash = Dash { pattern: p.dash_array.iter().take(MAX_DASH_ENTRIES).map(|v| *v as f64 * scale).collect(), offset: p.dash_offset as f64 * scale, align_corners: false }; // An invalid dash array (a negative value, or all zeros) strokes solid. st.dash = dash.is_dashed().then_some(dash); st } +/// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). +const MAX_DASH_ENTRIES: usize = 64; + pub(crate) fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index 2bdafbadd..ac5b1580e 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -362,7 +362,8 @@ impl<'p> Colors<'p> { let alt = alt_model(&it.next::>()?); let tint = Function::new(&it.next::>()?)?; let key = debug_text(&tint)?; - if names.is_empty() || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { + // (PDF's limit is 32 colorants; each gets a vector of that length below.) + if names.is_empty() || names.len() > MAX_COLORANTS || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { return None; } let colorants = names @@ -521,3 +522,6 @@ fn alt_color(alt: Alt, v: &[f32]) -> Option { Alt::Lab => Color::lab(round3(c(0)?.clamp(0.0, 100.0)), round3(c(1)?.clamp(-128.0, 127.0)), round3(c(2)?.clamp(-128.0, 127.0))), }) } + +/// The most colorants of a DeviceN space read. +const MAX_COLORANTS: usize = 64; diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 613bb33e7..694cc35c5 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -28,6 +28,9 @@ use crate::import_color::ink_names; /// The interpreter's limit on nested form XObjects. const MAX_DEPTH: u32 = 50; + +/// The most graphics states saved at once (PDF's own limit for nesting is 28; files nest far deeper). +const MAX_STATES: usize = 65_536; /// The most content operators walked per page (a guard against forms drawn within each other /// many times over): the art past it can't be told apart by group. A drawing of a million paths /// takes a few million; the walk reads about 15 million a second, twice as fast as the @@ -464,6 +467,8 @@ struct Walker<'w, 'p> { /// The saved graphics states' overprint parameters and inks, the current one last, pushed and /// popped as the interpreter does its states. states: Vec, + /// The `q`s past [`MAX_STATES`] whose `Q` has not come. + unkept: usize, /// The text shown since the last painted path. text: TextOverprint, /// The inks of the text shown since the last painted path: of its fills, of its strokes. @@ -513,12 +518,20 @@ impl Walker<'_, '_> { } fn save(&mut self) { + // Past this many nested states the new ones are not kept (a stream of `q` is cheap to write + // and holds a state each); their `Q`s are counted off first. + if self.states.len() >= MAX_STATES { + self.unkept = self.unkept.saturating_add(1); + return; + } self.states.push(self.state()); } /// The interpreter never pops its first state. fn restore(&mut self) { - if self.states.len() > 1 { + if self.unkept > 0 { + self.unkept -= 1; + } else if self.states.len() > 1 { self.states.pop(); } } @@ -1028,6 +1041,7 @@ fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: & out: Scan::default(), hidden_form: false, states: vec![State::default()], + unkept: 0, text: TextOverprint::None, text_inks: [RunInk::None; 2], ink_index: HashMap::new(), diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 7117a14be..4eca5d11a 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -166,6 +166,9 @@ pub(crate) struct TextLine { /// Runs of glyphs drawn alike: their look, how far the file raised them off the line's /// baseline (points, up; text rise, `Ts`), their text. runs: Vec<(Look, f64, String)>, + /// The bytes of text the horizontal glyphs added to `runs` so far (counted as they come: a + /// sum over the runs for each glyph is quadratic when every glyph is a run). + text_bytes: usize, /// Where the file put each character of the runs' text, in order: the first character of each /// glyph has its glyph's [`Slot`]; a space read from a gap and the other characters of a /// glyph of several have none. @@ -227,6 +230,7 @@ impl TextLine { last_size: at.size, baseline: vec![], runs: vec![], + text_bytes: 0, slots: vec![], upright: None, column: false, @@ -292,7 +296,7 @@ impl TextLine { if self.column || self.upright.is_some() { return false; } - let bytes: usize = self.runs.iter().map(|(_, _, t)| t.len()).sum(); + let bytes = self.text_bytes; let glyph = self.baseline.len(); let curved = self.curved(); let mut shift = 0.0; @@ -350,6 +354,7 @@ impl TextLine { } else { self.runs.push((look.clone(), 0.0, text.to_string())); } + self.text_bytes = self.text_bytes.saturating_add(text.len()); let one = text.chars().count() == 1; self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) })); self.slots.extend(text.chars().skip(1).map(|_| None)); diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index d207fac8f..b3fa28508 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -221,6 +221,10 @@ impl<'a> Job<'a> { if printed.is_empty() { return Err(PdfError::BadSetting("output.inks: no ink is set to print".into())); } + // Counted before the sheets are made: layouts times inks can be far more than the limit. + if layouts.len().saturating_mul(printed.len()).saturating_mul(set.copies as usize) > MAX_PAGES { + return Err(PdfError::BadSetting(format!("the job would print more than {MAX_PAGES} pages"))); + } let sheets: Vec<(usize, Option)> = (0..layouts.len()).flat_map(|l| printed.iter().map(move |i| (l, *i))).collect(); if sheets.len().saturating_mul(set.copies as usize) > MAX_PAGES { return Err(PdfError::BadSetting(format!("the job would print more than {MAX_PAGES} pages")));