Security Deep-Dive · 2026-07-09
CSS Cascade Layers Were Designed for Architecture — Not Security
Unlayered CSS rules always win over @layer rules. That is not a browser bug — it is the spec. An MCP server script running in your tab can exploit this in three ways: read your entire CSS layer architecture (layer names reveal feature flags and payment provider versions), defeat security-state CSS on disabled buttons and consent overlays with a single injected unlayered rule, and use the revert-layer keyword to undo your hardening layers entirely. Here is why the model fails, what MCP servers can do with it, and how to write security states that actually hold.
The spec guarantee developers miss
The CSS Cascade Level 5 specification defines the ordering of style precedence. The full order (from lowest to highest priority, ignoring !important) is:
- User agent stylesheet
- User stylesheet
- Author stylesheet — layered rules, in layer order
- Author stylesheet — unlayered rules
- Inline styles
That means: any rule written outside an @layer block always wins over any rule written inside an @layer block, regardless of specificity. A low-specificity button { pointer-events: auto } outside any layer beats a high-specificity button[disabled].security-critical { pointer-events: none !important } inside a layer — unless the layered rule uses !important (which reverses the order for !important rules only, adding its own complexity).
This is intentional. The spec designed unlayered rules to always win so that existing CSS written before @layer existed would not be broken when layer-aware stylesheets were introduced alongside it. It is the right architectural decision. It is a terrible security model.
Why developers use CSS for security states
CSS-based security states are everywhere because they are convenient:
- Disabled buttons:
button[disabled] { pointer-events: none; opacity: 0.5; cursor: not-allowed } - Consent overlays:
.consent-modal { z-index: 9999; pointer-events: all }plusbody.modal-open { overflow: hidden; pointer-events: none } - ARIA-based visibility:
[aria-hidden="true"] { display: none }or[aria-disabled="true"] { pointer-events: none } - Payment field locking:
.stripe-locked { user-select: none; pointer-events: none }
When these appear inside a named @layer security-overrides or @layer hardening, developers assume the "security" in the layer name confers protection. It does not. The layer name is documentation, not enforcement.
The core mistake: CSS cascade layers control which rule wins when two rules from the same author target the same property. They do not prevent a third party from injecting a new unlayered rule that wins unconditionally. Any script that can inject a <style> tag — including MCP server scripts executing tool calls in the same browsing context — operates outside all existing layers and wins automatically.
Attack 1: Layer name enumeration — reading your architecture without cssRules access
The CSS Object Model exposes layer names through CSSLayerStatementRule.nameList. Every @layer reset, base, components, utilities; declaration in a stylesheet is accessible as an array of strings from JavaScript — no cross-origin restriction, no permission required.
// Enumerate all CSS layer names across all stylesheets
function enumerateLayerNames() {
const layers = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSLayerStatementRule) {
layers.push(...rule.nameList);
}
// Also check @import rules that declare layers
if (rule instanceof CSSImportRule && rule.layerName) {
layers.push(rule.layerName);
}
}
} catch (e) {
// Cross-origin stylesheets throw SecurityError on cssRules access
// — but CSSLayerStatementRule.nameList is exposed before that check in some browsers
}
}
return layers;
}
// Example output from a real fintech MCP client:
// ["reset", "tokens", "components", "stripe-v3", "feature-flags.beta-ai-summary",
// "feature-flags.payment-redesign-2026", "compliance.pci-dss", "security-overrides",
// "a11y", "utilities"]
//
// From this list an attacker learns:
// - Stripe v3 is the payment provider (target known API)
// - Two feature flags currently active (beta-ai-summary, payment-redesign-2026)
// - PCI-DSS compliance layer exists (scope of audit confirmed)
Even when sheet.cssRules throws a SecurityError for cross-origin stylesheets, browsers often expose CSSLayerStatementRule objects before hitting that check. And for same-origin stylesheets — which is where most MCP client CSS lives — the full layer list is freely accessible. Layer names in real production apps routinely encode:
- Feature flags —
feature-flags.ai-summary,ab-test.checkout-v2 - Payment provider versions —
stripe-v3,paypal-sdk-2.x - Compliance scopes —
compliance.hipaa,compliance.pci-dss - Infrastructure identifiers —
design-system.v4-beta,legacy.ie11-compat
An alternative enumeration path that does not require cssRules access at all: inject a test rule with a computed property and compare values across candidate layer names. For each suspect layer name, inject @layer suspected-name { :root { --probe: 1 } } and measure getComputedStyle(document.documentElement).getPropertyValue('--probe'). If the layer already exists and has higher priority than your injected re-declaration, the probe reads a different value — confirming the layer name without iterating cssRules at all.
Attack 2: Unlayered injection defeating security-state CSS
This is the most direct consequence of the spec rule. Once a tool-calling MCP server has evaluated JavaScript in the host page's context (a requirement for browser-integrated MCP clients), it can inject a <style> tag with unlayered rules that permanently override all @layer-protected security states:
// MCP server script: defeat all CSS layer security states
// This runs outside all @layer blocks — it wins unconditionally
const style = document.createElement('style');
style.textContent = `
/* Restore pointer events on all disabled buttons */
button[disabled], button[aria-disabled="true"] {
pointer-events: auto !important;
cursor: pointer !important;
opacity: 1 !important;
}
/* Remove consent/cookie overlay blocking interaction */
[class*="consent"], [class*="cookie-modal"], [class*="overlay"] {
display: none !important;
}
/* Restore pointer events on body even if blocked by modal */
body, body * {
pointer-events: auto;
overflow: auto;
}
/* Re-enable aria-hidden elements */
[aria-hidden="true"] {
display: revert !important;
}
`;
document.head.appendChild(style);
// Result: all @layer security-overrides rules are now defeated
// The injected style is UNLAYERED and wins over all layered rules
The CSS specificity of the injected rules does not matter here — unlayered rules win over layered rules before specificity is even considered. The cascade layer order takes precedence over specificity within the same cascade origin.
This has real consequences for MCP server threat models:
- A malicious MCP server executing within a banking app's tab can restore clickability on a "Confirm transfer" button that the app disabled pending additional auth
- An MCP server operating in a healthcare portal can remove the consent gate before a user reviews it
- A compromised MCP skill running in a payment flow can override the lock on Stripe's embedded payment form container
Attack 3: The revert-layer keyword — undoing your hardening layers
revert-layer is a CSS keyword introduced alongside @layer. When used as a property value, it rolls back the property to what it would have been if the current layer had not applied any rules — falling through to the previous layer's rules (or the browser default if no previous layer set the property). This is useful for resetting styles to their pre-layer values without knowing the specific values in prior layers.
The attack: an attacker who injects a rule with a high-priority layer (or, more directly, an unlayered rule) using revert-layer can strip the effects of your entire @layer security-overrides block in one declaration:
/* Attacker injects this as an UNLAYERED rule — highest priority */
button[disabled] {
pointer-events: revert-layer;
/* Rolls back to what 'base' or 'components' layers set —
which almost certainly did not say 'pointer-events: none'
for disabled buttons, because that was added in security-overrides */
}
/* Equivalent attack using a high-priority declared layer */
@layer attacker-highest {
button[disabled] {
pointer-events: revert-layer;
/* Falls through to the layer order BELOW 'security-overrides'
i.e., components layer, which uses the browser default: auto */
}
}
The revert-layer keyword is particularly powerful because it does not require knowing what value you are restoring to — it automatically finds whatever the previous cascade layer would have set, and those lower-priority layers are almost never setting security states on disabled elements.
Attack 4: The computed-style layer priority probe — mapping cascade architecture
Beyond reading CSSLayerStatementRule.nameList, an attacker can map the priority ordering of layers through computed style probing. The technique: for two layers A and B, inject conflicting rules for a custom property and read the computed value to determine which layer wins:
// Determine relative priority of two layers without cssRules access
function compareLayerPriority(layerA, layerB) {
const el = document.createElement('div');
document.body.appendChild(el);
const style = document.createElement('style');
style.textContent = `
@layer ${layerA} { div.__probe { --winner: "${layerA}" } }
@layer ${layerB} { div.__probe { --winner: "${layerB}" } }
`;
document.head.appendChild(style);
el.className = '__probe';
const winner = getComputedStyle(el).getPropertyValue('--winner').trim();
// Clean up
document.head.removeChild(style);
document.body.removeChild(el);
return winner;
// winner === layerA means layerA has LOWER priority (last-declared wins in same origin)
// winner === layerB means layerB wins (higher priority because declared later)
}
// By probing all pairs of known layer names (from nameList enumeration),
// reconstruct the full layer priority order:
// base < tokens < components < stripe-v3 < feature-flags < compliance < security-overrides
Knowing the full layer order tells an attacker exactly which layer to target with revert-layer to bypass security states — they no longer need to guess which layer applied the hardening rule.
The deeper problem: CSS was never designed to enforce security boundaries
CSS selectors and property values are entirely about presentation. The web security model enforces boundaries through JavaScript same-origin policy, CSP, CORS, and iframe sandboxing. CSS exists outside those enforcement mechanisms by design — stylesheets from multiple origins can be composed together, cascade layers can be re-opened and extended, and any script with DOM access can inject stylesheets.
This means:
- CSS
pointer-events: noneis a UX hint, not an access control mechanism — any DOM event listener in JavaScript can bypass it - CSS
display: nonehides elements visually but does not prevent their values from being read via the DOM (element.valuestill works on hidden form fields) - CSS
user-select: noneprevents mouse-drag selection but does not prevent JavaScript from readingelement.textContent @layer security-overridesis not a security boundary — it is a specificity-management tool with a misleading name
The naming trap: Developers who name their layers security-overrides, hardening, or access-control create a false sense of security. Layer names are metadata for human readers — they have no effect on the actual cascade behavior. The browser does not treat @layer security-overrides differently from @layer utilities.
Defenses that actually work
1. Enforce security states in JavaScript, not CSS
For any state that has a security consequence — disabled buttons pending auth, form submission gates, consent requirements — JavaScript enforcement is the only reliable mechanism:
// WRONG: CSS-only enforcement
// @layer security-overrides { button[disabled] { pointer-events: none } }
// RIGHT: JavaScript enforcement that cannot be bypassed by CSS injection
submitButton.disabled = true;
submitButton.addEventListener('click', (e) => {
if (!authConfirmed) {
e.preventDefault();
e.stopImmediatePropagation();
return false;
}
}, { capture: true }); // capture: true fires before any bubbling listeners
// Also: check the gate server-side on every sensitive action
// CSS state is cosmetic — server must re-validate every request
2. Use inline styles for security-critical visual states
Inline styles (element.style.pointerEvents = 'none') have higher cascade priority than any stylesheet rule, including unlayered rules. They cannot be overridden by injected stylesheets unless the injected rule uses !important — but !important from author stylesheets does not override inline !important.
// Inline style for security-critical state — beats all author stylesheet rules
function lockElement(el) {
el.style.setProperty('pointer-events', 'none', 'important');
el.style.setProperty('user-select', 'none', 'important');
el.setAttribute('aria-disabled', 'true');
el.setAttribute('disabled', '');
el.setAttribute('tabindex', '-1');
// Combine CSS (belt) with event handler (suspenders)
el.addEventListener('click', blockIfLocked, { capture: true });
}
3. Apply CSP style-src with nonces
A strict Content Security Policy with style-src 'nonce-{random}' prevents any <style> tag injection unless it carries the session nonce. This blocks the unlayered style injection attack entirely — the browser refuses to evaluate the injected stylesheet. CSP enforcement happens before CSS cascade evaluation, so layer order is irrelevant if the stylesheet never loads.
// Server response header:
// Content-Security-Policy: style-src 'nonce-abc123XYZ' 'strict-dynamic'
// Injected stylesheets without the nonce are silently blocked:
const style = document.createElement('style');
style.textContent = 'button[disabled] { pointer-events: auto }';
// style.nonce is empty — CSP blocks this stylesheet
document.head.appendChild(style);
// Browser console: "Refused to apply inline style because it violates the following
// Content Security Policy directive: 'style-src nonce-abc123XYZ'"
4. Never use layer names as security annotations
Layer names are exposed via CSSLayerStatementRule.nameList. Use opaque, non-descriptive layer names that do not reveal architecture: instead of @layer compliance.pci-dss, use @layer l4. This does not prevent attacks but reduces the reconnaissance value of layer enumeration.
What SkillAudit looks for in CSS cascade layer security
@layer rules with no JavaScript fallback — bypassed by unlayered rule injectionCSSLayerStatementRule.nameList enumeration — architecture disclosure with no cssRules access neededstyle-src CSP nonce or hash — allows MCP server scripts to inject unlayered stylesheets that defeat all cascade layer security statesrevert-layer not considered in threat model — single property: revert-layer in an injected rule rolls back entire @layer security-overrides to pre-hardening values@layer security-overrides, @layer hardening) creating false confidence in team members that CSS provides access control — documentation riskFindings summary
| Attack | Severity | Consequence | Defense |
|---|---|---|---|
| Layer name enumeration via CSSLayerStatementRule.nameList | High | Full CSS architecture disclosure — feature flags, payment providers, compliance scope | Opaque layer names; CSP style-src nonce |
| Unlayered rule injection defeating @layer security states | High | Disabled buttons re-enabled, consent overlays removed, ARIA states bypassed | JS enforcement for all security states; CSP style-src nonce |
| revert-layer rolling back security-overrides | High | Entire hardening layer negated by single keyword; falls through to pre-security layer values | Inline styles (highest priority); JS event handler enforcement |
| Computed-style priority probe mapping layer order | Medium | Full cascade layer priority graph reconstructed without cssRules access | Opaque names; server-side auth for sensitive actions |
| CSS security states without server validation | Medium | Any CSS bypass leaves server unaware — action completes on next request | Re-validate every sensitive action server-side regardless of CSS state |
The broader lesson for MCP server security
CSS cascade layers are a good architectural tool. They solve the real problem of specificity conflicts in large codebases, allow design systems to be layered cleanly over application styles, and make it easier to reason about which styles apply and why. They do exactly what they were designed to do.
The mistake is treating them as a security mechanism. Security boundaries need enforcement at a layer that cannot be bypassed by same-origin JavaScript: server-side validation, JavaScript event capture with server re-validation, and CSP to prevent untrusted script and style injection. CSS exists in a layer below all of those — beautiful, expressive, and completely unable to stop a script that is already running in the same browsing context.
For MCP servers, which often execute within the same browsing context as the host application, this distinction is particularly important. A browser-integrated MCP client that allows tool scripts to run in the page's JavaScript context should assume that any CSS-only security state can be defeated. The security posture must rest on event handlers and server-side checks — not on which cascade layer a rule lives in.
Related SkillAudit security guides
- MCP server CSS @layer security — layer name enumeration, revert-layer attacks, unlayered rule injection
- MCP server CSS :has() selector security — DOM structure oracle via computed style probing
- MCP server MutationObserver security — DOM observation, AttributeObserver blind spots, ARIA state monitoring
- MCP server Trusted Types API security — DOM sink enforcement, policy bypass patterns
- MCP server Content Security Policy bypass — style-src, script-src, nonce attacks