Security reference · CSS injection · Shadow DOM · Host pseudo-class attacks

MCP server CSS :host pseudo-class security

The CSS :host and :host() pseudo-classes are exclusive to shadow root stylesheets — they select the shadow host element (the custom element that has the shadow root attached) from inside the shadow tree. :host-context() extends this by crossing the shadow boundary: it fires when any ancestor of the shadow host in the light DOM matches a given selector. For MCP servers that wrap consent disclosures inside shadow root components, these selectors create a consent-hiding attack surface that is completely invisible to auditors that only inspect the light DOM.

:host scope — shadow boundary rules

SelectorWhere it worksWhat it matches
:hostInside a shadow root stylesheet onlyThe element that hosts the shadow root (the shadow host)
:host(.class)Inside a shadow root stylesheet onlyThe shadow host if it currently has the given class
:host-context(.selector)Inside a shadow root stylesheet onlyMatches if ANY ancestor of the shadow host (in the light DOM) matches .selector
:host in light DOMNever valid — browser ignores itN/A — :host outside a shadow root is invalid and matches nothing
:host specificitySame as a class selector: (0,1,0):host() with argument has (0,1,0) + the argument's specificity

Shadow boundary creates an auditor blind spot: Standard CSS auditors that scan document.styleSheets in the light DOM do not access shadow root stylesheets. Each shadow root has its own adoptedStyleSheets and inline <style> elements that are only accessible via shadowRoot.styleSheets. An MCP component can place all consent-hiding rules inside its shadow root stylesheet, making them invisible to any light-DOM-only CSS scan.

CSS :host attack surface in MCP shadow components

CSS pattern (inside shadow root)Trigger conditionAttack quality
:host(.mcp-ready) .consent-wrap { display:none }MCP adds .mcp-ready class to the shadow host element on initializationClass applied in connectedCallback() — fires before user sees install UI; both CSS and class change under MCP control
:host-context([data-mcp-installed]) .consent { display:none }MCP sets data-mcp-installed on any ancestor (e.g., document.documentElement)Crosses shadow boundary; one JS attribute assignment hides consent in all MCP shadow components simultaneously
:host(:not(.consent-required)) .consent-disclosure { display:none }Host element never receives .consent-required — absence-condition; fires from renderAbsence-condition: default state hides consent; adding the class would show it but MCP never adds it
:host([data-state="installed"]) .consent { color: transparent; pointer-events: none }MCP sets data-state="installed" attribute on the host element after install button clickTimed attribute attack — consent visible during audit window, hidden the moment user initiates install

Attack 1: :host(.mcp-ready) — class-controlled shadow host consent hiding

MCP creates a custom element <mcp-install-panel> with an attached shadow root. The consent disclosure is rendered inside the shadow root as part of the component's shadow DOM. The shadow root's stylesheet uses :host(.mcp-ready) to hide the consent wrapper once MCP's initialization class is applied. Because connectedCallback() runs when the element is inserted into the DOM — before the page reaches interactive state — the consent is hidden before the user can interact:

/* Malicious CSS — inside MCP's shadow root stylesheet — SA-CSS-HOST-001 */
/* :host(.mcp-ready) fires when the shadow host has class .mcp-ready */
:host(.mcp-ready) .consent-wrap {
  display: none;
}

/* MCP's shadow DOM content */
/* <template id="mcp-tpl"> */
/*   <div class="install-form"> ... </div>  */
/*   <div class="consent-wrap">               */
/*     <p>By installing, you grant ...</p>    */
/*   </div>                                   */
/* </template>                                */

class McpInstallPanel extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'closed' });
    /* Attach the template with consent inside shadow root */
    shadow.appendChild(document.getElementById('mcp-tpl').content.cloneNode(true));

    /* Immediately add .mcp-ready — fires BEFORE user interaction */
    /* At this point, :host(.mcp-ready) becomes true → .consent-wrap hidden */
    this.classList.add('mcp-ready');
  }
}
customElements.define('mcp-install-panel', McpInstallPanel);

/* Why shadow DOM makes this invisible to auditors:
   - document.styleSheets only exposes light-DOM stylesheets
   - Shadow root has its own stylesheet (shadow.adoptedStyleSheets or <style> inside shadow)
   - Standard CSS consent-hiding scanner: MISSES this rule entirely
   - shadowRoot.mode = 'closed' means JS can't read shadowRoot from outside
   - The consent element is inside the shadow DOM — querySelectorAll('.consent-wrap') returns empty

/* Detection: scan custom elements for closed shadow roots and their stylesheets */
function detectHostClassAttack(rootEl = document.body) {
  const findings = [];
  const customEls = rootEl.querySelectorAll('*');
  for (const el of customEls) {
    const tag = el.tagName.toLowerCase();
    if (!tag.includes('-')) continue;
    /* Open shadow roots can be inspected */
    const shadow = el.shadowRoot;
    if (!shadow) continue; /* closed shadow root — flag separately */
    for (const sheet of shadow.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          const sel = rule.selectorText || '';
          if (/:host[\s(]/.test(sel) &&
              /consent|disclosure|terms|privacy/i.test(rule.cssText)) {
            findings.push({ id: 'SA-CSS-HOST-001', severity: 'critical',
              message: `Shadow root :host rule hiding consent in <${tag}>: "${sel}"` });
          }
        }
      } catch (_) {}
    }
  }
  return findings;
}

Closed shadow mode blocks all external inspection: When MCP uses attachShadow({ mode: 'closed' }), element.shadowRoot returns null from any external script. The consent-hiding CSS rule lives in a shadow root that is entirely unreadable by third-party auditors. SkillAudit's static analysis of the MCP source must identify closed shadow roots and inspect their template content for consent-hiding patterns.

Attack 2: :host-context() — ancestor state propagates across shadow boundary

:host-context(selector) is unique among shadow DOM selectors: it crosses the shadow boundary outward. It fires when any ancestor of the shadow host — anywhere in the light DOM tree — matches the given selector. MCP exploits this to create a single control point: one attribute assignment on document.documentElement hides consent in every MCP shadow component on the page simultaneously:

/* Malicious CSS — inside MCP's shadow root stylesheet — SA-CSS-HOST-002 */
/* :host-context() fires when ANY light DOM ancestor matches the selector */
:host-context([data-mcp-installed]) .consent {
  display: none;
}

/* Equivalent formulations: */
:host-context(.mcp-active) .terms-disclosure { display: none }
:host-context(body.mcp-initialized) .consent-wrap { display: none }
:host-context(#mcp-host-app) .installation-consent { opacity: 0; pointer-events: none }

/* MCP's initialization JS — single call hides consent in ALL shadow components */
window.addEventListener('DOMContentLoaded', () => {
  /* Adding data-mcp-installed to <html> makes :host-context() true */
  /* for every MCP shadow root anywhere in the page tree */
  document.documentElement.setAttribute('data-mcp-installed', '1');
  /* At this point: ALL MCP shadow components hide their .consent elements */
  /* One attribute change, unlimited shadow root impact */
});

/* Why :host-context() is particularly dangerous:
   - Crosses the shadow boundary outward, unlike all other CSS selectors
   - Allows a single light-DOM state change to trigger consent hiding in many components
   - The trigger (data-mcp-installed on <html>) looks like a legitimate initialization flag
   - No per-component JS required — the CSS rule in each shadow root does the work
   - Note: :host-context() is not in CSS Selectors 4 final spec but supported in Chrome/Safari */

/* Detection: scan shadow root stylesheets for :host-context() rules */
function detectHostContextAttack(rootEl = document.body) {
  const findings = [];
  for (const el of rootEl.querySelectorAll('*')) {
    const shadow = el.shadowRoot;
    if (!shadow) continue;
    for (const sheet of shadow.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          const sel = rule.selectorText || '';
          if (/:host-context/.test(sel) &&
              /consent|disclosure|terms|privacy/i.test(rule.cssText)) {
            findings.push({ id: 'SA-CSS-HOST-002', severity: 'critical',
              message: `Cross-boundary :host-context() consent attack in ${el.tagName.toLowerCase()}: "${sel}"` });
          }
        }
      } catch (_) {}
    }
  }
  return findings;
}

Attack 3: :host(:not(.consent-required)) — shadow DOM absence condition

The :host(:not()) pattern creates an absence-condition attack inside the shadow root: the rule fires when the shadow host does NOT have a specific class. Since MCP controls whether the host ever receives the required class, MCP can ensure the absence condition is always true — consent is hidden from the first render and never shown:

/* Malicious CSS — inside MCP's shadow root stylesheet — SA-CSS-HOST-003 */
/* Fires when shadow host does NOT have .consent-required class */
/* MCP never adds .consent-required → rule fires from first render */
:host(:not(.consent-required)) .consent-disclosure {
  display: none;
}

/* Variant: attribute-based absence condition */
:host(:not([data-terms-accepted])) .terms-banner { display: none }

/* Variant: combination absence condition */
:host(:not(.consent-required):not([data-override])) .consent-wrap { display: none }

/* The attack logic:
   t=0:  Shadow host renders. Host has no .consent-required class.
   t=0:  :host(:not(.consent-required)) is TRUE → .consent-disclosure hidden.
   t=0:  Auditor checks shadow DOM content → finds .consent-disclosure with display:none.
   t=∞:  .consent-required is NEVER added by MCP — absence is permanent.

   For the absence condition to fail (consent to show), the HOST PAGE must add
   .consent-required to the shadow host element. But only MCP's script controls
   this element — there is no external mechanism to add the class.

/* Why this evades detection:
   - The CSS rule looks like a "show consent only when required" guard
   - Reading it forwards: "hide consent unless marked as required" — sounds defensive
   - But the critical observation: "required" is never true — absence is the permanent state
   - No positive trigger needed — the rule fires from render

/* Detection: :host(:not()) rules in shadow stylesheets that hide consent */
function detectHostNotAbsenceAttack(rootEl = document.body) {
  const findings = [];
  for (const el of rootEl.querySelectorAll('*')) {
    const shadow = el.shadowRoot;
    if (!shadow) continue;
    for (const sheet of shadow.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          const sel = rule.selectorText || '';
          if (/:host\(:not\(/.test(sel) &&
              /consent|disclosure|terms|privacy/i.test(rule.cssText)) {
            /* Verify: does the host element actually ever receive the required class? */
            const notArg = sel.match(/:host\(:not\(([^)]+)\)/)?.[1];
            if (notArg && !el.classList.contains(notArg.replace('.', ''))) {
              findings.push({ id: 'SA-CSS-HOST-003', severity: 'high',
                message: `Shadow :host(:not()) absence condition hides consent — host never has ${notArg}: "${sel}"` });
            }
          }
        }
      } catch (_) {}
    }
  }
  return findings;
}

Attack 4: :host([data-state="installed"]) — attribute state timed consent hiding

The attribute-qualified :host() selector fires when the shadow host element has a specific attribute value. MCP uses this for a timed attack: consent is visible during the page-load audit window, and the hiding rule only activates when MCP sets the attribute — which it does immediately on the first user interaction with the install button. The audit tool sees the consent, but the user never does:

/* Malicious CSS — inside MCP's shadow root stylesheet — SA-CSS-HOST-004 */
/* Fires when shadow host element has attribute data-state="installed" */
:host([data-state="installed"]) .consent {
  color: transparent;      /* text invisible but still in accessibility tree */
  pointer-events: none;    /* not clickable */
  user-select: none;       /* not selectable */
}

/* MCP shadow component event listener */
class McpInstallPanel extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    /* ... render install UI with consent inside shadow ... */

    const btn = shadow.querySelector('.install-btn');
    btn.addEventListener('click', () => {
      /* The instant user clicks Install, the host attribute is set */
      /* :host([data-state="installed"]) fires → consent becomes invisible */
      /* The user clicked BEFORE seeing the consent because the attribute fires at click */
      this.setAttribute('data-state', 'installed');
      /* Now proceed with installation... */
    });
  }
}

/* Timeline:
   t=0:    Page loads. Shadow host has no data-state attribute. Consent visible.
   t=0:    Automated auditor scans → consent IS visible → PASSES
   t=user: User sees install button before reading consent (consent below fold)
   t=click: btn click handler fires → data-state="installed" set → consent becomes transparent
   t=click: User has now "installed" without seeing consent — timed attribute attack

/* Variant using CSS transition for slower fade: */
:host .consent {
  transition: opacity 0.1s ease;
  opacity: 1;
}
:host([data-state="installed"]) .consent {
  opacity: 0;
}
/* The 100ms transition makes the hiding visually smooth and less suspicious */

/* Detection: audit shadow root attribute-qualified :host() rules */
async function detectHostAttributeTimedAttack(rootEl = document.body) {
  const findings = [];
  for (const el of rootEl.querySelectorAll('*')) {
    const shadow = el.shadowRoot;
    if (!shadow) continue;
    for (const sheet of shadow.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          const sel = rule.selectorText || '';
          if (/:host\(\[/.test(sel) &&
              /consent|disclosure|terms|privacy/i.test(rule.cssText)) {
            findings.push({ id: 'SA-CSS-HOST-004', severity: 'high',
              message: `Attribute-qualified :host() consent attack — state changes on user action: "${sel}"` });
          }
        }
      } catch (_) {}
    }
    /* Check if element has event listeners that set attributes */
    const src = el.outerHTML + (el.shadowRoot?.innerHTML || '');
    if (/setAttribute.*data-state|dataset\.[a-z]+ ?=/.test(src) &&
        /consent|terms/i.test(src)) {
      findings.push({ id: 'SA-CSS-HOST-004', severity: 'medium',
        message: `Shadow host ${el.tagName.toLowerCase()} sets attributes near consent content — recheck consent visibility after user interactions` });
    }
  }
  return findings;
}

:host-context() browser support note: :host-context() is implemented in Chrome and Safari but is not part of the final CSS Selectors Level 4 specification and is absent in Firefox. This creates a cross-browser attack gap: consent-hiding via :host-context() only fires in WebKit/Blink-based browsers, while Firefox-based auditors see consent as always visible. Safari on iOS (all Apple devices) uses WebKit and is therefore affected.

SkillAudit findings for CSS :host consent attacks

CriticalSA-CSS-HOST-001 — :host(.mcp-ready) .consent-wrap { display:none }. Shadow root :host() class attack. MCP's connectedCallback() immediately adds the trigger class, hiding consent inside the shadow DOM before first render. Invisible to all light-DOM CSS scanners. Requires shadow stylesheet inspection for detection.
CriticalSA-CSS-HOST-002 — :host-context([data-mcp-installed]) .consent { display:none }. Cross-boundary :host-context() attack. One attribute assignment on document.documentElement propagates through the shadow boundary and hides consent in all MCP shadow components simultaneously. Supported in Chrome and Safari (all iOS browsers).
HighSA-CSS-HOST-003 — :host(:not(.consent-required)) .consent-disclosure { display:none }. Shadow DOM absence-condition using :host(:not()). The required class is never applied by MCP; the absence condition is permanent from first render. Rule reads as a "show when required" guard but functions as a "never show" lock.
HighSA-CSS-HOST-004 — :host([data-state="installed"]) .consent { color:transparent }. Timed attribute attack fires on the first user click (install button handler). Consent visible during automated audit window; invisible at the moment the user interacts. Consent text remains in the accessibility tree (color:transparent, not display:none) but is visually gone.

Related MCP consent attack research

Audit your MCP server for :host consent-hiding attacks inside shadow roots: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-HOST findings.