From 53295faa59a33ed38ddec8d311ede61f7b978a58 Mon Sep 17 00:00:00 2001 From: Andrey Mnatsakanov Date: Sun, 26 Jul 2026 12:55:34 +0200 Subject: [PATCH] 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 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 . The check defaults to well-formed when ambiguous, so valid XML is never falsely flagged. Fixes #504 --- crates/obscura-js/js/bootstrap.js | 61 ++++++++++++++++++++++++++++++- crates/obscura-js/src/runtime.rs | 28 ++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 4ffed6c..a67543c 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -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 `` 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 (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 + 4); if (e === -1) return false; i = e + 3; continue; } + if (s.startsWith('', i + 9); if (e === -1) return false; i = e + 3; continue; } + if (s.startsWith('', i + 2); if (e === -1) return false; i = e + 2; continue; } + if (s.startsWith('', 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 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 = 'This page contains the following errors:
error while parsing XML
'; + } catch (e) { /* ignore */ } + } + // Helper: depth-first walk to find an element by predicate. const walk = (node, pred) => { if (!node) return null; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 03a12fc..8e01a72 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1676,6 +1676,34 @@ mod tests { ); } + #[test] + fn dom_parser_flags_malformed_xml_with_parsererror() { + let mut rt = setup_runtime(""); + let has_err = rt + .evaluate("(function(){var d=new DOMParser().parseFromString('','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(""); + let ok = rt + .evaluate("(function(){var d=new DOMParser().parseFromString('x','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(""); + let ok = rt + .evaluate("(function(){var d=new DOMParser().parseFromString('

hi','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("Test");