diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 55161b718..798ca97bf 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -143,6 +143,11 @@ pub fn import( if depth > MAX_DEPTH { return Err(Error::TooDeep(MAX_DEPTH)); } + // FilmCraft's parser expands internal entities without a node limit (#2129); Final Cut Pro + // and FCPXML exports never declare any. + if matches!(format, TimelineFormat::Fcp7Xml | TimelineFormat::Fcpxml) && bytes.windows(8).any(|w| w == b" = std::result::Result; diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index 3ad7fcaae..1e0c7d417 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -520,6 +520,21 @@ fn export_survives_a_precomp_layer_pointing_at_a_non_comp() { assert!(matches!(export(&p, footage, &ExportOptions::default(), &HashMap::new()), Err(Error::NoComp(_)))); } +#[test] +fn xml_timelines_declaring_entities_are_refused() { + // #2129: the timeline parser expands internal entities without a node limit. + let dtd = "]>"; + let docs = [ + (TimelineFormat::Fcp7Xml, format!("{dtd}&b;")), + (TimelineFormat::Fcpxml, format!("{dtd}&b;")), + ]; + for (format, doc) in docs { + let mut p = Project::default(); + let r = import(&mut p, doc.as_bytes(), Some(format), &ImportOptions::default(), &mut |_| None); + assert!(matches!(r, Err(Error::XmlEntities)), "{format:?}"); + } +} + #[test] fn deeply_nested_documents_error_instead_of_overflowing_the_stack() { // A few hundred kilobytes nested 100 000 levels deep overflowed the parsers' stack. diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index d2720bb0d..95c00a91a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -1261,6 +1261,10 @@ fn xml_depth(text: &str) -> usize { max } +/// Nodes a document may expand to: a few kilobytes of nested internal entities +/// (``) otherwise expand to millions and exhaust memory. +const MAX_XML_NODES: u32 = 4_000_000; + /// Parse an SVG document. pub fn parse(bytes: &[u8]) -> Result { let text = String::from_utf8_lossy(bytes); @@ -1268,7 +1272,8 @@ pub fn parse(bytes: &[u8]) -> Result { if xml_depth(text) > MAX_XML_DEPTH { return Err(Error::Xml(format!("elements nested more than {MAX_XML_DEPTH} levels deep"))); } - let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + // DTDs stay allowed (Illustrator's SVGs declare entities), but expansion is bounded (#2129). + let opts = roxmltree::ParsingOptions { allow_dtd: true, nodes_limit: MAX_XML_NODES, ..Default::default() }; let xml = roxmltree::Document::parse_with_options(text, opts).map_err(|e| Error::Xml(e.to_string()))?; let root = xml.root_element(); if root.tag_name().name() != "svg" { diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 31c95e62f..06dfbd333 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -220,6 +220,18 @@ fn transforms() { assert!((p.x - 6.0).abs() < 1e-9 && (p.y - 7.0).abs() < 1e-9); } +#[test] +fn nested_entities_are_refused_instead_of_expanding_without_bound() { + // #2129: each top-level `&b;` stays under roxmltree's per-reference cap but adds 12 500 + // nodes, so 40 KB of references expands to 5 million. + let dtd = format!("", "".repeat(50), "&a;".repeat(250)); + let bomb = format!("{}", "&b;".repeat(400)); + assert!(matches!(crate::parse(bomb.as_bytes()), Err(crate::Error::Xml(_)))); + // An ordinary entity, as Illustrator declares them, still parses. + let ok = "]>"; + assert!(crate::parse(ok.as_bytes()).is_ok()); +} + #[test] fn deep_nesting_errors_instead_of_overflowing_the_stack() { // 100 000 nested groups overflowed the XML parser's stack and aborted the process.