Security reference · CSS injection · Pseudo-class attacks · Shadow DOM

MCP server CSS :scope, :root, :host shadow DOM security

MCP servers that present their consent UI as a Web Component (Custom Element with shadow DOM) operate in a CSS environment where standard audit tools cannot reach. Inside a shadow root, :scope resolves to the shadow root itself rather than the document root, :root matches the shadow root in some contexts, and :host() allows CSS inside the shadow to react to class changes on the host element in the outer document. A malicious MCP server can use these shadow-DOM-specific pseudo-classes to hide consent elements in ways that are invisible to document-level querySelector, getComputedStyle, and stylesheet scanners — all of which stop at shadow boundaries.

CSS :scope/:root/:host attack surface in shadow DOM consent UIs

CSS patternContextEvasion quality
:scope > .consent-section { display:none }Inside shadow root stylesheet — :scope resolves to shadow root, direct child combinator hides top-level consent childrenStandard document.querySelector cannot reach inside shadow roots; shadow DOM is the auditor blind spot
:host(.active-session) .consent { opacity:0 }:host() in shadow DOM CSS reacts to host element class in outer document — MCP adds class when user authenticatesAttack trigger is a class change in the outer document; inside shadow CSS looks legitimate
:root .consent-disclosure { display:none }Higher specificity than data-attribute selectors used by consent frameworks; overrides their visibility rulesLooks like a global reset; high specificity overrides without matching specific consent framework selectors
:host-context(.mcp-loaded) .terms { display:none }:host-context() matches when any ancestor in the outer DOM has the class; MCP adds class to body or html elementTrigger is an outer-document class; inside shadow CSS does not reveal the connection

Attack 1: :scope in shadow DOM hides top-level consent children

Inside a shadow root's stylesheet, :scope resolves to the shadow root (not the document root). The child combinator (>) from :scope targets elements that are direct children of the shadow root. An MCP server that structures its consent UI as a shadow component can use :scope > .consent-section to hide consent elements at the top level of the shadow tree:

/* Malicious CSS inside MCP server's shadow DOM component — SA-CSS-SCOPE-001 */
/* This CSS is in a <style> tag inside the shadow root, not in the document */

:scope > .consent-section {
  display: none;
}

/* Shadow DOM structure: */
/* <mcp-consent-dialog>   ← custom element (in outer document) */
/*   #shadow-root          ← shadow root */
/*     <div class="dialog-wrapper"> */
/*       <h2>Installation Permissions</h2> */
/*       <div class="consent-section">  ← HIDDEN: :scope > .consent-section */
/*         This server requests access to your file system and network... */
/*       </div> */
/*       <button class="accept-btn">Accept</button> */
/*     </div> */

/* Why this is invisible to standard auditors:
   document.querySelector('.consent-section') → null (shadow boundary blocks access)
   document.querySelectorAll('[class*="consent"]') → empty (same barrier)
   getComputedStyle(consentEl) → TypeError: consentEl is null

   To reach inside a shadow root, an auditor must:
   1. Find all custom elements in the document
   2. Access element.shadowRoot (returns null for closed shadow roots)
   3. Query within the shadow root: shadowRoot.querySelector('.consent-section')
   4. Call getComputedStyle on the shadow-internal element

   MCP server shadow roots are typically closed (attachShadow({ mode: 'closed' }))
   which returns null for shadowRoot, making step 2 impossible without JS prototype
   interception. */

/* SkillAudit detection: intercept attachShadow at page load to capture shadow roots */
const originalAttachShadow = Element.prototype.attachShadow;
const shadowRoots = new WeakMap();
Element.prototype.attachShadow = function(init) {
  const root = originalAttachShadow.call(this, { ...init, mode: 'open' });
  shadowRoots.set(this, root);
  return root;
};

/* After interception, shadowRoots.get(customEl) returns the shadow root
   even for components that requested 'closed' mode. */

Closed shadow DOM auditor bypass: A malicious MCP server that uses attachShadow({ mode: 'closed' }) returns null for element.shadowRoot. Standard DOM-querying auditors cannot reach the shadow tree. SkillAudit's instrumented auditor intercepts Element.prototype.attachShadow before the MCP component initializes to force open-mode access — but this requires the auditor to load before the MCP script.

Attack 2: :host() reacts to outer-document class changes

:host() is a CSS function pseudo-class used inside shadow DOM stylesheets that targets the shadow host element (the custom element in the outer document) when it matches a given selector. An MCP server can use :host(.active-session) to hide consent inside the shadow DOM in response to a class being added to the host element by MCP JavaScript in the outer document:

/* Malicious CSS inside MCP server's shadow DOM — SA-CSS-SCOPE-002 */
:host(.active-session) .consent {
  opacity: 0;
  pointer-events: none;
}

/* Malicious JS in outer document (not inside shadow DOM): */
/* MCP adds class to host element when user authenticates */
const mcpDialog = document.querySelector('mcp-consent-dialog');

// Fired when user completes login (before consent is shown)
authProvider.addEventListener('authenticated', () => {
  mcpDialog.classList.add('active-session');
  // :host(.active-session) fires inside the shadow DOM
  // .consent becomes opacity:0 — invisible but in layout and accessibility tree
});

/* Why this attack crosses shadow boundaries:
   The CSS is inside the shadow DOM and the class trigger is in the outer document.
   The shadow DOM CSS (:host()) is specifically designed to bridge this boundary —
   it is the intended mechanism for shadow DOM CSS to react to host element state.
   But an MCP server weaponizes it: the "active-session" class is added during
   authentication (a moment of trust, not a suspicious state), and consent hiding
   is the CSS consequence.

/* What auditors see:
   - Outer document CSS review: no suspicious rules (the attack is in shadow CSS)
   - Shadow DOM CSS review: ":host(.active-session) .consent" looks legitimate
     (authenticated users might see a different UI — this is a real pattern)
   - The malicious behavior only emerges from the combination of the class trigger
     timing (at auth, before consent is recorded) and the hiding effect

/* Detection: measure opacity of .consent inside shadow root after authentication */
async function checkHostClassConsentHide(shadowRoot, hostEl) {
  const consentEl = shadowRoot.querySelector('.consent, [class*="consent"]');
  if (!consentEl) return null;

  // Measure before class addition
  const before = parseFloat(getComputedStyle(consentEl).opacity);

  hostEl.classList.add('active-session');
  await new Promise(r => requestAnimationFrame(r));

  const after = parseFloat(getComputedStyle(consentEl).opacity);
  hostEl.classList.remove('active-session');

  if (after < 0.1 && before > 0.5) {
    return { id: 'SA-CSS-SCOPE-002', severity: 'critical',
      message: ':host(.active-session) rule hides consent inside shadow DOM ' +
               'when active-session class is added to host element.' };
  }
  return null;
}

Attack 3: :root high-specificity overrides consent framework visibility

Consent management platforms (CMPs) like OneTrust, Cookiebot, and TrustArc use attribute-based selectors ([data-consent="shown"], [class^="cmp-"]) to control consent UI visibility. These attribute selectors have a specificity of (0,1,0). A :root .consent-disclosure selector has a specificity of (0,1,1) — one tag element — plus the pseudo-class, giving it higher specificity than attribute selectors. An MCP server can override CMP-controlled visibility with a higher-specificity :root rule:

/* Malicious CSS injection — SA-CSS-SCOPE-003 */
/* Specificity: (0,1,1) — beats attribute-selector-only rules at (0,1,0) */
:root .consent-disclosure {
  display: none !important;
}

/* Why :root is used instead of just a class selector:
   :root alone is (0,1,0) — same as attribute selectors
   :root .consent-disclosure is (0,1,1) — beats single attribute selectors
   :root :root .consent-disclosure is (0,2,1) — beats compound attribute rules

   However, !important in the injected rule trumps all non-!important rules
   regardless of specificity. The :root selector is belt-and-suspenders for
   cases where the CMP framework also uses !important.

/* CMP framework rule examples that SA-CSS-SCOPE-003 overrides: */
/* [data-consent-shown="true"] .consent-disclosure { display: block; }   (0,1,0) */
/* .cmp-visible .consent-disclosure { display: block; }                  (0,2,0) */
/* :root .consent-disclosure { display: block; }    ← TIE: !important wins */

/* The attack hides consent even when the CMP correctly sets its visibility rules,
   because the MCP's injected !important overrides the CMP's !important display:block
   ONLY when injection order places the MCP stylesheet after the CMP stylesheet.
   (Last identical-importance rule wins in CSS cascade.) MCP scripts typically
   inject after the page's own stylesheets, giving them cascade precedence. */

/* Detection: check for !important declarations on consent elements */
for (const sheet of document.styleSheets) {
  try {
    for (const rule of sheet.cssRules) {
      if (!(rule instanceof CSSStyleRule)) continue;
      if (/:root/.test(rule.selectorText)
          && rule.style.getPropertyPriority('display') === 'important') {
        report({ id: 'SA-CSS-SCOPE-003', severity: 'high',
          message: ':root rule with display:none !important found. ' +
                   'May override CMP framework consent visibility rules.' });
      }
    }
  } catch (_) {}
}

Attack 4: :host-context() reacts to outer-document ancestor class

:host-context() is the ancestor-aware variant of :host(): it matches when any ancestor of the shadow host element — not just the host itself — has a given class or attribute. An MCP server can add a class to the <body> or <html> element to trigger consent hiding inside any of its shadow components on the page:

/* Malicious CSS inside MCP server's shadow DOM — SA-CSS-SCOPE-004 */
:host-context(.mcp-loaded) .terms {
  display: none;
}

/* Malicious JS in outer document: */
/* Add class to document.body at MCP initialization — before consent is shown */
document.body.classList.add('mcp-loaded');

/* Effect:
   - MCP adds 'mcp-loaded' to document.body during initialization
   - :host-context(.mcp-loaded) fires inside every shadow root on the page
     for which MCP is the host element ancestor
   - .terms elements inside all MCP shadow components are hidden
   - This is a single trigger that controls multiple shadow components simultaneously

/* Browser support caveat:
   :host-context() is supported in Chrome/Edge but has been removed from
   the CSS specification for shadow DOM encapsulation reasons. Firefox never
   implemented it; Safari implementation is experimental.
   On non-Chrome browsers, this attack is inert — but Chrome dominates
   developer tools and security testing, where it is fully active.

/* Why this is architecturally significant:
   :host-context() is the only CSS mechanism that allows a single outer-document
   change to affect CSS inside multiple shadow roots simultaneously.
   An MCP server that deploys multiple shadow components (consent dialog,
   permissions panel, session widget) can silence all consent UI in all components
   with one classList.add() call on the body.

/* Detection: intercept classList operations on document.body and document.documentElement */
const _add = DOMTokenList.prototype.add;
DOMTokenList.prototype.add = function(...classes) {
  _add.apply(this, classes);
  const target = this === document.body.classList ? 'body'
               : this === document.documentElement.classList ? 'html' : null;
  if (!target) return;
  for (const cls of classes) {
    // Check if adding this class hides any consent element in any shadow root
    for (const [host, shadow] of capturedShadowRoots) {
      const consentEl = shadow.querySelector('.terms, .consent, [class*="consent"]');
      if (!consentEl) continue;
      requestAnimationFrame(() => {
        const s = getComputedStyle(consentEl);
        if (s.display === 'none' || parseFloat(s.opacity) < 0.1) {
          report({ id: 'SA-CSS-SCOPE-004', severity: 'critical',
            message: `Adding class "${cls}" to ${target} hides consent inside shadow DOM. ` +
                     'Suspected :host-context() consent attack.' });
        }
      });
    }
  }
};

Shadow DOM as an audit firewall: All four attack vectors in this page share a common property: the consent element exists inside a shadow root, and the hiding mechanism involves shadow-DOM-specific CSS pseudo-classes. Standard security auditing tools that do not instrument attachShadow and do not traverse shadow roots will find zero evidence of these attacks. Shadow DOM-aware auditing is required.

SkillAudit findings for CSS :scope/:root/:host shadow DOM attacks

CriticalSA-CSS-SCOPE-001 — :scope > .consent-section { display:none } inside shadow root stylesheet. Consent hidden at shadow root level; requires shadow-DOM-aware auditor with attachShadow interception to detect. Standard document.querySelector cannot find shadow-internal elements.
CriticalSA-CSS-SCOPE-002 — :host(.active-session) .consent { opacity:0 } in shadow CSS, triggered by MCP JS adding class to host element in outer document at authentication. Bridges shadow boundary; attack only visible when host element class change is correlated with consent element opacity change inside shadow root.
HighSA-CSS-SCOPE-003 — :root .consent-disclosure { display:none !important } in injected stylesheet. Higher specificity and cascade position than CMP framework visibility rules; overrides CMP's consent shown state. Detected via stylesheet scanning for :root rules with !important on display property.
CriticalSA-CSS-SCOPE-004 — :host-context(.mcp-loaded) .terms { display:none } in shadow CSS, triggered by MCP JS adding class to document.body. Single outer-document change silences consent in all MCP shadow components simultaneously. Requires classList interception and shadow root traversal to detect.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: shadow DOM consent attack detection
 *
 * Full shadow-DOM-aware audit pipeline:
 * 1. Intercept attachShadow to capture all shadow roots (including closed mode)
 * 2. For each captured shadow root, scan CSS and measure consent visibility
 * 3. Probe :host() and :host-context() by toggling host and ancestor classes
 */

// Phase 0: Intercept attachShadow BEFORE MCP scripts load
const capturedShadowRoots = new Map();
const _attachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function(init) {
  const root = _attachShadow.call(this, { ...init, mode: 'open' });
  capturedShadowRoots.set(this, root);
  return root;
};

async function auditShadowDOMConsentAttacks() {
  const results = [];
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

  for (const [hostEl, shadowRoot] of capturedShadowRoots) {
    const consentEls = shadowRoot.querySelectorAll(
      '.consent, .terms, [class*="consent"], [class*="terms"]'
    );

    for (const consentEl of consentEls) {
      if (consentEl.textContent.trim().length < 50) continue;

      // SA-CSS-SCOPE-001: measure consent at page load
      const baseStyle = getComputedStyle(consentEl);
      if (baseStyle.display === 'none' || parseFloat(baseStyle.opacity) < 0.1) {
        results.push({ id: 'SA-CSS-SCOPE-001', severity: 'critical',
          message: `Consent element hidden inside shadow root of <${hostEl.tagName.toLowerCase()}>` });
      }

      // SA-CSS-SCOPE-002: probe :host() by toggling host classes
      const testClass = 'active-session';
      hostEl.classList.add(testClass);
      await new Promise(r => requestAnimationFrame(r));
      const afterHostClass = getComputedStyle(consentEl);
      hostEl.classList.remove(testClass);
      if ((afterHostClass.display === 'none' || parseFloat(afterHostClass.opacity) < 0.1)
          && baseStyle.display !== 'none') {
        results.push({ id: 'SA-CSS-SCOPE-002', severity: 'critical',
          message: `Consent hidden inside shadow root when host element gets class "${testClass}"` });
      }

      // SA-CSS-SCOPE-004: probe :host-context() by toggling body class
      document.body.classList.add('mcp-loaded');
      await new Promise(r => requestAnimationFrame(r));
      const afterBodyClass = getComputedStyle(consentEl);
      document.body.classList.remove('mcp-loaded');
      if ((afterBodyClass.display === 'none' || parseFloat(afterBodyClass.opacity) < 0.1)
          && baseStyle.display !== 'none') {
        results.push({ id: 'SA-CSS-SCOPE-004', severity: 'critical',
          message: 'Consent hidden inside shadow root when "mcp-loaded" class added to body. ' +
                   'Suspected :host-context() attack.' });
      }
    }

    // SA-CSS-SCOPE-003: scan shadow root stylesheets for :root !important
    for (const sheet of shadowRoot.styleSheets || []) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule instanceof CSSStyleRule
              && /:root/.test(rule.selectorText)
              && rule.style.getPropertyPriority('display') === 'important') {
            results.push({ id: 'SA-CSS-SCOPE-003', severity: 'high',
              message: ':root display:none !important rule found in shadow stylesheet' });
          }
        }
      } catch (_) {}
    }
  }

  return results;
}

/* Safe patterns:
   1. Use closed shadow DOM for consent UI — prevents MCP from accessing
      consent elements; MCP would have to open its own shadow root which
      SkillAudit's attachShadow interception captures
   2. Do not allow MCP server scripts to run before consent is recorded —
      consent should be a host-page responsibility, not delegated to MCP components
   3. Avoid :host-context() in consent UI components — the dependency on
      outer-document ancestor state creates a cross-boundary attack surface
   4. Apply a CSP Content-Security-Policy that blocks MCP server scripts from
      the consent page entirely, restricting MCP access to post-consent pages only */

Related MCP consent attack research

Audit your MCP server's shadow DOM components for :scope/:host consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-SCOPE findings.