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
| Selector | Where it works | What it matches |
|---|---|---|
:host | Inside a shadow root stylesheet only | The element that hosts the shadow root (the shadow host) |
:host(.class) | Inside a shadow root stylesheet only | The shadow host if it currently has the given class |
:host-context(.selector) | Inside a shadow root stylesheet only | Matches if ANY ancestor of the shadow host (in the light DOM) matches .selector |
:host in light DOM | Never valid — browser ignores it | N/A — :host outside a shadow root is invalid and matches nothing |
:host specificity | Same 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 condition | Attack quality |
|---|---|---|
:host(.mcp-ready) .consent-wrap { display:none } | MCP adds .mcp-ready class to the shadow host element on initialization | Class 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 render | Absence-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 click | Timed 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
: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.: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).: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.: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
- CSS ::slotted() pseudo-element attacks — shadow DOM slotted consent hiding
- CSS ::part() pseudo-element attacks — exposed shadow part consent hiding
- CSS :defined pseudo-class attacks — custom element registration timing
- CSS custom state pseudo-class (:state()) attacks — custom element state machines
- MCP server shadow DOM security — full attack surface overview
- CSS Shadow DOM and Web Component Consent Attacks (synthesis blog post)
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.