Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion demo-site/src/components/ShadowDomEmbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ import { INJECTIONS } from "../data/injection-fixtures";
// class only matches via the EasyList generic CSS sheet, which now
// adopts into open shadow roots so the hide applies here too.
//
// A second host below mounts a custom element with a CLOSED shadow root
// A third host hydrates via `Element.setHTMLUnsafe` with a
// `<template shadowrootmode="open">` payload — the declarative shadow
// DOM path. The HTML parser materializes the shadow without ever going
// through `attachShadow`, so the extension's `setHTMLUnsafe` patch is
// the only thing that gets the shadow into the registry; without it
// the injection paragraph here would be passed through to the agent.
//
// A fourth host mounts a custom element with a CLOSED shadow root
// to exercise `closed-shadow-root-annotate`. By spec the rules cannot
// see inside; the heuristic only confirms it's there.

Expand All @@ -47,6 +54,7 @@ if (typeof customElements !== "undefined" && !customElements.get(CLOSED_TAG)) {

export default function ShadowDomEmbed() {
const hostRef = useRef<HTMLDivElement>(null);
const dsdHostRef = useRef<HTMLDivElement>(null);
const closedHostRef = useRef<HTMLDivElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -100,6 +108,56 @@ export default function ShadowDomEmbed() {
};
}, []);

useEffect(() => {
const host = dsdHostRef.current;
if (!host || host.shadowRoot) {
return;
}
// Declarative shadow DOM: the parser materializes the shadow when
// it encounters `<template shadowrootmode>` as a child of the
// receiver during `setHTMLUnsafe`. No `attachShadow` call is made.
// Without the extension's `setHTMLUnsafe` patch the new shadow is
// invisible to every shadow-piercing rule.
//
// `setHTMLUnsafe` is in TypeScript's lib.dom.d.ts but the browser
// matrix for "Baseline 2024" — feature-detect rather than rely on
// a tooling assumption.
if (typeof host.setHTMLUnsafe !== "function") {
return;
}
const escapedInjection = INJECTIONS.PRODUCT_DETAIL_HIDDEN_SYSTEM.replaceAll(
"<",
"&lt;",
).replaceAll(">", "&gt;");
host.setHTMLUnsafe(
`<template shadowrootmode="open">
<style>
.dsd-card {
padding: 6px 10px;
border: 1px solid #0f766e;
background: #f0fdfa;
color: #134e4a;
font: 13px system-ui;
border-radius: 4px;
}
.dsd-injection {
margin: 8px 0 0;
padding: 6px 10px;
border: 1px solid #b91c1c;
background: #fef2f2;
color: #7f1d1d;
font: 13px system-ui;
}
</style>
<div class="dsd-card">SSR-style widget hydrated via declarative shadow DOM.</div>
<p class="dsd-injection">${escapedInjection}</p>
</template>`,
);
return () => {
host.shadowRoot?.replaceChildren();
};
}, []);

useEffect(() => {
const host = closedHostRef.current;
if (!host || host.querySelector(CLOSED_TAG)) {
Expand Down Expand Up @@ -135,6 +193,19 @@ export default function ShadowDomEmbed() {
ref={hostRef}
className="shadow-host mt-3 rounded border border-dashed border-slate-300 p-3"
/>
<p className="mt-4 text-sm text-stone-700">
The host below hydrates via <code>Element.setHTMLUnsafe</code> with a{" "}
<code>&lt;template shadowrootmode="open"&gt;</code> payload — the
declarative shadow DOM path used by SSR component frameworks. The HTML
parser attaches the shadow without ever calling{" "}
<code>attachShadow</code>; without the extension's{" "}
<code>setHTMLUnsafe</code> patch the injection paragraph inside this
shadow would slip past every shadow-piercing rule.
</p>
<div
ref={dsdHostRef}
className="dsd-shadow-host mt-3 rounded border border-dashed border-slate-300 p-3"
/>
<p className="mt-4 text-sm text-stone-700">
The widget below mounts inside a <em>closed</em> shadow root. ABS cannot
reach inside by spec — every rule passes its contents through untouched.
Expand Down
42 changes: 26 additions & 16 deletions docs/src/content/docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,29 @@ Numbered citations like [[1]](#ref-greshake-2023) link to the
## Coverage scope

All rules run against the page's light DOM and any **open shadow roots** the
page builds via `element.attachShadow({ mode: "open" })`. That covers the way
most chat widgets, consent banners, ad SDKs, and custom elements ship UI today —
the host element lives in the light tree, the rendered content lives one
boundary inside.

**Closed shadow roots** (`{ mode: "closed" }`) are not reached. The Web
Components spec makes closed mode opt-out of all external JavaScript access —
`host.shadowRoot` is `null`, `document.adoptedStyleSheets` and
`MutationObserver` do not cross the boundary, and no supported API undoes that.
Any content a page renders inside a closed shadow root — whether ads, chat
widgets, hidden text, or prompt-injection payloads — is invisible to every rule
and will be passed through to the agent untouched. Closed shadow roots are
uncommon outside browser UA shadows and a handful of hardened embeds, but they
are a known gap. The optional
page builds, regardless of which attachment path created the shadow:

- Imperative attachment — `element.attachShadow({ mode: "open" })`. Covers the
way most chat widgets, consent banners, ad SDKs, and custom elements ship UI
today.
- Declarative shadow DOM at parse time — `<template shadowrootmode="open">` in
the initial HTML. The browser materializes the shadow before the content
script runs; the extension's startup walk finds it.
- Declarative shadow DOM post-parse — `Element.setHTMLUnsafe` and
`ShadowRoot.setHTMLUnsafe`, the modern hydration path used by SSR-style
component frameworks. The extension wraps both so a host that gains a shadow
via this path is added to the registry.

**Closed shadow roots** (`{ mode: "closed" }`) are not reached, regardless of
how they were attached (imperative, parse-time DSD with
`shadowrootmode="closed"`, or any other path). The Web Components spec makes
closed mode opt-out of all external JavaScript access — `host.shadowRoot` is
`null`, `document.adoptedStyleSheets` and `MutationObserver` do not cross the
boundary, and no supported API undoes that. Any content a page renders inside a
closed shadow root — whether ads, chat widgets, hidden text, or prompt-injection
payloads — is invisible to every rule and will be passed through to the agent
untouched. Closed shadow roots are uncommon outside browser UA shadows and a
handful of hardened embeds, but they are a known gap. The optional
[Flag Closed Shadow Roots](#flag-closed-shadow-roots-experimental) rule can
heuristically warn the agent at read-time when this gap is in use.

Expand Down Expand Up @@ -483,9 +492,10 @@ JavaScript. The rule looks for the structural shape strongly correlated with
in `customElements`) with no light-DOM children, no `host.shadowRoot`, and a
non-zero rendered box. Built-in elements with UA shadow roots (`<input>`,
`<details>`, `<video>`) are filtered out for free — their tag names contain no
hyphen. Declarative shadow DOM (`<template shadowrootmode="closed">`) is
hyphen. Declarative shadow DOM with `shadowrootmode="closed"` is
indistinguishable from imperative closed shadows after parsing and is not
separately surfaced.
separately surfaced; the open variant of declarative shadow DOM is covered by
the regular open-shadow plumbing described in [Coverage scope](#coverage-scope).

The landmark says "may contain content ABS cannot see," not "this is definitely
a closed shadow root" — a custom element that renders via canvas, WebGL, or
Expand Down
174 changes: 174 additions & 0 deletions extension/src/__test-mocks__/jsdom-extras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,177 @@ if (!("adoptedStyleSheets" in Document.prototype)) {
if (!("adoptedStyleSheets" in ShadowRoot.prototype)) {
defineAdoptedStyleSheets(ShadowRoot.prototype);
}

// setHTMLUnsafe / declarative shadow DOM — Baseline 2024, present in the
// Chrome MV3 versions we target. jsdom 26 hasn't shipped it. The
// production code in `shadow-roots.ts` patches both Element and
// ShadowRoot variants; tests need the underlying method on the prototype
// so the patch has something to wrap and so callers (test fixtures,
// integration tests) can drive the DSD path.
//
// This polyfill emulates only the surface our tests exercise:
//
// - Parse the HTML into a throwaway staging element with `innerHTML`.
// innerHTML preserves `<template>` elements and stashes their
// children in `template.content` — exactly the shape the parser
// uses ahead of DSD lifting.
// - For top-level `<template shadowrootmode="open"|"closed">` (parent
// is the staging element), `attachShadow` on the **receiver** in
// the requested mode and move the template content into the new
// shadow root.
// - For nested `<template shadowrootmode>` (parent is some other
// element in the parsed tree), `attachShadow` on the parent
// element and move the template content into its new shadow root.
// - Both passes recurse into `template.content` first so DSD inside
// DSD is materialized child-first.
// - The remaining (non-template, non-shadow) staging children
// replace the receiver's children.
//
// Limitations vs. the real parser:
// - `shadowrootdelegatesfocus`, `shadowrootclonable`, and
// `shadowrootserializable` attributes are ignored — none of our
// tests assert on those.
// - A duplicate top-level DSD template of the same mode is dropped
// (the real parser handles the second one as ordinary fragment
// content, but our tests never produce duplicates).
//
// Note on test-environment interaction: the polyfill calls
// `attachShadow` to materialize DSD shadows. `attachShadow` is itself
// patched by the production code under test, so in tests the registry
// also fills via the attachShadow path. This is fine — the production
// `setHTMLUnsafe` trampoline's `discoverShadowRootsIn` call is
// idempotent against the registry. In real browsers the parser
// materializes DSD without going through `attachShadow`, which is the
// gap the trampoline closes.
//
// Gated on the absence of the native method so a future jsdom upgrade
// that ships DSD natively will use the engine implementation.
interface SetHTMLUnsafeCapable {
setHTMLUnsafe?: (this: Element | ShadowRoot, html: string) => void;
}

function attachDSDShadow(host: Element, template: HTMLTemplateElement): void {
const mode = template.getAttribute("shadowrootmode");
if (mode !== "open" && mode !== "closed") {
template.remove();
return;
}
// Recurse into the template's content first so any nested DSD is
// lifted before the outer shadow swallows the subtree.
liftNestedDSD(template.content);
let shadow: ShadowRoot;
try {
shadow = host.attachShadow({ mode });
} catch {
// Host already has a shadow root attached. Fall back to it when
// accessible (open mode) and otherwise drop the template content —
// matches the spec's "ignore duplicate" outcome for closed.
const existing = host.shadowRoot;
if (!existing) {
template.remove();
return;
}
shadow = existing;
}
shadow.append(template.content);
template.remove();
}

function liftNestedDSD(root: ParentNode): void {
// Walk the document tree under `root` (NOT into `template.content`,
// which is its own off-tree fragment — attachDSDShadow handles those
// by recursing explicitly).
const stack: ParentNode[] = [root];
const templates: HTMLTemplateElement[] = [];
while (stack.length > 0) {
const node = stack.pop();
if (!node) {
continue;
}
for (const child of node.children) {
if (
child instanceof HTMLTemplateElement &&
child.hasAttribute("shadowrootmode")
) {
templates.push(child);
// Do not descend through the template — its children live in
// `.content`, which `attachDSDShadow` walks separately.
continue;
}
stack.push(child);
}
}
for (const template of templates) {
const parent = template.parentElement;
if (parent) {
attachDSDShadow(parent, template);
} else {
template.remove();
}
}
}

function polyfilledSetHTMLUnsafe(
this: Element | ShadowRoot,
html: string,
): void {
const ownerDocument = this.ownerDocument;
const staging = ownerDocument.createElement("div");
staging.innerHTML = html;

// Top-level DSD templates (direct children of staging) attach shadows
// on the receiver, not on staging. ShadowRoot receivers can't host a
// shadow of their own — drop those templates' DSD intent but keep
// their content as ordinary children.
//
// Collect templates in one pass before mutating; iterating an
// HTMLCollection while removing its members skips siblings.
const topLevelTemplates: HTMLTemplateElement[] = [];
for (const child of staging.children) {
if (
child instanceof HTMLTemplateElement &&
child.hasAttribute("shadowrootmode")
) {
topLevelTemplates.push(child);
}
}
for (const child of topLevelTemplates) {
if (this instanceof Element) {
attachDSDShadow(this, child);
} else {
// ShadowRoot receiver — flatten the template's content into the
// staging so downstream copy moves it into the shadow root.
liftNestedDSD(child.content);
child.replaceWith(child.content);
}
}

// Descendant DSD templates attach on their immediate parents.
liftNestedDSD(staging);

// Replace the receiver's children with the processed staging contents.
while (this.firstChild) {
this.firstChild.remove();
}
while (staging.firstChild) {
this.append(staging.firstChild);
}
}

const elementProtoForSet = Element.prototype as SetHTMLUnsafeCapable;
if (typeof elementProtoForSet.setHTMLUnsafe !== "function") {
Object.defineProperty(Element.prototype, "setHTMLUnsafe", {
configurable: true,
writable: true,
value: polyfilledSetHTMLUnsafe,
});
}

const shadowProtoForSet = ShadowRoot.prototype as SetHTMLUnsafeCapable;
if (typeof shadowProtoForSet.setHTMLUnsafe !== "function") {
Object.defineProperty(ShadowRoot.prototype, "setHTMLUnsafe", {
configurable: true,
writable: true,
value: polyfilledSetHTMLUnsafe,
});
}
Loading
Loading