From 6513ece05ec46ce7be572aef318d173da94c836b Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sun, 24 May 2026 22:45:55 +0000 Subject: [PATCH] fix(js): make createHTMLDocument return a detached document (147) jQuery 3.7.x feature-detects via: var body = document.implementation.createHTMLDocument("").body; body.innerHTML = "
"; Obscura's implementation returned globalThis.document, so the live was wiped down to two anonymous
stubs as soon as jQuery loaded. Every page that used jQuery plus a navigation menu later in the script bundle (e.g. WordPress + GeneratePress + offside.js) then crashed: the `.slideout-navigation` selector matched zero elements, offside.js's constructor was called with `undefined` as the target, and `el.classList` threw. The visible symptom on affected pages was a 1-byte `--dump text` output and the stack trace from issue 147 in stderr. Switch to a new detached _IframeDocument (already used for srcdoc iframes), and create a child in the new head when a title arg is provided. New regression test asserts that the sandbox is NOT the live document and that the real <body> survives the innerHTML write. Verification on the reporter's URL: Before: 1 byte stdout, classList TypeError in stderr. After: 57838 bytes stdout, no script error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- crates/obscura-js/js/bootstrap.js | 15 +++++++- crates/obscura-js/src/runtime.rs | 58 +++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 15c84aaea..abe6c17e6 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -1042,7 +1042,20 @@ class Document extends Node { get activeElement() { return globalThis.__obscura_focused || this.body; } get implementation() { return { - createHTMLDocument(title) { return globalThis.document; }, + // Must return a NEW, DETACHED document. jQuery and others use this as a + // sandbox: `createHTMLDocument("").body.innerHTML = "<form></form>..."` + // for feature detection. Returning the live document made that wipe the + // real <body>, dropping all page content (see issue 147). + createHTMLDocument(title) { + const doc = new _IframeDocument('', 'about:blank', undefined); + if (title !== undefined) { + const titleEl = document.createElement('title'); + titleEl.textContent = String(title); + doc._head.appendChild(titleEl); + doc._title = String(title); + } + return doc; + }, createDocument() { return globalThis.document; }, hasFeature() { return true; }, }; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index f6e49dcd1..d8c962aa1 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -965,6 +965,64 @@ mod tests { assert_eq!(text, serde_json::json!("BODY_TEXT")); } + /// Regression test for #147: `document.implementation.createHTMLDocument` + /// must return a NEW, detached document. jQuery 3.7.x uses it as a + /// sandbox — `createHTMLDocument("").body.innerHTML = "<form></form>..."` + /// during feature detection. The previous implementation returned the + /// live `globalThis.document`, so that write wiped the real <body> down + /// to two `<form>` stubs — every page that loaded jQuery (e.g. WordPress + /// + GeneratePress + offside.js) collapsed to a 1-byte text dump because + /// `.slideout-navigation` no longer matched any element, then offside.js + /// dereferenced `undefined.classList`. + #[test] + fn create_html_document_returns_detached_document() { + let mut rt = setup_runtime( + "<html><body><nav id=keep>NAV_CONTENT</nav><p>PARA</p></body></html>", + ); + + let before_children = rt + .evaluate("document.body.children.length") + .unwrap() + .as_f64() + .unwrap(); + assert_eq!(before_children, 2.0, "baseline: <body> has nav + p"); + + // Reproduce the exact jQuery 3.7.1 feature-detect snippet that used + // to wipe the live body. + rt.execute_script( + "jquery-support-detect", + r#" + var sandbox = document.implementation.createHTMLDocument(""); + sandbox.body.innerHTML = "<form></form><form></form>"; + globalThis.__sandboxFormCount = sandbox.body.children.length; + globalThis.__sandboxIsLive = (sandbox === document); + "#, + ) + .unwrap(); + + // The sandbox must be a distinct document, not the live one. + let is_live = rt.evaluate("globalThis.__sandboxIsLive").unwrap(); + assert_eq!(is_live, serde_json::json!(false), + "createHTMLDocument must NOT return the live document"); + + // The sandbox itself received the two forms. + let sandbox_forms = rt.evaluate("globalThis.__sandboxFormCount").unwrap(); + assert_eq!(sandbox_forms, serde_json::json!(2.0)); + + // Critical assertion: the real <body> still has its original content. + let after_children = rt + .evaluate("document.body.children.length") + .unwrap() + .as_f64() + .unwrap(); + assert_eq!(after_children, 2.0, + "live <body> must be untouched by sandbox writes"); + let nav_text = rt + .evaluate("document.querySelector('#keep').textContent") + .unwrap(); + assert_eq!(nav_text, serde_json::json!("NAV_CONTENT")); + } + /// Regression for #105: `element.querySelector` and `querySelectorAll` /// must scope to the receiver's subtree, not the whole document. #[test]