PDF import: an XMP packet nested more than 256 deep is skipped instead of overflowing the stack

When a PDF, PDF-compatible .ai or .ait opens, crates/pdf/src/file_info.rs
reads its File Info from the XMP packet with roxmltree, which parses
nested elements recursively and has no limit on their depth. A packet
with elements nested 10,000 deep (70 KB) overflowed a 1 MB stack, and
one nested 100,000 deep (700 KB) overflowed an 8 MB stack. A stack
overflow aborts the app.

File Info now counts how deep the packet's elements nest before roxmltree
parses it, and leaves out a packet nested more than 256 deep, as it
leaves out a packet that is not XML. The document information
dictionary then gives the File Info.
This commit is contained in:
dotCooCoo
2026-10-18 12:15:31 +07:00
committed by bflatastic
parent bdf6059155
commit 830cefcaf6
4 changed files with 80 additions and 4 deletions
+52 -1
View File
@@ -11,6 +11,8 @@ use crate::import_scan::text_string;
/// The longest XMP packet read, in bytes once decoded; a longer one is left out.
const MAX_XMP: usize = 8 << 20;
/// The deepest the elements of an XMP packet may nest; a packet nested deeper is left out.
const MAX_NESTING: usize = 256;
const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
const XML: &str = "http://www.w3.org/XML/1998/namespace";
@@ -94,9 +96,14 @@ fn packet(pdf: &Pdf) -> Option<Vec<u8>> {
(data.len() <= MAX_XMP).then(|| data.into_owned())
}
/// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16.
/// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16, or when its
/// elements nest more than [`MAX_NESTING`] deep.
fn xmp(packet: &[u8]) -> Option<FileInfo> {
let text = xml_text(packet)?;
// roxmltree parses nested elements recursively.
if nesting(&text) > MAX_NESTING {
return None;
}
let xml = roxmltree::Document::parse(&text).ok()?;
// The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value.
let descriptions: Vec<Node<'_, '_>> = xml
@@ -139,6 +146,50 @@ fn xml_text(b: &[u8]) -> Option<String> {
}
}
/// How deep the elements of `xml` nest: start tags less end tags, at most, outside comments,
/// CDATA sections, processing instructions, declarations and quoted attribute values.
fn nesting(xml: &str) -> usize {
let b = xml.as_bytes();
let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize);
// The index just past the first `end` at or after `from` (the end of the text if none).
let past = |from: usize, end: &[u8]| {
b.get(from..).and_then(|rest| rest.windows(end.len()).position(|w| w == end)).map_or(b.len(), |at| from + at + end.len())
};
while let Some(at) = b.get(i..).and_then(|rest| rest.iter().position(|&c| c == b'<')) {
let start = i + at;
let rest = b.get(start..).unwrap_or_default();
if rest.starts_with(b"<!--") {
i = past(start + 4, b"-->");
} else if rest.starts_with(b"<![CDATA[") {
i = past(start + 9, b"]]>");
} else if rest.starts_with(b"<?") {
i = past(start + 2, b"?>");
} else if rest.starts_with(b"<!") || rest.starts_with(b"</") {
depth = depth.saturating_sub(usize::from(rest.starts_with(b"</")));
i = past(start + 2, b">");
} else {
// A start tag: up to its `>` outside quotes; `/>` closes it at once.
let mut j = start + 1;
let mut quote = None;
while let Some(&c) = b.get(j) {
match (quote, c) {
(None, b'"' | b'\'') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None, b'>') => break,
_ => {}
}
j += 1;
}
if b.get(j.saturating_sub(1)) != Some(&b'/') {
depth += 1;
most = most.max(depth);
}
i = j + 1;
}
}
most
}
/// The values of property `ns` `name` on the first of `descriptions` that gives it: an attribute
/// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out.
fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String> {
+21
View File
@@ -173,6 +173,27 @@ fn an_unreadable_xmp_packet_leaves_the_information_dictionary() {
assert_eq!(file_info(b"%PDF-1.7\nnot a PDF", None), None);
}
#[test]
fn an_xmp_packet_nested_too_deep_leaves_the_information_dictionary() {
// The creator's `rdf:li` is 6 deep (`x:xmpmeta`, `rdf:RDF`, `rdf:Description`, `dc:creator`,
// `rdf:Seq`); `n` elements around its text nest the packet 6 + `n` deep.
let nested = |n: usize| {
let creator = format!("<dc:creator><rdf:Seq><rdf:li>{}Ada Lovelace{}</rdf:li></rdf:Seq></dc:creator>", "<a>".repeat(n), "</a>".repeat(n));
packet(&description("", &creator))
};
let author = |xmp: &str| file_info(&pdf(Some(INFO), Some(xmp)), None).unwrap().author;
assert_eq!(author(&nested(250)).as_deref(), Some("Ada Lovelace"));
assert_eq!(author(&nested(251)).as_deref(), Some("Grace Hopper"));
// A million deep: 7 MB, within the longest packet read.
let f = file_info(&pdf(Some(INFO), Some(&nested(1_000_000))), None).unwrap();
assert_eq!(f.author.as_deref(), Some("Grace Hopper"));
assert_eq!((f.created, f.modified), (Some(CREATED), Some(MODIFIED)));
// Tags in comments, CDATA sections and processing instructions don't count.
let tags = "<a>".repeat(300);
let creator = format!("<!-- {tags} --><dc:creator><![CDATA[{tags}]]><?pi {tags}?><rdf:Seq><rdf:li>Ada Lovelace</rdf:li></rdf:Seq></dc:creator>");
assert_eq!(author(&packet(&description("", &creator))).as_deref(), Some("Ada Lovelace"));
}
#[test]
fn a_written_pdf_gives_its_file_info_back() {
let mut d = Document::new(100.0, 100.0);