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
+6 -3
View File
@@ -823,8 +823,8 @@ fn the_file_info_sample_reaches_the_reader() {
proptest! {
#![proptest_config(config())]
/// File Info read on open: mutated XMP packets and junk information dictionaries, opened as a
/// PDF and as a `.ai`.
/// File Info read on open: mutated XMP packets, some with elements nested deeply, and junk
/// information dictionaries, opened as a PDF and as a `.ai`.
#[test]
fn pdf_file_info_never_panics(
cut in 0usize..2_000,
@@ -836,8 +836,11 @@ proptest! {
"/Subject <FEFF00> /Author <FEFFD800> /Keywords <EFBBBF> /Title (\\377\\376)",
"/CreationDate (D:00000101000000Z) /ModDate (D:99991231235959+23'59')",
]),
nest in prop::sample::select(vec![0usize, 0, 0, 251, 100_000]),
) {
let pdf = file_info_pdf(info, &mutate_text(RICH_XMP, cut, &edits));
// `nest` elements nested in one another around the packet.
let xmp = format!("{}{}{}", "<a>".repeat(nest), mutate_text(RICH_XMP, cut, &edits), "</a>".repeat(nest));
let pdf = file_info_pdf(info, &xmp);
let b64 = vectorcraft_format::base64_encode(&pdf);
let r = catch_quiet(|| {
let _ = vectorcraft_pdf::file_info(&pdf, None);
+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);
+1
View File
@@ -706,6 +706,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
- A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`).
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests_file_info.rs`).
- `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets.
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests_textimport.rs`).