fix(xfdf): bound what an FDF import copies out of repeated references

The FDF field walker and the comment copier followed every reference again
each time it appeared, so a field tree or comment whose kids or properties
repeat one reference level after level expanded a small file into millions
of rows or values before the depth limit stopped it. Both now skip a
reference back to an object they are inside of, and share one budget per
import of values and text; past it the import is refused.
This commit is contained in:
aiongg
2026-10-22 17:57:53 +00:00
parent 93436d41b0
commit b1196c5cbe
2 changed files with 161 additions and 17 deletions
+100 -17
View File
@@ -1198,18 +1198,66 @@ fn find_by_name(doc: &Document, nm: &str) -> Option<ObjRef> {
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<ObjRef>, budget: &mut ImportBudget) -> Result<Object, DataError> {
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::<Result<_, _>>()?),
Object::Dict(d) => {
Object::Dict(d.iter().map(|(k, v)| Ok((k.clone(), deep(src, v, depth + 1, path, budget)?))).collect::<Result<_, DataError>>()?)
}
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<String>)>, depth: usize) -> Result<(), DataError> {
@@ -1233,27 +1281,59 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec<Str
Ok(())
}
fn walk_fdf(fdf: &Document, list: &[Object], prefix: &str, out: &mut Vec<(String, Vec<String>)>, 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<String>)>,
depth: usize,
path: &mut Vec<ObjRef>,
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<String> = 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<Dict, DataError> {
@@ -1272,8 +1352,9 @@ pub fn data_values(bytes: &[u8]) -> Result<Vec<(String, Vec<String>)>, 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<Report, 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 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<ObjRef> = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect();
@@ -1360,7 +1442,8 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result<Report, DataError> {
// 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<ObjRef> = 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;
};
+61
View File
@@ -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<u8> {
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<String> = (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]