diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index 78f2ad55d..1c07eb4b2 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1200,6 +1200,8 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod .collect(); // The signed revision's document-level XMP stream. let old_metadata = old.root().and_then(|root| old.get(root).as_dict().and_then(|c| c.get(b"Metadata").and_then(Object::as_ref))); + // The signed revision's XFA data packets: filling the form rewrites them. + let old_xfa_data = xfa_data_packets(&old); // What each object was in the signed revision: its pages, and its trailer /Info. A changed // object is judged by the role it held when signed, never by the labels or keys its new, // attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its @@ -1295,6 +1297,12 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // else, even one it already had when signed (a page's Form XObject: the // renderer ignores /Type), does not make rewriting it a metadata change. kind::METADATA + } else if old_xfa_data.contains(&r) { + // The form's XFA data (`datasets`) or state (`form`) as the signed revision + // named it: filling an XFA form rewrites these, as a Reader Extensions (UR3) + // save of a certified form does (#2759). The role comes from the signed + // revision, so a later one can't name page content as XFA data. + kind::FORM_FILL } else { kind::OTHER } @@ -1318,6 +1326,21 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +/// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form` +/// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one +/// XFA stream holds its template too, and is not listed. +fn xfa_data_packets(doc: &Document) -> HashSet { + let Some(form) = acroform(doc) else { return HashSet::new() }; + let Some(xfa) = form.get(b"XFA").map(|x| doc.resolve(x)) else { return HashSet::new() }; + let Object::Array(xfa) = &*xfa else { return HashSet::new() }; + xfa.as_chunks::<2>() + .0 + .iter() + .filter(|[name, _]| doc.resolve(name).as_string().is_some_and(|s| matches!(s.to_text().as_str(), "datasets" | "form"))) + .filter_map(|[_, packet]| packet.as_ref()) + .collect() +} + /// Where an object sits in the Document Security Store (ISO 32000-2 §12.8.4.3). #[derive(Clone, Copy, PartialEq, Eq)] enum DssRole { diff --git a/crates/sign/tests/pdf.rs b/crates/sign/tests/pdf.rs index 908a5c0fe..75d2b5505 100644 --- a/crates/sign/tests/pdf.rs +++ b/crates/sign/tests/pdf.rs @@ -13,14 +13,20 @@ fn data(name: &str) -> Vec { /// One page with text and an unsigned signature field "Approval". fn fixture() -> Vec { - let objs: Vec<&[u8]> = vec![ - b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", + fixture_with(b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>", &[]) +} + +/// [`fixture`] with its catalog replaced by `catalog` and `more` objects from number 7 on. +fn fixture_with(catalog: &[u8], more: &[&[u8]]) -> Vec { + let mut objs: Vec<&[u8]> = vec![ + catalog, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>", b"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>", ]; + objs.extend_from_slice(more); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -749,6 +755,59 @@ fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() { } } +/// A form whose AcroForm carries its XFA as packets: template (7), datasets (8), form (9). +fn xfa_fixture() -> Vec { + fixture_with( + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /XFA [(template) 7 0 R (datasets) 8 0 R (form) 9 0 R] >> >>", + &[ + b"<< /Length 11 >>\nstream\n