mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 20:47:18 -06:00
SVG, FCP XML: bound XML entity expansion (#2129)
A few kilobytes of internal entities expanded to millions of nodes (each top-level reference stays under roxmltree's per-reference cap), so a 1 MB file exhausted memory. SVG keeps DTDs, which Illustrator's exports use for entities, but caps the expanded document at 4 million nodes. Final Cut Pro XML and FCPXML, which FilmCraft parses without a node limit, are refused when they declare an entity; real exports never do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
990fa668cd
commit
c70fccff99
@@ -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"<!ENTITY") {
|
||||
return Err(Error::XmlEntities);
|
||||
}
|
||||
let fc_opts = filmcraft_interchange::ImportOptions {
|
||||
base_dir: opts.base_dir.clone(),
|
||||
edl_frame_rate: opts.edl_frame_rate.map(|f| {
|
||||
|
||||
@@ -160,6 +160,8 @@ pub enum Error {
|
||||
NoComp(u64),
|
||||
#[error("the timeline document nests more than {0} levels deep")]
|
||||
TooDeep(usize),
|
||||
#[error("the timeline document declares XML entities, which timeline files don't use and which can expand without bound")]
|
||||
XmlEntities,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
@@ -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 = "<!DOCTYPE x [<!ENTITY a \"aaaaaaaaaa\"><!ENTITY b \"&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;\">]>";
|
||||
let docs = [
|
||||
(TimelineFormat::Fcp7Xml, format!("<?xml version=\"1.0\"?>{dtd}<xmeml version=\"5\">&b;</xmeml>")),
|
||||
(TimelineFormat::Fcpxml, format!("<?xml version=\"1.0\"?>{dtd}<fcpxml version=\"1.9\">&b;</fcpxml>")),
|
||||
];
|
||||
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.
|
||||
|
||||
@@ -1261,6 +1261,10 @@ fn xml_depth(text: &str) -> usize {
|
||||
max
|
||||
}
|
||||
|
||||
/// Nodes a document may expand to: a few kilobytes of nested internal entities
|
||||
/// (`<!ENTITY b "&a;&a;…">`) 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<Doc, Error> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
@@ -1268,7 +1272,8 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
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" {
|
||||
|
||||
@@ -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!("<!ENTITY a \"{}\"><!ENTITY b \"{}\">", "<g/>".repeat(50), "&a;".repeat(250));
|
||||
let bomb = format!("<?xml version=\"1.0\"?><!DOCTYPE svg [{dtd}]><svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>", "&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 = "<?xml version=\"1.0\"?><!DOCTYPE svg [<!ENTITY w \"40\">]><svg xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"&w;\" height=\"5\"/></svg>";
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user