Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Audit: rules are blind to content inside shadow roots #164

Description

@twschiller

Summary

The rule engine and every rule scan the light DOM only. Content rendered inside an open shadow root is invisible to nearly every rule; closed shadow roots are opaque to everything. This is a systemic blind spot, not a per-rule bug — the asymmetry the injection-defense rules exist to close (DOM is readable to agents but invisible to humans) is exactly what an attacker gets for free by mounting payloads inside a shadow root.

Why it happens

Three structural facts make this true across the catalog:

  1. rule.apply() is only ever called with document.body (extension/src/lib/rule-engine.ts:171, :221). No rule is ever handed a shadow root as its scan root.
  2. The shared subtree watcher attaches MutationObserver to document.body / document.head (extension/src/lib/subtree-watcher.ts:163, extension/src/lib/selector-token-index.ts:163). MutationObserver does not cross shadow boundaries even with `subtree:true` — mutations inside a shadow root never fire on a host-level observer. So the dispatcher won't fire for any DOM that arrives inside a shadow root.
  3. querySelectorAll / closest / TreeWalker stop at shadow boundaries. Every rule uses one of these on the light tree.

Only one code path traverses open shadow roots: extension/src/lib/page-tree.ts:251-255, which compresses the page for the LLM. Closed shadow roots (`mode: "closed"`) are opaque even there — `element.shadowRoot` is `null`.

What's affected, grouped by blast radius

High-asymmetry — rules whose entire point is defeated by a trivial bypass

An attacker that wants to plant injection text can wrap it in `

.attachShadow({mode:"open"})` and these rules walk past it:

  • `prompt-injection-redact`
  • `hidden-text-strip`
  • `attribute-injection-sanitize`
  • `encoded-payload-redact`
  • `unicode-invisibles-strip`
  • `svg-text-strip`, `svg-sprite-strip`
  • `html-comment-strip` — comments inside shadow trees survive
  • `meta-injection-strip` (unlikely in practice; `` rarely has shadow roots, but worth noting)

Wholesale silent miss for legitimate content rendered inside web components

Any rule that uses `createSelectorHideRule`, `createSubtreeWatcher`, `walkTextNodes(Chunked)`, or `findInnermostMatches` is blind to shadow-tree content. The full list:

  • Selector-hide family: `ads-hide` (curated selectors), `chat-widget-hide`, `cookie-banner-hide`, `newsletter-modal-hide`, `footer-redact`, `reviews-redact`, `comments-redact`, `social-embed-redact`
  • Text-walk family: `pii-redact`, `secrets-redact`, `prompt-injection-redact`, `encoded-payload-redact`, `unicode-invisibles-strip`
  • Per-element scans: `hidden-text-strip`, `html-comment-strip`, `noscript-strip`, `attribute-injection-sanitize`, `json-ld-sanitize`, `schema-trust-sanitize`, `svg-text-strip`, `svg-sprite-strip`, `confirmshame-sanitize`, `checkout-checkbox-sanitize`, `countdown-timer-redact`, `scarcity-redact`, `cart-addon-annotate`, `link-spoof-annotate`, `trust-badge-annotate`, `roach-motel-annotate`, `disguised-ad-flag`, `cross-origin-frame-redact`

Practical blast radius is smaller than the list suggests for the widget-hide rules. Intercom, Zendesk, HubSpot, OneTrust, etc. mount a wrapper in the light tree (`#intercom-frame`, `#onetrust-banner-sdk`, etc.) and put their UI inside its shadow root. The wrapper still matches our selector and gets hidden, which is what we want. Where it breaks: a vendor that renders the launcher node itself inside a parent's shadow root (rare today; some newer React-native chat embeds do this).

CSS-path also blind

`ads-hide`'s EasyList path (`extension/src/rules/ads-hide.ts:40-54`) injects a 13k-selector `display:none !important` stylesheet at document scope. Document stylesheets do not apply inside shadow trees (style scoping). So shadow-rendered ads aren't hidden even by CSS.

The placeholder stylesheet (`extension/src/lib/rule-engine.ts:39-107`) has the same property. If a placeholder is ever placed inside an open shadow root (only `irrelevant-sections-redact` can, via ref resolution through `page-tree`), it renders unstyled. Reveal click handlers are attached imperatively, so they'd still function.

Closed shadow roots — opaque to everything

`mode: "closed"` is uncommon outside browser UA shadows and some hardened widgets, but no fix above reaches them. Likely a documented limitation rather than a fix.

Ideas, ranked by impact × effort

Tier 1 — covers most rules with one change

  1. Shadow-aware subtree watcher. Patch `Element.prototype.attachShadow` at content-script entry to keep a `Set`. `subtree-watcher.ts` recursively `observe()`s each open shadow root with its own `MutationObserver`, fanning into the same subscriber set (subscribers don't know or care which tree a mutation came from). `selector-token-index`'s `findTriggeredRules` walks `shadowRoot.querySelectorAll("*")` for any host element seen during dispatch. Covers every `createSelectorHideRule` / `createSubtreeWatcher` rule with no rule-code change.
  2. Initial-apply walk into existing shadow roots. On `rule.apply(document.body)`, walk the existing tree once and call each rule's scan again rooted at every open shadow root found. Without this, rules miss shadow content that existed before the rule was enabled.

Tier 2 — text-walk rules

  1. Shadow-piercing text walker. `document.createTreeWalker` does not descend into shadow roots. Replace `walkTextNodes(Chunked)`'s walker with a custom iterator that, on each element with `shadowRoot`, recursively walks the shadow tree. Covers `pii-redact`, `secrets-redact`, `prompt-injection-redact`, `encoded-payload-redact`, `unicode-invisibles-strip`. Worth prioritizing the injection-defense rules over PII.

Tier 3 — CSS-path

  1. Shadow-aware ads-hide stylesheet. For each open shadow root, inject a `<style>` containing the EasyList rules. Either intercept `attachShadow` (same hook as Tier 1) or use `adoptedStyleSheets` (one `CSSStyleSheet`, adopted into every shadow root). Adopted stylesheets are the right primitive — single source of truth, cheap to attach. Same approach would fix placeholder styling inside shadow roots.

Tier 4 — explicit limitation

  1. Document closed shadow roots as unreachable. Add to README / docs so users running into a sealed widget understand why it's not redacted.

Non-goals

  • Trying to read closed shadow roots. There's no supported way; even devtools shadow inspection is gated.
  • Changing every rule's scan signature. The watcher/walker upgrades push shadow-awareness into the shared plumbing so per-rule code stays as-is.

Recommended sequencing

  1. PR 1 (Tier 1): shadow-aware subtree watcher + token-index + initial walk. Covers the majority of the catalog with one architectural change.
  2. PR 2 (Tier 2): shadow-piercing text walker. Targets the injection-defense rules specifically.
  3. PR 3 (Tier 3): `adoptedStyleSheets` for EasyList + placeholder styles.
  4. Docs: note the closed-shadow-root limitation.

Worth landing a test fixture first: a demo page that mounts injection text, a chat widget wrapper, and a cookie banner each inside an open shadow root, so each PR can assert before/after coverage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions