Audit: PDF import and export bounds

This commit is contained in:
Dylan M. Taylor
2026-10-24 04:49:07 +07:00
committed by bflatastic
parent 9c8afb09ba
commit 4b8e69981e
7 changed files with 55 additions and 6 deletions
+10
View File
@@ -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<String> {
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
}
+5 -1
View File
@@ -47,11 +47,15 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
/// holds. The writer reports such an image (a PNG, GIF or WebP kept as it is, whose header reads)
/// only when it finishes the file. Each repeated run leaves out at least one more image.
pub(crate) fn leaving_out_undecodable<T>(mut write: impl FnMut(&mut HashSet<String>) -> Result<T, PdfError>) -> Result<T, PdfError> {
// 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,
}
}
+10 -2
View File
@@ -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
}
+5 -1
View File
@@ -362,7 +362,8 @@ impl<'p> Colors<'p> {
let alt = alt_model(&it.next::<Object<'_>>()?);
let tint = Function::new(&it.next::<Object<'_>>()?)?;
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<Color> {
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;
+15 -1
View File
@@ -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<State>,
/// 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(),
+6 -1
View File
@@ -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));
+4
View File
@@ -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<usize>)> = (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")));