fix(js): surface a parsererror for malformed XML in DOMParser

DOMParser.parseFromString always HTML-parsed, even for XML mime types, so
malformed XML never produced the <parsererror> element Chrome emits and
error-detection code (doc.querySelector('parsererror')) never triggered. obscura
has no XML parser, so full XML semantics stay out of scope; instead, for XML
mime types run a conservative well-formedness check (tag balance, single root,
terminated comment/CDATA/PI, skipping quoted attribute regions) and, on a clear
error, make the document surface a <parsererror>. The check defaults to
well-formed when ambiguous, so valid XML is never falsely flagged.

Fixes #504
This commit is contained in:
Andrey Mnatsakanov committed 2026-08-04 08:43:45 +02:00
1 parent f0541f40a4
commit 53295faa59
2 files changed
+88 -1

No files matched your search

+60 -1
View File
@@ -25,7 +25,7 @@
'_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase',
'_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth',
'_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber',
'_htmlAttrName', '_convertNodes', '_parseHTMLFragment', '_elementClassFor', '_wrap', '_wrapEl',
'_htmlAttrName', '_convertNodes', '_parseHTMLFragment', '_xmlWellFormed', '_elementClassFor', '_wrap', '_wrapEl',
'_resolveUrl', '_registerIframe', '_base64ToUint8Array',
'_bodyToUint8Array', '_arrayBufferFromBytes',
'_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp',
@@ -5464,6 +5464,56 @@ if (typeof URLSearchParams === "undefined") globalThis.URLSearchParams = class U
// input into a detached `<html>` element and wrap it so the common Document
// API surface (body / head / documentElement / querySelector* / getElementById /
// getElementsByTagName / getElementsByClassName / title / cloneNode) works.
// Conservative XML well-formedness check. obscura has no XML parser, so this
// only decides whether to surface a <parsererror> (it does not build an XML
// tree). It flags clear structural errors — mismatched or unclosed tags,
// multiple/no root elements, unterminated comment/CDATA/PI — and defaults to
// "well-formed" whenever the scan is ambiguous, so valid XML is never falsely
// flagged. Quoted attribute regions, comments, CDATA, PIs and the doctype are
// skipped; a literal '<' in text (invalid in XML) reads as a bad tag.
function _xmlWellFormed(src) {
const s = String(src);
const stack = [];
let rootsClosed = 0; // top-level elements fully closed (or self-closed)
let i = 0;
const n = s.length;
while (i < n) {
const lt = s.indexOf('<', i);
if (lt === -1) break;
i = lt;
if (s.startsWith('<!--', i)) { const e = s.indexOf('-->', i + 4); if (e === -1) return false; i = e + 3; continue; }
if (s.startsWith('<![CDATA[', i)) { const e = s.indexOf(']]>', i + 9); if (e === -1) return false; i = e + 3; continue; }
if (s.startsWith('<?', i)) { const e = s.indexOf('?>', i + 2); if (e === -1) return false; i = e + 2; continue; }
if (s.startsWith('<!', i)) { const e = s.indexOf('>', i + 2); if (e === -1) return false; i = e + 1; continue; }
// A start/end/self-closing tag: find its '>' while skipping quoted regions.
let j = i + 1, quote = null;
while (j < n) {
const c = s[j];
if (quote) { if (c === quote) quote = null; }
else if (c === '"' || c === "'") quote = c;
else if (c === '>') break;
j++;
}
if (j >= n) return false; // unterminated tag
const inner = s.slice(i + 1, j).trim();
i = j + 1;
if (!inner) return false;
if (inner[0] === '/') {
const name = inner.slice(1).trim().split(/\s/)[0];
if (stack.length === 0 || stack[stack.length - 1] !== name) return false;
stack.pop();
if (stack.length === 0) rootsClosed++;
} else if (inner[inner.length - 1] === '/') {
if (stack.length === 0) rootsClosed++;
} else {
const name = inner.split(/\s/)[0];
if (!name) return false;
stack.push(name);
}
}
return stack.length === 0 && rootsClosed === 1;
}
globalThis.DOMParser = class DOMParser {
parseFromString(source, mimeType) {
const html = String(source ?? "");
@@ -5475,6 +5525,15 @@ globalThis.DOMParser = class DOMParser {
// children, which is what callers want.
try { root.innerHTML = html; } catch (e) { /* leave empty on parse error */ }
// For XML mime types, surface a <parsererror> on clearly-malformed input so
// error-detection code (doc.querySelector('parsererror')) works, matching
// Chrome. obscura has no XML parser, so the tree stays HTML-parsed.
if (isXml && !_xmlWellFormed(html)) {
try {
root.innerHTML = '<parsererror xmlns="http://www.w3.org/1999/xhtml">This page contains the following errors:<div>error while parsing XML</div></parsererror>';
} catch (e) { /* ignore */ }
}
// Helper: depth-first walk to find an element by predicate.
const walk = (node, pred) => {
if (!node) return null;
+28
View File
@@ -1676,6 +1676,34 @@ mod tests {
);
}
#[test]
fn dom_parser_flags_malformed_xml_with_parsererror() {
let mut rt = setup_runtime("<html><body></body></html>");
let has_err = rt
.evaluate("(function(){var d=new DOMParser().parseFromString('<a><b></a>','application/xml'); return d.querySelector('parsererror') ? true : false;})()")
.unwrap();
assert_eq!(has_err, serde_json::json!(true));
}
#[test]
fn dom_parser_accepts_well_formed_xml() {
let mut rt = setup_runtime("<html><body></body></html>");
let ok = rt
.evaluate("(function(){var d=new DOMParser().parseFromString('<root><child>x</child></root>','application/xml'); return d.querySelector('parsererror') ? 'ERR' : 'OK';})()")
.unwrap();
assert_eq!(ok, serde_json::json!("OK"));
}
#[test]
fn dom_parser_html_never_gets_parsererror() {
// HTML parsing is tolerant and must never synthesize a parsererror.
let mut rt = setup_runtime("<html><body></body></html>");
let ok = rt
.evaluate("(function(){var d=new DOMParser().parseFromString('<div><p>hi</a>','text/html'); return d.querySelector('parsererror') ? 'ERR' : 'OK';})()")
.unwrap();
assert_eq!(ok, serde_json::json!("OK"));
}
#[test]
fn test_document_title() {
let mut rt = setup_runtime("<html><head><title>Test</title></head><body></body></html>");