Skip to content

fix(js): make createHTMLDocument return a detached document (147)#183

Open
ousamabenyounes wants to merge 1 commit into
h4ckf0r0day:mainfrom
ousamabenyounes:fix/issue-147
Open

fix(js): make createHTMLDocument return a detached document (147)#183
ousamabenyounes wants to merge 1 commit into
h4ckf0r0day:mainfrom
ousamabenyounes:fix/issue-147

Conversation

@ousamabenyounes
Copy link
Copy Markdown
Contributor

@ousamabenyounes ousamabenyounes commented May 24, 2026

Summary

Fixes issue 147. document.implementation.createHTMLDocument(title) returned the live globalThis.document. jQuery 3.7.x feature-detects via that API and writes <form></form><form></form> into the returned document's body, so the live <body> was wiped down to two anonymous <form> stubs as soon as jQuery loaded. The classList TypeError from offside.min.js is a downstream symptom of the same root cause: with the body wiped, .slideout-navigation matched zero elements and offside.js's OffsideInstance constructor was called with undefined as the target.

Root Cause

crates/obscura-js/js/bootstrap.js, line 1045:

get implementation() {
  return {
    createHTMLDocument(title) { return globalThis.document; },  // <-- wrong
    ...
  };
}

Spec: createHTMLDocument must return a new, detached Document. jQuery's src/selector.js and support module rely on that detachment:

// jquery-3.7.1.js : line 10131
var body = document.implementation.createHTMLDocument( "" ).body;
body.innerHTML = "<form></form><form></form>";

With Obscura returning the live document, that innerHTML = assignment cleared the real <body>. Snapshot taken at runtime before the patch:

[BODY-PRE-JQUERY]  51 children: NOSCRIPT, A.skip-link, NAV#site-navigation, DIV#ocs-site, ..., NAV#generate-slideout-menu.main-navigation slideout-navig, ..., SCRIPT, IFRAME
[BODY-POST-JQUERY]  2 children: FORM, FORM

After the wipe, every later script that queried the page (offside.js for .slideout-navigation, GP menu plugin for .main-navigation, WP-Rocket lazy-loader for image placeholders) saw an empty document and either crashed or no-op'd. --dump text collapsed to 1 byte.

Fix

Return a fresh _IframeDocument (the same detached-document class already used for <iframe srcdoc> and similar). Create a <title> element in the new document's head when the caller passes a title argument.

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;
}

_IframeDocument already constructs an <html><head></head><body></body></html> tree via document.createElement(...) calls; the resulting nodes are real DOM nodes but they are detached from document.body, so innerHTML writes on the sandbox's body never reach the live tree.

createDocument (XML) is left untouched in this change — it has the same problem in principle but no caller in the reproducer exercised it; treating it the same way is a separate cleanup.

Verification

Reporter's command, before the patch:

$ obscura fetch https://vacuumwars.com/best-march-2026-robot-vacuums/ --dump text --timeout 25 --quiet | wc -c
1
$ obscura fetch https://vacuumwars.com/best-march-2026-robot-vacuums/ --dump text --timeout 25
WARN obscura_browser::page: Script error (https://vacuumwars.com/wp-content/plugins/gp-premium/menu-plus/functions/js/offside.min.js?ver=2.5.5): JS error: TypeError: Cannot read properties of undefined (reading 'classList')
    at h (<script>:1:306)
    at new l (<script>:1:2592)
    at Object.getOffsideInstance (<script>:1:2901)
    at getInstance (<script>:1:133)
    at <script>:1:3089

After the patch:

$ obscura fetch https://vacuumwars.com/best-march-2026-robot-vacuums/ --dump text --timeout 25 --quiet | wc -c
57838
$ obscura fetch https://vacuumwars.com/best-march-2026-robot-vacuums/ --dump text --timeout 25 2>&1 >/dev/null | grep -c classList
0

No classList TypeError in stderr; full article body is dumped to stdout.

The 3 other reporter URLs (/vacuum-wars-best-robot-vacuums/, /top-rated-robot-vacuums-and-three-to-never-get/, /roborock-reviews/) all use the same GP-Premium + jQuery + offside.js stack and recover the same way.

Test plan

  • cargo build --release clean (no new warnings)
  • cargo check -p obscura-cli clean
  • cargo test -p obscura-js — 86 pass (baseline 85, +1 new regression test)
  • cargo test -p obscura-dom — 31 pass (no change)
  • cargo test -p obscura-browser — 3 pass (no change)
  • New test create_html_document_returns_detached_document reproduces the bug — RED (test fails when the one-line fix is reverted) and GREEN (test passes with fix applied). Captured locally on this branch.
  • End-to-end test against the reporter's live URL: 1 byte → 57838 bytes, classList TypeError gone.
  • Stealth build skipped (no macOS available; per repo rules I should not claim stealth verification I didn't run).

Risk

Low.

  • createHTMLDocument was previously aliasing the global document, which meant any code that relied on it returning a detached sandbox could not work at all. Switching to a detached _IframeDocument makes the API behave per spec; callers that were silently corrupting the live <body> (jQuery feature-detection) now operate on the sandbox as intended.
  • No call site in the codebase reads from a createHTMLDocument(...) return value as if it were the live document; the only existing test for implementation was the surrounding Document.implementation shape.
  • The new _IframeDocument instance is unreferenced as soon as the caller drops it; no listeners or timers are attached.

jQuery 3.7.x feature-detects via:

    var body = document.implementation.createHTMLDocument("").body;
    body.innerHTML = "<form></form><form></form>";

Obscura's implementation returned globalThis.document, so the live
<body> was wiped down to two anonymous <form> 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 <title> 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant