From d2dd0400bbfaa369d3baacd365c77a8e5fcd3f95 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 2 Sep 2026 06:55:58 +0800 Subject: [PATCH] fix(xml): expand internal subset entities in DOMParser --- moli-parser/src/lib.rs | 1 + moli-parser/src/xml.rs | 6 +- moli-parser/src/xml_preprocess.rs | 370 ++++++++++++++++++ .../tests/dom_xhr/dom/element_construction.rs | 29 ++ 4 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 moli-parser/src/xml_preprocess.rs diff --git a/moli-parser/src/lib.rs b/moli-parser/src/lib.rs index 1704bcf8a6..0d2675a753 100644 --- a/moli-parser/src/lib.rs +++ b/moli-parser/src/lib.rs @@ -11,6 +11,7 @@ mod session; mod stream; mod stylesheet_blocking; mod xml; +mod xml_preprocess; mod xml_stream; mod xml_tree_viewer; diff --git a/moli-parser/src/xml.rs b/moli-parser/src/xml.rs index 83a04ba518..ac34303af0 100644 --- a/moli-parser/src/xml.rs +++ b/moli-parser/src/xml.rs @@ -18,7 +18,7 @@ use xmlparser::{ ElementEnd as XmlElementEnd, Token as XmlTokenizerToken, Tokenizer as XmlTokenizer, }; -use super::{html_chunks, xml_tree_viewer::transform_document_to_xml_tree_view}; +use super::{html_chunks, xml_preprocess::prepare_xml_for_xml5ever, xml_tree_viewer::transform_document_to_xml_tree_view}; use moli_dom::native::{ Attribute as NativeAttribute, DomHost, NativeDom, NativeNodeId, Node, ParserConstruction, }; @@ -114,6 +114,7 @@ impl XmlParser { xml: String, present_unstyled_xml: bool, ) -> NativeDom { + let xml = prepare_xml_for_xml5ever(&xml); let prepass_error = xml_element_stack_error(&xml); let sink = XmlDocumentSink::new(XmlLiveTreeSinkTarget::new_owned(final_url)); let mut parser = parse_xml_document(sink, XmlParseOpts::default()); @@ -155,7 +156,8 @@ impl XmlParser { let target = XmlLiveTreeSinkTarget::new_borrowed(dom_host, document_handle)?; let sink = XmlDocumentSink::new(target); let mut parser = parse_xml_document(sink, XmlParseOpts::default()); - for chunk in html_chunks(xml) { + let xml = prepare_xml_for_xml5ever(xml); + for chunk in html_chunks(&xml) { parser.process(XmlStrTendril::from(chunk)); } parser.finish().finish_live_tree(); diff --git a/moli-parser/src/xml_preprocess.rs b/moli-parser/src/xml_preprocess.rs new file mode 100644 index 0000000000..0a24689536 --- /dev/null +++ b/moli-parser/src/xml_preprocess.rs @@ -0,0 +1,370 @@ +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, + ops::Range, + rc::Rc, +}; + +use xmlparser::{ + ElementEnd, EntityDefinition, Token as XmlTokenizerToken, Tokenizer as XmlTokenizer, +}; + +const MAX_ENTITY_NESTING_DEPTH: usize = 32; +const MAX_ENTITY_EXPANSION_BYTES: usize = 1024 * 1024; + +struct ExpansionCandidate { + range: Range, + attribute_quote: Option, +} + +struct Edit { + range: Range, + replacement: String, +} + +/// xml5ever rejects every internal DTD subset before it reaches declared +/// general entities. Translate the deliberately narrow subset that Moli can +/// account for into input xml5ever already understands. Unsupported subsets +/// stay untouched so the normal XML parsererror path remains authoritative. +pub(super) fn prepare_xml_for_xml5ever(source: &str) -> Cow<'_, str> { + try_prepare_xml_for_xml5ever(source).map_or(Cow::Borrowed(source), Cow::Owned) +} + +fn try_prepare_xml_for_xml5ever(source: &str) -> Option { + let mut subset_content_start = None; + let mut subset_content_end = None; + let mut subset_token_ranges = Vec::new(); + let mut declarations = HashMap::new(); + let mut candidates = Vec::new(); + let mut inside_subset = false; + let mut element_depth = 0usize; + + for token in XmlTokenizer::from(source) { + let token = token.ok()?; + match token { + XmlTokenizerToken::DtdStart { span, .. } => { + if inside_subset || subset_content_start.is_some() { + return None; + } + inside_subset = true; + subset_content_start = Some(span.end()); + } + XmlTokenizerToken::EntityDeclaration { + name, + definition, + span, + } if inside_subset => { + subset_token_ranges.push(span.range()); + if is_parameter_entity_declaration(span.as_str()) + || is_predefined_entity(name.as_str()) + { + return None; + } + let EntityDefinition::EntityValue(value) = definition else { + return None; + }; + if value.as_str().contains('%') { + return None; + } + declarations + .entry(name.as_str().to_owned()) + .or_insert_with(|| value.as_str().to_owned()); + } + XmlTokenizerToken::Comment { span, .. } + | XmlTokenizerToken::ProcessingInstruction { span, .. } + if inside_subset => + { + subset_token_ranges.push(span.range()); + } + XmlTokenizerToken::DtdEnd { span } => { + if !inside_subset || subset_content_end.is_some() { + return None; + } + inside_subset = false; + subset_content_end = Some(span.start()); + } + XmlTokenizerToken::Attribute { value, .. } if !inside_subset => { + let quote = value + .start() + .checked_sub(1) + .and_then(|index| source.as_bytes().get(index)) + .copied()?; + if !matches!(quote, b'\'' | b'"') { + return None; + } + candidates.push(ExpansionCandidate { + range: value.range(), + attribute_quote: Some(quote), + }); + } + XmlTokenizerToken::Text { text } if !inside_subset && element_depth > 0 => { + candidates.push(ExpansionCandidate { + range: text.range(), + attribute_quote: None, + }); + } + XmlTokenizerToken::ElementEnd { end, .. } if !inside_subset => match end { + ElementEnd::Open => element_depth = element_depth.checked_add(1)?, + ElementEnd::Close(..) => element_depth = element_depth.saturating_sub(1), + ElementEnd::Empty => {} + }, + _ => {} + } + } + + if inside_subset { + return None; + } + let content_start = subset_content_start?; + let content_end = subset_content_end?; + if content_start > content_end { + return None; + } + + subset_token_ranges.sort_unstable_by_key(|range| range.start); + let mut cursor = content_start; + for range in &subset_token_ranges { + if range.start < cursor + || range.end > content_end + || !source[cursor..range.start].bytes().all(is_xml_space) + { + return None; + } + cursor = range.end; + } + if !source[cursor..content_end].bytes().all(is_xml_space) { + return None; + } + + let mut resolver = EntityResolver::new(declarations); + let mut inserted_expansion_bytes = 0usize; + let mut edits = vec![Edit { + range: content_start.checked_sub(1)?..content_end.checked_add(1)?, + replacement: String::new(), + }]; + + for candidate in candidates { + let fragment = &source[candidate.range.clone()]; + let Some(expanded) = resolver.expand_document_fragment( + fragment, + candidate.attribute_quote, + &mut inserted_expansion_bytes, + )? + else { + continue; + }; + edits.push(Edit { + range: candidate.range, + replacement: expanded, + }); + } + + edits.sort_unstable_by_key(|edit| std::cmp::Reverse(edit.range.start)); + let mut prepared = source.to_owned(); + let mut following_start = source.len(); + for edit in edits { + if edit.range.end > following_start { + return None; + } + following_start = edit.range.start; + prepared.replace_range(edit.range, &edit.replacement); + } + Some(prepared) +} + +fn is_parameter_entity_declaration(span: &str) -> bool { + span.strip_prefix(" bool { + matches!(name, "amp" | "lt" | "gt" | "apos" | "quot") +} + +fn is_xml_space(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r') +} + +struct EntityResolver { + declarations: HashMap, + cache: HashMap>, + failed: HashSet, + cached_expansion_bytes: usize, +} + +impl EntityResolver { + fn new(declarations: HashMap) -> Self { + Self { + declarations, + cache: HashMap::new(), + failed: HashSet::new(), + cached_expansion_bytes: 0, + } + } + + fn expand_document_fragment( + &mut self, + fragment: &str, + attribute_quote: Option, + inserted_expansion_bytes: &mut usize, + ) -> Option> { + let mut output = String::with_capacity(fragment.len()); + let mut cursor = 0; + let mut changed = false; + + while let Some(relative_ampersand) = fragment[cursor..].find('&') { + let ampersand = cursor + relative_ampersand; + output.push_str(&fragment[cursor..ampersand]); + let Some(relative_semicolon) = fragment[ampersand + 1..].find(';') else { + output.push_str(&fragment[ampersand..]); + cursor = fragment.len(); + break; + }; + let semicolon = ampersand + 1 + relative_semicolon; + let name = &fragment[ampersand + 1..semicolon]; + if self.declarations.contains_key(name) { + let replacement = self.resolve(name, &mut Vec::new())?; + let replacement = escape_attribute_quote(&replacement, attribute_quote); + *inserted_expansion_bytes = + inserted_expansion_bytes.checked_add(replacement.len())?; + if *inserted_expansion_bytes > MAX_ENTITY_EXPANSION_BYTES { + return None; + } + output.push_str(&replacement); + changed = true; + } else { + output.push_str(&fragment[ampersand..=semicolon]); + } + cursor = semicolon + 1; + } + output.push_str(&fragment[cursor..]); + + Some(changed.then_some(output)) + } + + fn resolve(&mut self, name: &str, stack: &mut Vec) -> Option> { + if let Some(cached) = self.cache.get(name) { + return Some(Rc::clone(cached)); + } + if self.failed.contains(name) + || stack.len() >= MAX_ENTITY_NESTING_DEPTH + || stack.iter().any(|ancestor| ancestor == name) + { + self.failed.insert(name.to_owned()); + return None; + } + + let raw = self.declarations.get(name)?.to_owned(); + stack.push(name.to_owned()); + let expanded = self.expand_entity_value(&raw, stack); + stack.pop(); + let Some(expanded) = expanded else { + self.failed.insert(name.to_owned()); + return None; + }; + + let cached_expansion_bytes = self.cached_expansion_bytes.checked_add(expanded.len())?; + if cached_expansion_bytes > MAX_ENTITY_EXPANSION_BYTES { + self.failed.insert(name.to_owned()); + return None; + } + self.cached_expansion_bytes = cached_expansion_bytes; + let expanded: Rc = Rc::from(expanded); + self.cache.insert(name.to_owned(), Rc::clone(&expanded)); + Some(expanded) + } + + fn expand_entity_value(&mut self, value: &str, stack: &mut Vec) -> Option { + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + + while let Some(relative_ampersand) = value[cursor..].find('&') { + let ampersand = cursor + relative_ampersand; + push_bounded(&mut output, &value[cursor..ampersand])?; + let Some(relative_semicolon) = value[ampersand + 1..].find(';') else { + push_bounded(&mut output, &value[ampersand..])?; + cursor = value.len(); + break; + }; + let semicolon = ampersand + 1 + relative_semicolon; + let reference = &value[ampersand + 1..semicolon]; + if self.declarations.contains_key(reference) { + let replacement = self.resolve(reference, stack)?; + push_bounded(&mut output, &replacement)?; + } else { + push_bounded(&mut output, &value[ampersand..=semicolon])?; + } + cursor = semicolon + 1; + } + push_bounded(&mut output, &value[cursor..])?; + Some(output) + } +} + +fn escape_attribute_quote(replacement: &str, quote: Option) -> Cow<'_, str> { + match quote { + Some(b'\'') if replacement.contains('\'') => { + Cow::Owned(replacement.replace('\'', "'")) + } + Some(b'"') if replacement.contains('"') => Cow::Owned(replacement.replace('"', """)), + _ => Cow::Borrowed(replacement), + } +} + +fn push_bounded(output: &mut String, fragment: &str) -> Option<()> { + if output.len().checked_add(fragment.len())? > MAX_ENTITY_EXPANSION_BYTES { + return None; + } + output.push_str(fragment); + Some(()) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use super::prepare_xml_for_xml5ever; + + #[test] + fn expands_general_entities_and_removes_the_internal_subset() { + let source = concat!( + "", + "", + "&b;\">", + "", + "]>", + "&node;" + ); + + assert_eq!( + prepare_xml_for_xml5ever(source), + "AB" + ); + } + + #[test] + fn uses_the_first_duplicate_entity_declaration() { + let source = "]>&x;"; + + assert_eq!( + prepare_xml_for_xml5ever(source), + "first" + ); + } + + #[test] + fn leaves_unsupported_or_recursive_subsets_for_the_xml_parser() { + for source in [ + "]>", + "]>", + "]>", + "]>&loop;", + ] { + assert!(matches!( + prepare_xml_for_xml5ever(source), + Cow::Borrowed(unchanged) if unchanged == source + )); + } + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/element_construction.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/element_construction.rs index 2c97882815..e87660da29 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/element_construction.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom/element_construction.rs @@ -412,6 +412,35 @@ fn dom_parser_rejects_public_doctype_without_system_literal() { assert_eq!(result, "true|true|true|true"); } +#[test] +fn dom_parser_expands_entities_from_an_internal_subset() { + let mut vm = new_storage_test_vm("https://dom-parser-internal-subset.test/"); + + let result = vm + .eval( + r#" +(() => { + const doc = new DOMParser().parseFromString( + ' ]>&x;', + 'text/xml' + ); + const serializer = new XMLSerializer(); + return [ + doc.doctype.name, + doc.doctype.publicId, + doc.doctype.systemId, + serializer.serializeToString(doc.documentElement), + serializer.serializeToString(doc.doctype), + doc.getElementsByTagName('parsererror').length + ].join('|'); +})() +"#, + ) + .expect("DOMParser internal subset entity should evaluate"); + + assert_eq!(result, "foo|||y||0"); +} + #[test] fn xhtml_element_interface_survives_move_through_xml_document() { let mut vm = new_storage_test_vm("https://xhtml-xml-document-move.test/");