Security research · Shadow DOM · Custom elements · CSS attack surface

CSS Custom Element and Shadow DOM Consent Attacks on MCP Servers: How :defined, ::slotted(), and :host Hide Terms Inside Web Components

2026-08-01 ~1,900 words SkillAudit Security Research

When we analyze MCP server consent-hiding attacks using CSS selectors, we tend to focus on light-DOM selectors: sibling combinators that fire on standard HTML elements, pseudo-classes like :hover or :focus, and property tricks like display:none or opacity:0. Absence-condition attacks using :not() and registration timing attacks using :defined extend this surface. But all of these assume the consent disclosure lives in the light DOM — inside the main document tree where document.querySelectorAll() can find it.

What happens when MCP wraps its consent disclosure inside a shadow root?

Shadow DOM is the encapsulation mechanism behind modern web components. A custom element like <mcp-install-panel> can attach a shadow root with its own internal DOM tree. CSS from the host page cannot reach inside that shadow root (unless the shadow element explicitly exposes named parts via part=""). document.querySelectorAll('.consent-disclosure') returns empty if the element is inside a shadow root with mode: 'closed'. The shadow boundary is a hard CSS and DOM encapsulation barrier — and MCP can exploit that encapsulation to move consent disclosures into a space where standard auditors cannot inspect them.

This post covers four shadow DOM attack vectors that SkillAudit's static analysis covers: :defined registration timing, ::slotted() slot targeting, :host and :host-context() host element control, and ::part() expose-then-hide. Each operates at a different layer of the shadow boundary.

In this post

  1. Why shadow DOM creates a new attack boundary
  2. :defined — weaponizing element registration timing
  3. ::slotted() — consent text as a slot target
  4. :host and :host-context() — the shadow host as attack pivot
  5. ::part() — intentional expose, immediate hide
  6. What standard auditors miss
  7. Safe consent patterns in web components

Why shadow DOM creates a new attack boundary

In a light-DOM attack, the auditor's job is straightforward: inspect document.styleSheets for rules targeting consent-class elements, check computed visibility, and scan the DOM for elements with relevant class names. The entire page is one flat tree with one set of stylesheets.

Shadow DOM fragments this picture. Each custom element that uses attachShadow() creates an isolated subtree with:

What the light-DOM auditor sees

The custom element tag (<mcp-install-panel>) in the DOM. No child elements visible via querySelectorAll if shadow mode is closed. No shadow stylesheets in document.styleSheets. The consent element is not found.

What's actually happening inside

A shadow root with its own DOM tree containing the consent disclosure. Shadow stylesheets with :host and ::slotted() rules. The consent element exists but is unreachable from outside the shadow boundary.

The result is a false negative: the light-DOM auditor reports "no consent element found" — which it interprets as "consent not implemented," not as "consent hidden inside shadow DOM." MCP can exploit this by deliberately placing consent inside a shadow root and relying on auditors to miss it entirely.

But even auditors that do inspect shadow roots face four distinct attack vectors — one for each shadow-specific CSS mechanism.

1

:defined — weaponizing element registration timing

Critical · Zero injected HTML in light DOM · Timing-controlled consent hiding

The :defined pseudo-class fires the moment a custom element is registered via customElements.define(). Before registration, a custom element tag (<mcp-widget>) is :not(:defined) — the browser treats it as an unknown element. Once customElements.define('mcp-widget', ...) runs, the element transitions to :defined.

MCP uses this transition as a CSS trigger. The attack setup: MCP injects <mcp-widget></mcp-widget> before the consent disclosure in the light DOM. The injected stylesheet contains mcp-widget:defined ~ .consent { display:none }. At page load, mcp-widget is :not(:defined) — the rule does not fire — the consent is visible — the automated load-time audit passes. Then MCP's script calls customElements.define('mcp-widget', McpWidget) from inside a setTimeout(). The element becomes :defined. The rule fires. The consent disappears. All of this happens after the audit window closes.

/* :defined timing attack — fires after deferred customElements.define() */
mcp-widget:defined ~ .consent { display: none }

/* Registration is deferred to after page-load audits: */
setTimeout(() => customElements.define('mcp-widget', McpWidget), 800);

The inverse of this attack uses :not(:defined): MCP injects a tag that is never registered. *:not(:defined) ~ .consent { display:none } fires from page load and never resolves — a permanent absence condition requiring zero JavaScript. See the full :defined attack reference for all four patterns.

What auditors must check

  • Scan stylesheets for any custom-element:defined or :not(:defined) sibling rules targeting consent class names
  • Check for deferred customElements.define() calls inside setTimeout, requestAnimationFrame, or event listeners
  • Re-evaluate consent visibility AFTER all customElements.define() calls complete, not just at load time
2

::slotted() — consent text as a slot target

Critical · Light-DOM consent moves into shadow DOM via slots · Invisible to DOM scanners

Shadow DOM slots (<slot>) allow a shadow root to incorporate light-DOM children into its layout. When you write <mcp-panel><p class="consent">...</p></mcp-panel>, the <p class="consent"> is a light-DOM element — it exists in the main document tree. But if mcp-panel's shadow root contains a <slot></slot>, the shadow DOM renders the <p> inside the shadow layout, in the position where the slot appears.

The shadow root's stylesheet can then target that slotted element using ::slotted():

/* Inside mcp-panel's shadow root stylesheet */
::slotted(.consent) {
  display: none;
  visibility: hidden;
}

The <p class="consent"> is technically in the light DOM — document.querySelector('.consent') finds it. But its rendering is controlled by the shadow root it has been slotted into. The shadow stylesheet uses ::slotted(.consent) to apply display:none to the slotted element. A light-DOM audit finds the element (it's in the document) and checks its class and text content — and sees what looks like a visible consent element. The shadow stylesheet that actually hides it is invisible to the light-DOM scan.

The attack becomes more complex when MCP wraps existing page content in a shadow host via JavaScript: shadowRoot.appendChild(document.querySelector('.consent')) moves light-DOM elements into a shadow root mid-session, converting them from regular DOM elements to shadow DOM content. The audit may have already run and passed before the move happens. See the ::slotted() attack reference for the full attack taxonomy.

What auditors must check

  • For each custom element in the DOM: check its shadow root (if open) for ::slotted() rules targeting consent-related class names
  • Detect JS that moves light-DOM elements into shadow roots (shadowRoot.appendChild, shadowRoot.insertBefore)
  • Check computed styles for slotted elements — getComputedStyle() reflects shadow stylesheet rules on slotted elements
3

:host and :host-context() — the shadow host as attack pivot

Critical · Crosses shadow boundary outward · One state change hides all shadow components

:host and :host-context() are the inside-out shadow selectors — they operate within a shadow root's stylesheet but react to conditions on the shadow host element or its ancestors in the light DOM.

:host(.mcp-ready) matches when the shadow host element has the class .mcp-ready. MCP's connectedCallback() adds this class immediately when the component initializes — before any user interaction, before any audit re-check, and before the user can read the consent inside the shadow root. The shadow root's stylesheet hides the consent disclosure the moment the class appears:

/* Inside mcp-install-panel's shadow root stylesheet */
:host(.mcp-ready) .consent-wrap { display: none }

/* connectedCallback: immediately adds the trigger class */
connectedCallback() {
  this.attachShadow({ mode: 'closed' }); /* Blocks external inspection */
  this.classList.add('mcp-ready');       /* Triggers :host(.mcp-ready) */
}

:host-context() is the more dangerous variant: it fires when any ancestor of the shadow host in the light DOM matches a given selector. This crosses the shadow boundary outward. MCP can add a single attribute to document.documentElementdata-mcp-installed="1" — and every MCP shadow component on the page that has :host-context([data-mcp-installed]) .consent { display:none } in its shadow stylesheet will simultaneously hide its consent disclosure. One call, all components affected.

Crucially, all :host and :host-context() rules live inside shadow root stylesheets — accessible via element.shadowRoot.styleSheets for open shadow roots, but completely unreachable for closed shadow roots. The full attack reference is in the :host pseudo-class security page.

What auditors must check

  • For each open shadow root: iterate shadowRoot.styleSheets and scan for :host() and :host-context() rules targeting consent class names inside the shadow DOM
  • Closed shadow roots (mode: 'closed') require static source analysis — check the component's template/class definition for :host rules in the source
  • Monitor document.documentElement attribute changes for :host-context() triggers that propagate to all shadow components
4

::part() — intentional expose, immediate hide

High · Inverts the purpose of shadow part exposure · Passes shadow DOM security reviews

::part() is the CSS Shadow Parts specification's answer to the question: "how do host pages style specific elements inside shadow roots?" Elements inside a shadow root can add a part="" attribute to expose themselves to host-page CSS. The host page can then target them with custom-element::part(name) { ... }.

MCP inverts this mechanism. The attack: MCP's shadow component adds part="consent" to the consent disclosure element inside the shadow root. Then MCP's injected host-page stylesheet contains mcp-widget::part(consent) { display:none }. The consent element is exposed — technically accessible to host CSS — and then immediately hidden by MCP's own host CSS rule:

/* Shadow HTML: consent element with part attribute */
<div part="consent">By installing, you grant access to...</div>

/* Host-page CSS (MCP-injected): hides the just-exposed consent part */
mcp-widget::part(consent) { display: none }

A shadow DOM security review might flag this as "the consent element has part='consent', so it's accessible and styleable by the host page" — interpreting the part attribute as a sign of openness. But the openness is the first half of the attack. The host-page ::part(consent) rule is the second half. The part attribute is evidence of deliberate exposure for the purpose of hiding, not evidence of compliance.

The attack extends through exportparts chains: an outer custom element can re-export inner shadow root parts, allowing host-page CSS to target consent elements that are two shadow boundaries deep. Each exportparts hop adds a layer of indirection that complicates manual tracing while keeping the hiding rule in the light-DOM host stylesheet. See the ::part() attack reference for the full taxonomy including specificity bypass patterns.

What auditors must check

  • Find all shadow DOM elements with consent-related part attribute values (part="consent", part="disclosure", part="terms")
  • Cross-reference against host-page stylesheets for matching ::part() hide rules
  • Trace exportparts attribute chains across nested custom elements to find transitively exposed parts
  • Check ::part() rule specificity (0,1,1) against shadow-internal display rules that might be overridden

What standard auditors miss

The table below summarizes what each shadow DOM attack vector evades:

Attack vectorEvades light-DOM CSS scan?Evades querySelectorAll?Evades computed visibility check?Detection requirement
:defined timing attackNo — rule is in light-DOM stylesheetNo — custom element is in light DOMYes — consent is visible at t=0, hidden after deferred registrationRe-check consent after all customElements.define() calls complete
::slotted() attackYes — rule is in shadow stylesheetPartial — element is in light DOM but rendered inside shadow slotYes — computed style reflects shadow rule, but scanner may not call getComputedStyle on slotted elementInspect shadow stylesheets; check shadow elements for ::slotted() rules targeting consent classes
:host / :host-context() attackYes — rule is in shadow stylesheetYes — consent is inside shadow DOMYes for closed shadows; may be detected for open shadows via getComputedStyleIterate shadowRoot.styleSheets for open roots; static analysis of source for closed roots
::part() attackPartial — ::part() rule is in light-DOM stylesheet (findable), but pairing with shadow part attribute requires two-side checkYes — element is inside shadow DOMYes — computed style affected but the shadow source says "display:block"Scan host-page stylesheets for ::part() hide rules AND match against shadow DOM part attributes

The common thread: every shadow DOM attack vector requires a scanner that crosses the shadow boundary in at least one direction — either inspecting shadow stylesheets from outside, or correlating light-DOM CSS rules with shadow DOM element attributes. Single-side scans (light-DOM only or shadow-DOM only) produce false negatives for every vector in this table.

Safe consent patterns in web components

If you're building an MCP server that uses web components for its install UI, here are the patterns that SkillAudit's scanner will verify:

/* Safe consent in web components: these patterns pass SkillAudit checks */

/* 1. Light-DOM consent — keep consent outside any shadow root entirely */
<mcp-install-panel></mcp-install-panel>
<div class="consent-disclosure">Terms...</div>  /* in light DOM, not slotted */

/* 2. If consent must be in shadow DOM: open shadow + no :host hiding */
attachShadow({ mode: 'open' });  /* Allows external inspection */
/* No :host(), :host-context(), or ::slotted() rules that target consent */

/* 3. If using slots: don't apply ::slotted() rules to consent elements */
/* slot {  } → only layout, no display/visibility hiding on slotted consent */

/* 4. If using ::part(): don't add part="" to consent elements */
/* If part="" is required for styling: ensure no host ::part() hide rules exist */

/* 5. No deferred customElements.define() for elements that are CSS triggers */
/* Registration timing must not coincide with consent visibility state changes */

The core principle: consent disclosures must be visible to users at the time they are asked to make an installation decision. Shadow DOM encapsulation does not change this requirement; it only changes which CSS mechanisms are available to circumvent it. SkillAudit's shadow DOM security scanner checks all four attack vectors described in this post as part of every audit.

Run a free SkillAudit scan on your MCP server or Claude skill to check for shadow DOM consent-hiding attacks: skillaudit.dev/#audit. The report covers :defined, ::slotted(), :host, and ::part() attack patterns alongside the full CSS injection surface.