xml: validate xml declaration

And now, because we do this, we no longer have to have the parser skip anything
that looks like a ProcessingInstruction.
This commit is contained in:
Karl Seguin committed 2026-09-23 17:34:10 +08:00
1 parent 414167bfcd
commit 79376d00fa
3 files changed
+48 -8

No files matched your search

-8
View File
@@ -123,13 +123,5 @@ pub fn xmlDocument(frame: *Frame, xml: []const u8) !?*Document.XMLDocument {
if (parser.err != null or parser.xml_error or doc_node.firstChild() == null) {
return null;
}
// If first node is a `ProcessingInstruction` (e.g. the <?xml?>
// declaration), skip it.
const first_child = doc_node.firstChild().?;
if (first_child.getNodeType() == 7) {
_ = try doc_node.removeChild(first_child, frame);
}
return doc;
}
+13
View File
@@ -521,6 +521,11 @@
'<a>&#0;</a>',
'<a><?1x?></a>', // invalid processing instruction target
'<?1x?><a/>',
'<?xml version="2.0"?><a/>', // only 1.x versions are accepted
'<?xml version="10.0"?><a/>',
'<?xml version="1."?><a/>',
'<?xml version=1.0?><a/>',
'<?xml encoding="UTF-8"?><a/>', // version is required
]) {
testing.expectEqual(bad + ' -> error', bad + (isError(p.parseFromString(bad, 'text/xml')) ? ' -> error' : ' -> ok'));
}
@@ -529,6 +534,8 @@
for (const good of [
'<a/>',
'<?xml version="1.0" encoding="UTF-8"?>\n<a/>\n',
"<?xml version = '1.1075' ?><a/>",
'<?xml version="1.000" standalone="yes"?><a/>',
'\uFEFF<a/>',
'<a><![CDATA[<x>]]><!-- c --><?pi d?></a><!-- trailing -->',
'<a b="x&quot;y&apos;&lt;&#169;"/>',
@@ -548,6 +555,12 @@
testing.expectEqual('svg', svg.doctype.name);
testing.expectEqual('-//W3C//DTD SVG 1.1//EN', svg.doctype.publicId);
testing.expectEqual('svg', svg.documentElement.localName);
// the XML declaration creates no node, a leading processing instruction does
const decl = p.parseFromString('<?xml version="1.0"?><?xml-stylesheet href="a.css"?><a/>', 'text/xml');
testing.expectEqual(2, decl.childNodes.length);
testing.expectEqual('xml-stylesheet', decl.firstChild.target);
testing.expectEqual('xml-stylesheet', p.parseFromString('<?xml-stylesheet href="a.css"?><a/>', 'text/xml').firstChild.target);
}
</script>
+35
View File
@@ -874,6 +874,17 @@ impl<'arena> xml5ever::tokenizer::TokenSink for UnclosedTagSink<'arena> {
.parse_error(std::borrow::Cow::Borrowed("Unclosed element at EOF"));
}
}
// The XML declaration isn't a processing instruction, so no node
// is created for it. xml5ever doesn't validate it.
Token::ProcessingInstruction(pi) if &*pi.target == "xml" => {
if !is_valid_xml_declaration(&pi.data) {
use xml5ever::tree_builder::TreeSink;
self.tb
.sink
.parse_error(std::borrow::Cow::Borrowed("Invalid XML declaration"));
}
return xml5ever::tokenizer::ProcessResult::Continue;
}
_ => {}
}
self.tb.process_token(token)
@@ -884,6 +895,30 @@ impl<'arena> xml5ever::tokenizer::TokenSink for UnclosedTagSink<'arena> {
}
}
// The declaration must start with `version="1.x"`: browsers accept any 1.x
// (XML 1.0 5th edition's VersionNum is `1.[0-9]+`) and reject everything else.
fn is_valid_xml_declaration(data: &str) -> bool {
fn trim(s: &str) -> &str {
s.trim_start_matches([' ', '\t', '\r', '\n'])
}
let Some(rest) = trim(data).strip_prefix("version").map(trim) else {
return false;
};
let Some(rest) = rest.strip_prefix('=').map(trim) else {
return false;
};
let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else {
return false;
};
let Some((version, _)) = rest[1..].split_once(quote) else {
return false;
};
match version.strip_prefix("1.") {
Some(minor) => !minor.is_empty() && minor.bytes().all(|b| b.is_ascii_digit()),
None => false,
}
}
// xml5ever::driver::XmlParser, minus the tree-builder-typed tokenizer so the
// UnclosedTagSink can sit in between.
struct XmlDocumentParser<'arena> {