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 = "
..."` + // for feature detection. Returning the live document made that wipe the + // real , 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 = "..."` + /// during feature detection. The previous implementation returned the + /// live `globalThis.document`, so that write wiped the real down + /// to two `"; + 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 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 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]