Fix: main-world shadow-root probe for definitive closed-shadow detection (#203)#217
Merged
Merged
Conversation
…ion (#203) Addresses red-team audit items #1 (closed shadow root content invisible) and the open-shadow blind spot identified in PR #211's footnote, both from #203. Wraps Element.prototype.attachShadow, Element.prototype.setHTMLUnsafe, and ShadowRoot.prototype.setHTMLUnsafe in the page world via the same delivery pattern as webdriver-probe-annotate and checkout-checkbox-sanitize. The isolated-world copies of these prototypes are distinct objects from the page-world copies, so the existing patches in lib/shadow-roots.ts never fired on page-script attachments. Two events bridge back across worlds: - abs:closed-shadow-attached → consumed by closed-shadow-root-annotate for a definitive landmark stamp. Supersedes the structural heuristic (no canvas/WebGL false positives, catches closed shadows on non-custom-element hosts). Carries no detail — closed shadow contents must remain opaque to every consumer. - abs:shadow-discover with detail.target → consumed by shadow-roots.ts, routes the receiver through discoverShadowRootsIn so page-script open-shadow attachments and setHTMLUnsafe receivers are no longer invisible to the isolated-world rule engine. The structural heuristic stays as the gap-filler for the active tab between toggle-on and the moment the executeScript fallback injects the probe. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
twschiller
added a commit
that referenced
this pull request
Jun 7, 2026
Three main-world bundle registration tests (webdriver-probe,
checkout-checkbox-defense, shadow-root-probe) had identical ~50-line
blocks wiring chrome.scripting.registerContentScripts /
unregisterContentScripts / getRegisteredContentScripts into a stateful
in-memory store. Each new bundle would copy-paste the same mock — the
canonical rule-of-three trigger.
Extracted to src/__test-mocks__/chrome-scripting-registry.ts as
installScriptingRegistry(), exposing { registerMock, unregisterMock,
getRegisteredMock, seed, registered }. The per-test variance (which
module to import, which startup function to call, which script literal
to seed) stays local.
Net: 251 lines removed, 130 added across the three tests + the shared
helper. The seed/registered abstraction also drops the inline
RegistrationModule prefix-collision workaround in
shadow-root-probe-registration.test.ts (the colliding identifiers moved
into the helper module).
Stacked on shadow-root-main-world-probe (PR #217).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Addresses red-team audit item #1 (closed shadow root content invisible) and the open-shadow blind spot called out in PR #211's footnote, both from #203.
Wraps
Element.prototype.attachShadow,Element.prototype.setHTMLUnsafe, andShadowRoot.prototype.setHTMLUnsafein the page world via the same delivery pattern aswebdriver-probe-annotateandcheckout-checkbox-sanitize. The isolated-world copies of these prototypes are distinct objects from the page-world copies, so the existing patches inlib/shadow-roots.tsonly fired for isolated-world callers and jsdom-shimmed test paths — page-script attachments slipped past entirely.Two events bridge across worlds:
abs:closed-shadow-attached→ consumed byclosed-shadow-root-annotatefor a definitive landmark stamp. Supersedes the structural heuristic — no canvas/WebGL false positives, catches closed shadows on non-custom-element hosts. Carries no detail so closed shadow contents remain opaque to every consumer.abs:shadow-discoverwithdetail.target→ consumed byshadow-roots.ts, routes the receiver throughdiscoverShadowRootsInso page-script open-shadow attachments andsetHTMLUnsafereceivers are no longer invisible to the isolated-world rule engine.The structural heuristic stays as the gap-filler for the active tab between toggle-on and the moment the
executeScriptfallback injects the probe.Notable choices
closed-shadow-root-annotate— the registration lifecycle (lib/shadow-root-probe-registration.ts) mirrorswebdriver-probe-registration.ts: enrolled when (enforcement on) AND (rule enabled). Keeps the page-world surface gated to opt-in users.allFrames: true— shadow roots attach in any frame and same-origin iframes have their own prototype copies, so per-frame injection is required.event.detail === nullso a future "convenient payload" tweak can't silently leak host or root references.abs:shadow-discoverlistener gates ontarget instanceof Nodeso a page firingabs:shadow-discoverwith garbage detail no-ops cleanly.<template shadowrootmode=\"closed\">is consumed by the HTML parser without going throughattachShadow, so the probe doesn't see it. The materialized root is also indistinguishable from "no shadow" externally, so the heuristic can't catch it reliably either. Documented in the rule's header.docs/src/content/docs/rules.mdrewrites theclosed-shadow-root-annotatesection around the new probe-primary / heuristic-fallback split. The coverage-scope section is unchanged (open-shadow story is materially the same — the probe is belt-and-suspenders for open).shadowProbeRegisterMock,ShadowProbeRegistrationModule, etc.) so they don't collide with the identical shapes inwebdriver-probe-registration.test.ts. Both files have no top-level import/export and TypeScript treats them as scripts that share global scope; Biome'snoExportsInTestrule blocks theexport {}workaround.Test plan
npx jest src/lib/__tests__/shadow-root-probe-source.test.ts— 8 tests covering attachShadow open/closed dispatch, setHTMLUnsafe receiver dispatch, idempotency, no-detail-on-closed contract.npx jest src/lib/__tests__/shadow-root-probe-source.property.test.ts— 2 property invariants: event count equals op count across random attachShadow/setHTMLUnsafe sequences; discover-event target equals the receiver.npx jest src/lib/__tests__/shadow-root-probe-registration.test.ts— 5 tests covering the four enabled/registered corners plus the no-op short-circuit.npx jest src/lib/__tests__/shadow-roots.test.ts— 3 new tests on theabs:shadow-discoverlistener: open-root registration from probe dispatch, ShadowRoot-target walking, forged-dispatch defense.npx jest src/rules/__tests__/closed-shadow-root-annotate.test.ts— 5 new tests on the probe integration: landmark stamped from event alone (no heuristic match),inject-shadow-root-proberequested on apply, per-document dedupe on repeated events, listener removed on teardown, rule-detection emitted exactly once.npx jest— full extension suite, 93 suites / 1856 tests pass.bun run check— biome + eslint clean.bun run typecheck— clean.bun run knip— clean (entry added for the new bundle).bun run build— extension builds;dist/shadow-root-probe.jsships as its own bundle.pre-commit run --files docs/src/content/docs/rules.md— mdformat + markdownlint pass.🤖 Generated with Claude Code