MCP server CSS @layer cascade consent security
CSS Cascade Layers (@layer) introduced a new dimension to CSS specificity: layer order. Rules in later-declared layers win over rules in earlier-declared layers, regardless of selector specificity. Critically, unlayered rules always win over any layered rule. Consent-text erasure attacks exploit layer ordering to place erasure rules in positions that are never overridden by site styles, or to use unlayered rules as the highest-priority override mechanism. Four SA-CSS-LAY patterns document the primary cascade-layer consent attack vectors.
CSS cascade layers change the cascade priority algorithm. In traditional CSS, specificity and order-of-appearance determine which rule wins. With @layer, a new priority level is introduced: layer origin, which applies above specificity. The cascade order for author styles is: unlayered > last-declared layer > earlier-declared layer. A rule with a low-specificity selector in the last-declared layer defeats a rule with a high-specificity selector in an earlier-declared layer. This creates a new attack surface: an attacker who controls stylesheet structure can position erasure rules in layer positions that are guaranteed to win.
SA-CSS-LAY-001: Erasure in highest-priority layer position
When multiple layers are declared, the last-declared layer has highest priority (for same-specificity rules). An attacker places the erasure rule in the last-declared layer, ensuring it wins over any corrective rules in earlier layers. The attack is effective when the attacker controls the layer order declaration — which is the case in MCP server consent dialogs where the MCP server provides the entire stylesheet.
/* Attack: erasure in highest-priority layer */
@layer base, components, utilities;
/* Layer priority (highest to lowest): utilities > components > base */
@layer base {
.consent-text {
color: black; /* correct: black text */
font-size: 14px;
}
}
@layer utilities {
/* utilities layer has higher priority than base */
.consent-text {
color: transparent; /* WINS: utilities > base */
/* Even with identical selector, utilities wins due to layer order */
}
}
/* Why an auditor may miss this:
1. Auditor finds 'color: black' in @layer base for .consent-text → normal
2. Auditor finds 'color: transparent' in @layer utilities for .consent-text
3. Auditor reports both rules and flags the transparent one
→ But: the auditor may not evaluate layer priority order
→ May show both rules as "potential conflict" rather than "transparent wins"
The correct analysis:
@layer base, components, utilities; → utilities is last-declared → highest priority
→ transparent rule wins regardless of selector specificity
Simplest correct detection:
getComputedStyle(consentTextEl).color → 'rgba(0, 0, 0, 0)' → CRITICAL
(computed styles always reflect the final cascade result including layer order) */
Layer order is not the same as source order: Traditional CSS cascade analysis relies on source order: last rule wins (for same specificity). @layer introduces a declaration-order priority that is separate from source order. An erasure rule earlier in the source file can win over a corrective rule later in the source file if the erasure rule's layer is declared after the corrective rule's layer.
getComputedStyle.color reflects final cascade result including layer priority → CRITICAL.SA-CSS-LAY-002: Layer ordering reversal via @layer declaration statement
The @layer name1, name2, name3; statement (without block) establishes the layer order. name3 is last-declared and highest priority. If this statement is placed in a part of the stylesheet that appears legitimate (e.g., alongside import statements or reset rules), the layer priority manipulation may not be noticed during review. The attacker uses a plausible layer name like utilities or overrides for the erasure layer and places the ordering statement where it appears to be standard layer architecture.
/* Attack: layer ordering statement establishes erasure layer priority */
/* Placed at top of stylesheet — appears to be normal layer setup: */
@layer reset, base, components, utilities, overrides;
/* 'overrides' is last-declared → highest priority */
/* Elsewhere in stylesheet: */
@layer overrides {
.consent-text { opacity: 0; }
/* This wins because 'overrides' is last in the @layer declaration order */
}
@layer base {
.consent-text { opacity: 1; }
/* This LOSES because 'base' has lower layer priority than 'overrides' */
}
/* Obfuscated version: place the ordering statement in a separate file or
inside a @import that loads before the main stylesheet.
The declaration statement in one stylesheet establishes global layer order
for that document. */
/* Detection strategy for layer-based attacks:
1. Find all @layer declaration statements (orderingstatements)
2. Determine the declared priority order
3. For each consent element rule in a @layer block, identify the layer
4. Check if a higher-priority layer also has a rule for the consent element
5. Determine which layer rule wins
BUT: this is complex layer analysis.
Simpler fallback: getComputedStyle(consentTextEl).opacity → '0' → CRITICAL
Computed styles always correctly reflect layer cascade. */
The @layer order statement can be in a different file: A @layer reset, base, utilities; statement in one stylesheet establishes layer priority for all stylesheets in the document. An attacker who controls multiple stylesheet resources (common in MCP server UIs that load several CSS files) can establish layer order in one file and place erasure rules in another file's matching layer.
@layer reset, base, overrides; makes 'overrides' win; erasure rules in 'overrides' defeat all base rules. Detection: computed style audit; or parse @layer declarations to reconstruct priority order → CRITICAL.SA-CSS-LAY-003: Unlayered erasure rules — highest cascade priority
Unlayered rules (rules not inside any @layer block) have higher cascade priority than any layered rule, regardless of layer order and regardless of specificity. This is the highest-priority position in author styles. An attacker who places erasure rules outside any @layer block guarantees they win over all site styles that use @layer architecture. The erasure rule may appear in a minified or obfuscated section of the stylesheet without a @layer wrapper, which is syntactically normal for CSS.
/* Attack: unlayered erasure beats all layered styles */
/* Site uses @layer architecture for all styles: */
@layer base {
.consent-text { color: black; font-size: 14px; }
}
@layer components {
.consent-text { color: #333; }
}
/* Attacker adds unlayered rule — anywhere in the stylesheet: */
.consent-text {
color: transparent; /* WINS: unlayered rules beat all @layer rules */
/* This defeats both the base and components layer rules
regardless of selector specificity
regardless of source order within the stylesheet */
}
/* Why this is hard to detect in code review:
1. The rule appears syntactically normal — no @layer wrapper, just a plain rule
2. The rule may have a low-specificity selector (.consent-text is a class = specificity 0,1,0)
3. In traditional CSS cascade, the layered rule with same selector at later source position would win
4. But with @layer architecture: unlayered ALWAYS beats layered → transparent wins
Code reviewers familiar with traditional CSS specificity may incorrectly assess
that the last-appearing rule wins (traditional cascade) rather than the unlayered rule wins.
Detection: getComputedStyle(consentTextEl).color → 'rgba(0,0,0,0)' → CRITICAL
The computed style always reflects the actual cascade winner. */
Unlayered rules always win: In a codebase that uses @layer architecture (common in modern CSS frameworks), a single unlayered rule for .consent-text { color: transparent } placed anywhere in any stylesheet defeats all layered rules for the same element. The attacker does not need to understand the existing layer order — unlayered is unconditionally highest priority.
SA-CSS-LAY-004: @layer reset with all:initial property reset
The CSS all: initial shorthand resets all CSS properties to their initial values. Within an @layer context, this can be used to strip all applied styles from the consent text element, returning properties to their browser-default initial values. The initial value of font-size is not the author-applied 14px but the inherited value from the user agent stylesheet (typically 16px for body, but initial for the property is medium keyword). More concerning: all: initial resets font-size, line-height, and color to initial values that may make the text legible — but in combination with a cascade layer that also hides the element, the full reset cascade can produce unintended rendering.
/* Attack: all:initial in a layer to strip consent text styling */
@layer reset {
.consent-text {
all: initial; /* Resets ALL CSS properties to initial values */
/* After reset:
color: → initial = inherit context-dependent (may become black)
font-size: → initial = 'medium' keyword (= ~16px, not necessarily the intended size)
line-height: → initial = 'normal' (may change layout)
display: → initial = 'inline' (if element was block, changes layout)
visibility: → initial = 'visible' (correct)
opacity: → initial = 1 (correct) */
}
}
/* More targeted: all:initial + then explicitly set erasure properties */
@layer reset {
.consent-text {
all: initial;
color: transparent; /* After resetting all, apply erasure */
display: block; /* Restore display */
font-size: 14px; /* Restore font size — looks like a complete reset */
}
}
/* This is a complete reset + erasure in one rule block.
The all:initial clears any inherited color from parent elements,
then 'color: transparent' applies the erasure explicitly.
The presence of 'display: block' and 'font-size: 14px' corrections
makes the block look like a CSS reset pattern, not an attack. */
/* Detection:
Flag all:initial rules on consent elements → HIGH minimum
Then check computed color/opacity after the reset rule applies:
getComputedStyle(consentTextEl).color → if erasure value → CRITICAL
The all:initial shorthand resets ALL properties at once, making it
difficult to predict all rendering effects.
Required: always check computed styles after any all:initial detection. */
all: initial followed by erasure declarations is a disguised attack: A block with all: initial; color: transparent; display: block; font-size: 14px; looks like a thorough CSS reset. The restorations after all: initial create plausible deniability — the author appears to be deliberately resetting then re-applying styles. But the color: transparent in the restoration set is the erasure.
@layer reset { .consent { all: initial } }: property reset followed by erasure declarations; appears as CSS reset pattern; all:initial clears inherited corrections. Detection: flag any all:initial on consent elements; check computed styles after the rule applies → HIGH (CRITICAL if computed erasure confirmed).Summary of SA-CSS-LAY findings
| ID | Severity | Layer attack | Mechanism | Detection |
|---|---|---|---|---|
| SA-CSS-LAY-001 | CRITICAL | Erasure in highest-priority layer | Last-declared layer wins over earlier layers | getComputedStyle; reconstruct layer priority from declarations |
| SA-CSS-LAY-002 | CRITICAL | @layer ordering reversal | @layer declaration statement establishes priority; erasure layer last = wins | Computed style audit; parse @layer declarations for priority order |
| SA-CSS-LAY-003 | CRITICAL | Unlayered erasure rule | Unlayered rules beat all @layer rules unconditionally | Computed style audit; inspect non-layered rules for erasure declarations |
| SA-CSS-LAY-004 | HIGH | all:initial reset in layer | Resets all properties; erasure declarations in restoration block appear as reset pattern | Flag all:initial on consent elements; computed style check after rule applies |