mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
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:
+100
-17
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user