Skip to content

PoC clone node#1897

Draft
jonathanKingston wants to merge 16 commits into
mainfrom
jkt/clone-node-element-hiding
Draft

PoC clone node#1897
jonathanKingston wants to merge 16 commits into
mainfrom
jkt/clone-node-element-hiding

Conversation

@jonathanKingston
Copy link
Copy Markdown
Contributor

@jonathanKingston jonathanKingston commented Aug 15, 2025

Asana Task/Github Issue: https://app.asana.com/1/137249556945/project/1201520793593668/task/1211051310695039?focus=true

Description

Testing Steps

Checklist

Please tick all that apply:

  • I have tested this change locally
  • I have tested this change locally in all supported browsers
  • This change will be visible to users
  • I have added automated tests that cover this change
  • I have ensured the change is gated by config
  • This change was covered by a ship review
  • This change was covered by a tech design
  • Any dependent config has been merged

Note

Medium Risk
Changes the core isDomNodeEmpty heuristic used to hide/unhide elements by switching parsing strategy and adding custom-element detection, which could affect page layout/false positives and has some web-compat risk.

Overview
Improves element-empty detection in element-hiding by preferring cloneNode(true) over DOMParser for performance, while adding a custom element (hyphenated tag) check to fall back to DOMParser to avoid page-observable custom element constructor side effects.

Updates the emptiness heuristic to correctly consider when the root node itself is a media/form element or an iframe (not just descendants), and caches the custom-element presence check once per hide/unhide pass via a new useDOMParser flag.

Reviewed by Cursor Bugbot for commit e0eb7e8. Bugbot is set up for automated code reviews on this repo. Configure here.

@netlify
Copy link
Copy Markdown

netlify Bot commented Aug 15, 2025

Deploy Preview for content-scope-scripts ready!

Name Link
🔨 Latest commit f958fe5
🔍 Latest deploy log https://app.netlify.com/projects/content-scope-scripts/deploys/697f98e038b41f00082d03fb
😎 Deploy Preview https://deploy-preview-1897--content-scope-scripts.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Aug 15, 2025

Temporary Branch Update

The temporary branch has been updated with the latest changes. Below are the details:

Please use the above install command to update to the latest version.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Aug 15, 2025

[Beta] Generated file diff

Time updated: Fri, 22 May 2026 10:16:59 GMT

Android
    - android/contentScope.js

File has changed

Apple
    - apple/contentScope.js

File has changed

Chrome-mv3
    - chrome-mv3/inject.js

File has changed

Firefox
    - firefox/inject.js

File has changed

Integration
    - integration/contentScope.js

File has changed

Windows
    - windows/contentScope.js

File has changed

jonathanKingston and others added 10 commits August 15, 2025 17:51
* Migrate element hiding integration tests from privacy-test-pages (#2144)

* migrate element hiding integration tests from privacy-test-pages

* show results at top of page once tests are complete

* Refactor renderResults and remove unnecessary peer dependencies

Co-authored-by: jkingston <jkingston@duckduckgo.com>

* Refactor renderResults to simplify options and improve container handling

Co-authored-by: jkingston <jkingston@duckduckgo.com>

* Refactor: Keep test summary and results together

Co-authored-by: jkingston <jkingston@duckduckgo.com>

* Refactor renderResults to use results-container or body

Co-authored-by: jkingston <jkingston@duckduckgo.com>

* Add element hiding test page and config

Co-authored-by: jkingston <jkingston@duckduckgo.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: jkingston <jkingston@duckduckgo.com>

* PoC clone node

* Update element-hiding.js

* Update element-hiding.js

* Update element-hiding.js

* Lint fix

* Update element-hiding.js

* Refactor: Improve element hiding logic with DOMParser and custom element handling

Co-authored-by: jkingston <jkingston@duckduckgo.com>

* feat: Cache custom elements check in element-hiding

Co-authored-by: jkingston <jkingston@duckduckgo.com>

---------

Co-authored-by: David Harbage <dave@duckduckgo.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Comment thread injected/src/features/element-hiding.js
cursoragent and others added 3 commits February 1, 2026 18:05
Co-authored-by: jkingston <jkingston@duckduckgo.com>
Co-authored-by: jkingston <jkingston@duckduckgo.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 1, 2026

Build Branch

Branch pr-releases/jkt/clone-node-element-hiding
Commit 9c4958ffe4
Updated May 22, 2026 at 10:16:16 AM UTC

Static preview entry points

QR codes (mobile preview)
Entry point QR code
Docs QR for docs preview
Static pages QR for static pages preview
Integration pages QR for integration pages preview

Integration commands

npm (Android / Extension):

npm i github:duckduckgo/content-scope-scripts#pr-releases/jkt/clone-node-element-hiding

Swift Package Manager (Apple):

.package(url: "https://github.com/duckduckgo/content-scope-scripts.git", branch: "pr-releases/jkt/clone-node-element-hiding")

git submodule (Windows):

git -C submodules/content-scope-scripts fetch origin pr-releases/jkt/clone-node-element-hiding
git -C submodules/content-scope-scripts checkout origin/pr-releases/jkt/clone-node-element-hiding
Pin to exact commit

npm (Android / Extension):

npm i github:duckduckgo/content-scope-scripts#9c4958ffe4703ddb759b21f99b24f394194c5fe1

Swift Package Manager (Apple):

.package(url: "https://github.com/duckduckgo/content-scope-scripts.git", revision: "9c4958ffe4703ddb759b21f99b24f394194c5fe1")

git submodule (Windows):

git -C submodules/content-scope-scripts fetch origin pr-releases/jkt/clone-node-element-hiding
git -C submodules/content-scope-scripts checkout 9c4958ffe4703ddb759b21f99b24f394194c5fe1

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 1, 2026

⚠️ Cursor review was not successful.

This PR requires a manual review and approval from a member of one of the following teams:

  • @duckduckgo/content-scope-scripts-owners
  • @duckduckgo/apple-devs
  • @duckduckgo/android-devs
  • @duckduckgo/team-windows-development
  • @duckduckgo/extension-owners
  • @duckduckgo/config-aor
  • @duckduckgo/breakage-aor
  • @duckduckgo/breakage

@github-actions github-actions Bot added the semver-patch Bug fix / internal — no release needed label Mar 5, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Redundant per-node custom element check adds unnecessary overhead
    • Removed the redundant per-node hasCustomElements(node) fallback in isDomNodeEmpty, since the cached useDOMParser value (set from document.body at pass start) already covers all descendant nodes.

Create PR

Or push these changes by commenting:

@cursor push c4f91ed857
Preview (c4f91ed857)
diff --git a/injected/src/features/element-hiding.js b/injected/src/features/element-hiding.js
--- a/injected/src/features/element-hiding.js
+++ b/injected/src/features/element-hiding.js
@@ -60,7 +60,7 @@
 let appliedRules = new Set();
 let shouldInjectStyleTag = false;
 let styleTagInjected = false;
-/** @type {boolean} Cached per-pass; falls back to per-node checks when false. */
+/** @type {boolean} Cached per-pass via hasCustomElements(document.body). */
 let useDOMParser = false;
 let mediaAndFormSelectors = 'video,canvas,embed,object,audio,map,form,input,textarea,select,option,button';
 let hideTimeouts = [0, 100, 300, 500, 1000, 2000, 3000];
@@ -218,12 +218,10 @@
 
     // Use cloneNode for performance, but fall back to DOMParser when custom elements
     // are present to avoid triggering custom element constructors (page-observable).
-    // useDOMParser is cached per-pass via hasCustomElements check in hideAdNodes/unhideLoadedAds.
-    // If the page-level cache is false, still check the node before cloning.
-    const shouldUseDOMParser = useDOMParser || hasCustomElements(node);
+    // useDOMParser is cached per-pass via hasCustomElements(document.body) in hideAdNodes/unhideLoadedAds.
     /** @type {HTMLElement} */
     let parsedNode;
-    if (shouldUseDOMParser) {
+    if (useDOMParser) {
         // DOMParser wraps content in <html><head>...</head><body>...</body></html>
         if (!parser) {
             parser = new DOMParser();

// are present to avoid triggering custom element constructors (page-observable).
// useDOMParser is cached per-pass via hasCustomElements check in hideAdNodes/unhideLoadedAds.
// If the page-level cache is false, still check the node before cloning.
const shouldUseDOMParser = useDOMParser || hasCustomElements(node);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant per-node custom element check adds unnecessary overhead

Low Severity

The per-node hasCustomElements(node) fallback in isDomNodeEmpty is logically redundant. When useDOMParser is false (set by hasCustomElements(document.body) at the start of each pass), no node within document.body can contain custom elements, so the per-node check always returns false. This adds unnecessary getElementsByTagName('*') traversal for every element processed — particularly costly for closest-empty rules that walk up the DOM tree, calling hasCustomElements on progressively larger parent subtrees.


Please tell me if this was useful or not with a 👍 or 👎.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please fix this.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web Compatibility Assessment

  • injected/src/features/element-hiding.js:353,372 - warning: the new custom-element pre-scan assumes document.body exists. On body-less documents such as SVG/XML, the scheduled hide/unhide passes throw before rule processing; the previous path only required document.querySelectorAll.

Security Assessment

  • injected/src/features/element-hiding.js:229 - error: DOMParser is now read lazily during timeout-driven rule passes, after page script can replace window.DOMParser. That lets a hostile page control parseFromString or the returned document and bypass/break element hiding.
  • injected/src/features/element-hiding.js:233 - error: node.cloneNode(true) calls the live method on a page-controlled node/prototype. A page can replace Node.prototype.cloneNode or an own cloneNode before these timers run, executing page code in the protection path or returning an object that subverts the heuristic.

Risk Level

High Risk: this changes DOM-processing behavior for the injected elementHiding feature across platforms and introduces delayed calls to mutable page-world DOM primitives.

Recommendations

  1. Capture the native DOMParser constructor and Node.prototype.cloneNode at module load, or add safe captured/bound references in captured-globals.js; invoke prototype methods with captured ReflectApply.
  2. Make hasCustomElements use captured DOM/String primitives if it remains security-relevant, or keep the DOMParser path when safe native capture is not available.
  3. Guard document.body in both hide/unhide passes, or use document.documentElement/skip non-HTML documents explicitly.
  4. Add hostile-page tests that replace window.DOMParser, Node.prototype.cloneNode, and the custom-element traversal primitive before init(), plus a body-less document test.
Open in Web View Automation 

Sent by Cursor Automation: Web compat and sec

if (shouldUseDOMParser) {
// DOMParser wraps content in <html><head>...</head><body>...</body></html>
if (!parser) {
parser = new DOMParser();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now reads DOMParser lazily inside the timeout-driven pass. Since init() runs after config and these timers fire after page script can mutate globals, a hostile page can replace window.DOMParser and control parseFromString or the returned document, making element hiding throw or bypass. The previous module-scope parser at least captured the constructor at injection time. Please capture DOMParser/parseFromString at module load, or add a captured reference in captured-globals.js, before lazy initialization.

}
parsedNode = parser.parseFromString(node.outerHTML, 'text/html').documentElement;
} else {
parsedNode = /** @type {HTMLElement} */ (node.cloneNode(true));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls the live cloneNode property on a page-controlled node. Pages can replace Node.prototype.cloneNode or define an own cloneNode before these timers run, so they can execute page code in the protection path or return an object that breaks the emptiness heuristic. If the clone path stays, capture Node.prototype.cloneNode early and invoke it with captured ReflectApply; the custom-element detector should similarly avoid live page-mutated DOM methods.

const document = globalThis.document;

// Cache custom elements check once per pass to avoid repeated DOM traversal
useDOMParser = hasCustomElements(document.body);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes document.body exists. On non-HTML/body-less documents such as SVG or XML, hasCustomElements(document.body) throws before any rule processing, whereas the previous path only needed document.querySelectorAll. Please guard document.body or use an explicit document.documentElement/non-HTML skip path, and mirror the same fix in unhideLoadedAds().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

semver-patch Bug fix / internal — no release needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants