diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index bc3ea01f4..99470b58a 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1198,18 +1198,66 @@ fn find_by_name(doc: &Document, nm: &str) -> Option { None } -/// Copy an object out of another document, resolving references (FDF objects are small). -fn deep(src: &Document, o: &Object, depth: usize) -> Object { +/// What one FDF import may copy out of the file. A field tree or comment that repeats a reference +/// level after level would otherwise expand a small file into millions of values. +const MAX_FDF_IMPORT_VALUES: usize = 1_000_000; + +struct ImportBudget { + values: usize, + bytes: usize, +} + +impl ImportBudget { + fn new() -> Self { + Self { values: MAX_FDF_IMPORT_VALUES, bytes: MAX_FDF_BYTES } + } + + fn spend(&mut self, values: usize, bytes: usize) -> Result<(), DataError> { + match (self.values.checked_sub(values), self.bytes.checked_sub(bytes)) { + (Some(v), Some(b)) => { + (self.values, self.bytes) = (v, b); + Ok(()) + } + _ => Err(DataError::Malformed(format!( + "the file expands to more than {MAX_FDF_IMPORT_VALUES} values or {} MiB of text", + MAX_FDF_BYTES >> 20 + ))), + } + } +} + +/// Copy an object out of another document, resolving references. A reference back to an object +/// being copied becomes null. +fn deep(src: &Document, o: &Object, depth: usize, path: &mut Vec, budget: &mut ImportBudget) -> Result { + budget.spend(1, 0)?; if depth > 16 { - return Object::Null; + return Ok(Object::Null); } - match o { - Object::Ref(r) => deep(src, &src.get(*r), depth + 1), - Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1)).collect()), - Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), deep(src, v, depth + 1))).collect()), + Ok(match o { + Object::Ref(r) => { + if path.contains(r) { + return Ok(Object::Null); + } + path.push(*r); + let copy = deep(src, &src.get(*r), depth + 1, path, budget); + path.pop(); + copy? + } + Object::Array(a) => Object::Array(a.iter().map(|x| deep(src, x, depth + 1, path, budget)).collect::>()?), + Object::Dict(d) => { + Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::>()?) + } Object::Stream(_) => Object::Null, + Object::String(s) => { + budget.spend(0, s.bytes.len())?; + o.clone() + } + Object::Name(n) => { + budget.spend(0, n.len())?; + o.clone() + } other => other.clone(), - } + }) } fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) -> Result<(), DataError> { @@ -1233,27 +1281,59 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec)>, depth: usize) { +/// Read an FDF field tree. A field listed again beneath itself is skipped. +fn walk_fdf( + fdf: &Document, + list: &[Object], + prefix: &str, + out: &mut Vec<(String, Vec)>, + depth: usize, + path: &mut Vec, + budget: &mut ImportBudget, +) -> Result<(), DataError> { if depth > 32 { - return; + return Ok(()); } for f in list { - let Some(d) = fdf.resolve(f).as_dict().cloned() else { continue }; + budget.spend(1, 0)?; + let held; + let field = match f { + Object::Ref(r) if path.contains(r) => continue, + Object::Ref(r) => { + held = fdf.get(*r); + &*held + } + direct => direct, + }; + let Some(d) = field.as_dict() else { continue }; let Some(t) = d.get(b"T").and_then(|t| fdf.resolve(t).as_string().map(|s| s.to_text())) else { continue }; let full = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + budget.spend(0, full.len())?; if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) { let vals: Vec = match &*v { Object::String(s) => vec![s.to_text()], Object::Name(n) => vec![pdfcraft_forms::name_text(n)], - Object::Array(a) => a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect(), + Object::Array(a) => { + budget.spend(a.len(), 0)?; + a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect() + } _ => Vec::new(), }; + budget.spend(vals.len(), vals.iter().map(String::len).fold(0, usize::saturating_add))?; out.push((full.clone(), vals)); } - if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)).and_then(|k| k.as_array().cloned()) { - walk_fdf(fdf, &kids, &full, out, depth + 1); + if let Some(kids) = d.get(b"Kids").map(|k| fdf.resolve(k)) + && let Some(kids) = kids.as_array() + { + let entered = f.as_ref().map(|r| path.push(r)).is_some(); + let walked = walk_fdf(fdf, kids, &full, out, depth + 1, path, budget); + if entered { + path.pop(); + } + walked?; } } + Ok(()) } fn fdf_body(fdf: &Document) -> Result { @@ -1272,8 +1352,9 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; let body = fdf_body(&fdf)?; let mut values = Vec::new(); + let mut budget = ImportBudget::new(); if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0); + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; } return Ok(values); } @@ -1346,10 +1427,11 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { let fdf = Document::open(std::sync::Arc::new(bytes.to_vec())).map_err(|e| DataError::Malformed(e.to_string()))?; let body = fdf_body(&fdf)?; let mut report = Report::default(); + let mut budget = ImportBudget::new(); // Fields (with /Kids for hierarchical names). let mut values = Vec::new(); if let Some(list) = body.get(b"Fields").map(|f| fdf.resolve(f)).and_then(|f| f.as_array().cloned()) { - walk_fdf(&fdf, &list, "", &mut values, 0); + walk_fdf(&fdf, &list, "", &mut values, 0, &mut Vec::new(), &mut budget)?; } // Comments: parents first, then replies (whose /IRT needs the parent's new reference). let pages: Vec = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect(); @@ -1360,7 +1442,8 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { // The parent is another comment: link it below rather than copying it in. let irt = src.get(b"IRT").cloned(); src.remove(b"IRT"); - let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0) else { continue }; + let mut path: Vec = a.as_ref().into_iter().collect(); + let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { continue; }; diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index a93f70c70..cf9095bf3 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1759,6 +1759,67 @@ fn an_imported_redaction_rect_with_a_non_number_is_not_used() { assert!(mark.get(b"QuadPoints").is_none(), "quads were drawn from a malformed /Rect: {mark:?}"); } +/// An FDF body with `body` as its `/FDF` dictionary contents and `objects` (numbered from 2). +fn fdf_with(body: &str, objects: &[String]) -> Vec { + let mut out = format!("%FDF-1.2\n1 0 obj\n<< /FDF << {body} >> >>\nendobj\n"); + for (i, o) in objects.iter().enumerate() { + out.push_str(&format!("{} 0 obj\n{o}\nendobj\n", i + 2)); + } + out.push_str("trailer\n<< /Root 1 0 R >>\n%%EOF\n"); + out.into_bytes() +} + +/// A field that lists itself as a kid is read once. +#[test] +fn an_fdf_field_listing_itself_is_read_once() { + let cyclic = fdf_with("/Fields [2 0 R]", &["<< /T (a) /V (x) /Kids [2 0 R] >>".into()]); + assert_eq!(data_values(&cyclic).unwrap(), vec![("a".to_string(), vec!["x".to_string()])]); +} + +/// A field tree whose `/Kids` repeat one subtree level after level is refused rather than +/// expanded into millions of rows. +#[test] +fn fdf_field_trees_that_repeat_references_are_bounded() { + let levels = 19; + let mut objects: Vec = (0..levels).map(|i| format!("<< /T (f{i}) /V (x) /Kids [{n} 0 R {n} 0 R] >>", n = i + 3)).collect(); + objects.push("<< /T (leaf) /V (x) >>".into()); + let shared = fdf_with("/Fields [2 0 R]", &objects); + let read = data_values(&shared); + assert!(read.is_err(), "{} rows", read.map_or(0, |v| v.len())); + assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_)))); +} + +fn fdf_note(data: &str) -> String { + format!("<< /Subtype /Text /Rect [0 0 9 9] /NM (n) /Page 0 /PCData {data} >>") +} + +/// A comment property that refers back to the comment is copied once, not nested level after +/// level. +#[test] +fn an_fdf_comment_referring_to_itself_is_copied_once() { + let cyclic = fdf_with("/Annots [2 0 R]", &[fdf_note("2 0 R")]); + let mut dst = blank(); + assert_eq!(import(&mut dst, &cyclic).unwrap().comments, 1); + let placed = find_by_name(&dst, "n").unwrap(); + let data = dst.get(placed).as_dict().unwrap().get(b"PCData").cloned(); + assert!(!matches!(data, Some(Object::Dict(_))), "{data:?}"); +} + +/// A comment whose properties repeat one reference level after level is refused rather than +/// copied into millions of values. +#[test] +fn fdf_comment_graphs_that_repeat_references_are_bounded() { + let note = fdf_note; + let width = 6; + let mut objects = vec![note("3 0 R")]; + for i in 0..8 { + objects.push(format!("[{}]", vec![format!("{} 0 R", i + 4); width].join(" "))); + } + objects.push("(leaf)".into()); + let shared = fdf_with("/Annots [2 0 R]", &objects); + assert!(matches!(import(&mut blank(), &shared), Err(DataError::Malformed(_)))); +} + /// Line-ending names are attribute values: a name holding a quote or angle bracket cannot add /// attributes or elements to the exported XFDF. #[test]