Skip to content

Fix: cover open declarative shadow DOM via setHTMLUnsafe (#203)#211

Merged
twschiller merged 1 commit into
mainfrom
worktree-twinkly-tumbling-gray
Jun 7, 2026
Merged

Fix: cover open declarative shadow DOM via setHTMLUnsafe (#203)#211
twschiller merged 1 commit into
mainfrom
worktree-twinkly-tumbling-gray

Conversation

@twschiller

Copy link
Copy Markdown
Contributor

Summary

Addresses red-team audit item #4 (open variant) from #203.
<template shadowrootmode=\"open\"> materialized post-parse via
Element.setHTMLUnsafe or ShadowRoot.setHTMLUnsafe was a blind spot in
the shadow-piercing plumbing:

  • The HTML parser attaches the shadow on the receiver without ever calling
    attachShadow, so the attachShadow patch in installShadowRootHook
    doesn't fire.
  • The receiver is already in the document, so the subtree-watcher's "host
    was just inserted" path doesn't fire either — only the receiver's
    descendants are added in the MutationRecord, but the shadow itself is
    attached to the receiver, not to any added node.

This left a clean carrier shape for SSR-style component frameworks (Lit,
Stencil, RSC experiments) that hydrate via setHTMLUnsafe: the same
content carried via imperative attachShadow was already covered, so the
DSD path was a "switch to the new API to bypass the rules" shortcut.

installShadowRootHook now also wraps Element.prototype.setHTMLUnsafe
and ShadowRoot.prototype.setHTMLUnsafe. Each trampoline calls through
to the original and then walks the receiver with the existing
discoverShadowRootsIn, which registers any new open shadow and fans it
out to subscribers (the subtree-watcher's subscribeShadowRootAttached
callback handles routing on host containment). Initial-parse DSD was
already covered by the startup walk; this PR closes the post-parse gap.

Closed DSD inherits the same opt-out contract as imperative closed
shadows. A main-world probe over Element.prototype.attachShadow (same
delivery pattern as webdriver-probe-annotate) is the long-term path
and is captured in the closed-shadow-root-annotate header as future
work.

Notable choices

  • Document.parseHTMLUnsafe not patched — it returns a detached
    Document; any path that grafts its content into the live tree goes
    through appendChild (caught by the subtree-watcher's discovery walk)
    or another setHTMLUnsafe call (caught here).
  • Feature-detected — DSD APIs reached cross-browser baseline mid-2024;
    if the method is absent on the prototype, the patch is a no-op and the
    imperative-only coverage remains.
  • Idempotent against the existing registryregisterShadowRoot
    guards on the Set membership, so subscribers fire exactly once per
    open root even when both the trampoline and (in tests) the polyfilled
    attachShadow path see the same root.
  • jsdom 26 shimjest-environment-jsdom@30 ships jsdom 26, which
    hasn't shipped setHTMLUnsafe. src/__test-mocks__/jsdom-extras.ts
    gains a feature-gated polyfill that emulates the parser's DSD lifting:
    top-level <template shadowrootmode> → shadow on receiver,
    descendant template → shadow on its parent element, child-first
    recursion for nested DSD. The shim is gated on the absence of the
    native method; a future jsdom that ships DSD natively will use the
    engine implementation.
  • Demo-site fixtureShadowDomEmbed.tsx gains a third host that
    hydrates via Element.setHTMLUnsafe with a
    <template shadowrootmode=\"open\"> payload carrying the existing
    PRODUCT_DETAIL_HIDDEN_SYSTEM injection fixture. Reviewers can see
    the rule pipeline reaching into a DSD shadow in the before/after view.
  • Docs reflect current behavior only — Coverage scope now enumerates
    the three open-shadow attachment paths covered (imperative, parse-time
    DSD, post-parse setHTMLUnsafe) and explicitly notes that closed
    shadows remain unreached regardless of attachment path. The
    closed-shadow-root-annotate doc section's DSD clause clarifies that
    the open variant is now covered.

Test plan

  • npx jest src/lib/__tests__/shadow-roots* — 22 tests (16 hand
    written + 6 property cases), 6 new for DSD: open-on-host registers,
    closed-on-host does not, nested-host DSD registers, ShadowRoot
    setHTMLUnsafe registers descendants, listener fires exactly once
    per root, repeated setHTMLUnsafe doesn't double-register.
  • npx jest src/lib/__tests__/shadow-roots.property.test.ts — 1 new
    property: for any random nested tree of open/closed DSD templates,
    the registry equals exactly the set of open shadows.
  • npx jest src/lib/__tests__/subtree-watcher.test.ts — 72 tests,
    1 new integration confirming a subscriber receives a shadow-side
    element via the setHTMLUnsafe path with no other intervention.
  • npx jest — full extension suite, 1726 tests pass.
  • bun run check — biome + eslint clean.
  • bun run typecheck — clean.
  • bun run knip — clean.
  • bun run check in demo-site/ — biome + eslint clean.
  • pre-commit run --files docs/src/content/docs/rules.md — mdformat
    + markdownlint clean.
  • Manual: build the extension, load the demo site, confirm the DSD
    card's injection paragraph is hidden behind a reveal placeholder
    with shadow-piercing rules enabled (and that the closed-shadow card
    continues to surface the closed-shadow-root-annotate landmark).

🤖 Generated with Claude Code

Addresses audit item #4 (open variant). `<template shadowrootmode="open">`
materialized post-parse via `Element.setHTMLUnsafe` or
`ShadowRoot.setHTMLUnsafe` bypassed both the `attachShadow` patch and the
subtree-watcher's "host was just inserted" path — the receiver is already
in the tree and only its descendants mutate, so a host that gains a
shadow this way was invisible to every shadow-piercing rule. The
extension now wraps both `setHTMLUnsafe` variants in
`installShadowRootHook` and walks the receiver afterward so any new open
DSD shadow lands in the registry and fans out to subscribers.

Closed DSD inherits the same opt-out contract as imperative closed
shadows; the main-world probe sketched in `closed-shadow-root-annotate`
is captured as future work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-browser-shield-demo-site Ready Ready Preview, Comment Jun 7, 2026 7:13pm

Request Review

@twschiller twschiller merged commit f7c33fa into main Jun 7, 2026
7 checks passed
@twschiller twschiller deleted the worktree-twinkly-tumbling-gray branch June 7, 2026 19:18
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