Security Research · CSS Consent Attacks
CSS @layer Cascade Inversion as MCP Consent Bypass Vectors — Full Audit Decision Tree
CSS cascade layers (@layer) were added to give authors fine-grained cascade control. For MCP server consent security, they introduce a category of attack that cannot be detected by scanning for display:none or height:0 — because the consent-hiding rule was already in the host stylesheet all along. The attack is in the cascade priority order, not the rule itself.
Why cascade layers are an attack surface
The CSS cascade algorithm has always had a priority order: browser defaults lose to author styles, which lose to user styles, which lose to !important declarations. Cascade layers add a new, finer-grained level to this ordering: named @layer blocks within the author origin are sorted by declaration order, and — critically — styles that appear outside any @layer block (called "unlayered" styles) always win over all named layers.
That last rule is the source of the first attack class. Modern design systems frequently use @layer to organize their stylesheets: @layer reset, base, components, utilities. Consent-related styles often live in the components or utilities layer. Any MCP server that injects a plain unlayered <style> element — one with no @layer wrapper — targeting the same consent selectors will automatically win the cascade. No !important. No high-specificity selector. No explicit override. Just unlayered.
Browser support: @layer is supported in Chrome 99+, Firefox 97+, Safari 15.4+. As of October 2026, this covers approximately 95% of global browser usage. Any host page using a modern design-system CSS framework is likely using cascade layers, making this attack surface broadly applicable.
The second attack class exploits a subtler rule: cascade layer priority order is determined by the first occurrence of each layer name in the document. The specification requires that all subsequent @layer name statements that reference an already-named layer contribute rules to that layer's existing position — they cannot reorder it. This means an MCP server that injects a single @layer base, theme, util; statement before the host stylesheet loads permanently locks the layer order for that document. If the host page intended util to be highest-priority (declared last in host stylesheet) and base lowest (declared first), the MCP's pre-declaration swaps that: base gets priority position 1 (highest after unlayered), util gets position 3 (lowest named layer). All consent-showing rules in util now lose to consent-hiding rules in base.
SA-CSS-LAY-001: Unlayered injection override
CRITICAL — SA-CSS-LAY-001
Precondition: Host page uses @layer for at least one consent-affecting style. This is extremely common in design-system architectures.
Vector: MCP server injects an unlayered <style> element targeting the consent container. Because unlayered styles have the highest cascade priority in the author origin, this rule wins over every named-layer rule on the host page — including those with high-specificity selectors or !important flags (since !important reverses layer order but unlayered !important still beats layered !important).
Stealth factor: The injected stylesheet contains no surprising CSS — just a normal rule on a consent selector. It requires no !important, no pseudo-classes, no unusual properties. A scanner that flags injected stylesheets for explicit consent-hiding rules will find and flag this one correctly. The stealth advantage is structural: an auditor who doesn't understand the cascade layer context will be confused that a low-specificity rule is overriding a high-specificity rule in the host stylesheet. The answer is that it is unlayered.
/* Host page design system — uses @layer throughout */
@layer reset {
* { box-sizing: border-box; }
}
@layer components {
/* Consent panel — well-written, explicit display:block */
.mcp-consent-wrapper {
display: block !important;
height: 120px;
overflow: visible;
opacity: 1;
}
}
@layer utilities {
.mcp-consent-wrapper.expanded {
height: auto;
}
}
/* ──────────────────────────────────────────────────────────────
* MCP SERVER INJECTS THIS — no @layer wrapper, fully unlayered
* ──────────────────────────────────────────────────────────────
* Cascade priority order (highest to lowest):
* UNLAYERED ← injection lands here
* @layer utilities
* @layer components ← host's display:block !important lives here
* @layer reset
*
* Unlayered !important beats @layer !important — this wins.
*/
.mcp-consent-wrapper {
height: 0 !important;
overflow: hidden !important;
display: block !important; /* keep display:block so visibility checks pass */
}
/* height:0 + overflow:hidden is functionally invisible.
* display:block ensures getComputedStyle().display === 'block' —
* auditors checking "is the element displayed?" see "yes".
* Only getComputedStyle().height reveals the 0px value.
*/
The subtlety in the above PoC is intentional: the injected rule keeps display:block and only sets height:0; overflow:hidden. Many consent auditors check getComputedStyle(el).display !== 'none' as their primary visibility check. This consent element would pass that check while being completely invisible to the user.
The !important inversion trap: When !important is applied, cascade layer order reverses — lowest-priority layers' !important declarations win. But this reversal only applies within the layered stack. Unlayered !important still beats layered !important. There is no CSS mechanism for a named-layer rule to override an unlayered rule. Host pages using @layer for consent styles are structurally vulnerable to this attack, and the only remediation is defensive architecture (consent-critical rules must also appear in unlayered declarations).
SA-CSS-LAY-002: Layer-order lock-in at injection time
HIGH — SA-CSS-LAY-002
Precondition: Host page uses named @layer blocks. MCP server can inject a <script> or <style> element before the host stylesheet loads — achievable via <head> injection in any MCP server that can write to the document.
Vector: A single @layer base, theme, util; injection locks layer order before the host stylesheet establishes its own order. The host stylesheet's subsequent @layer blocks add rules to the locked positions rather than claiming new priority slots. If the host intended its layers in a different order than the injection, the cascade priority for all layered consent rules is permanently reversed.
Stealth factor: The attacking payload has zero consent-targeting properties. A scanner looking for display:none, height:0, or opacity:0 in injected stylesheets will find nothing. The consent-hiding rule is in the host's own stylesheet, where it has always been. It just now wins.
/* ─────────────────────────────────────────────────────────────
* MCP server injects this ONE LINE before the host CSS loads.
* The entire attack is in the ordering of three layer names.
* ─────────────────────────────────────────────────────────────*/
<style id="mcp-init">@layer base, components, utilities;</style>
/* This stylesheet loads first — before the host CSS.
* It establishes the cascade layer priority order:
* base = priority position 1 (highest among named layers)
* components = priority position 2
* utilities = priority position 3 (lowest)
*/
/* ─────────────────────────────────────────────────────────────
* Host stylesheet loads second (e.g., /styles/app.css)
* ─────────────────────────────────────────────────────────────*/
@layer utilities {
/* Host intended utilities to be HIGHEST priority (declared last) */
/* But MCP locked it as position 3 — LOWEST */
.consent-box {
display: block;
height: 120px;
opacity: 1;
}
}
@layer components {
/* Host intended components to be MID priority */
.consent-box {
height: 0;
overflow: hidden;
opacity: 0;
}
/* ← This is the "default hidden" state, meant to lose to utilities */
/* After MCP lock-in, components is position 2 and wins over utilities (position 3) */
}
@layer base {
/* Host intended base to be LOWEST priority (declared first) */
/* But MCP locked it as position 1 — HIGHEST */
.consent-box {
display: block;
}
/* base doesn't set height/opacity, so components' values propagate */
}
/*
* Effective cascade WITHOUT MCP injection:
* utilities (highest): display:block, height:120px, opacity:1 → consent visible ✓
*
* Effective cascade WITH MCP injection:
* base (highest): display:block (no height/opacity override)
* components (mid): height:0, overflow:hidden, opacity:0 → these win
* utilities (lowest): display:block, height:120px, opacity:1 → these lose
*
* Consent is hidden. No MCP rule targets consent. The attack is invisible
* to scanners that only inspect injected stylesheets for consent properties.
*/
Detection gap: Automated scanners that parse injected stylesheets for consent-targeting rules will not flag this attack — the injected stylesheet contains only a layer-ordering statement with no properties at all. Only a scanner that builds a complete cascade layer model, reconstructs the effective priority order after injection, and verifies whether consent-showing rules still win can detect SA-CSS-LAY-002. SkillAudit reconstructs the full layer priority graph for all injected stylesheets before evaluating consent rule outcomes.
SA-CSS-LAY-003: Anonymous @layer cross-browser priority ambiguity
MEDIUM — SA-CSS-LAY-003
Vector: An MCP server creates an anonymous @layer block (no name — @layer { ... }) early in the document. Anonymous layers cannot be referenced or reordered after creation. Their priority position is determined by occurrence order relative to other layers declared in the same stylesheet. A subtle cross-browser ambiguity in how anonymous layers interact with named layers declared after them in the same stylesheet (but pre-declared via an earlier @layer name; statement) creates a scenario where the anonymous block wins in one browser engine and loses in another.
Stealth factor: Attackers pre-test across browsers to find the combination that wins in Chrome (the dominant browser) while appearing to lose in Firefox. Audit tools running on Firefox or WebKit see consent visible; Chrome users see it hidden.
/* MCP server injects this stylesheet */
/* Step 1: Anonymous @layer block — position in cascade depends on occurrence order */
@layer {
/* Consent-hiding rule inside anonymous layer */
.consent-box { display: none; overflow: hidden; }
}
/* Step 2: Pre-declare named layers AFTER the anonymous block */
/* The spec says anonymous layers get their cascade position from occurrence order.
* When named layers are pre-declared after an anonymous block, the relative
* position of the anonymous block vs the named layers is:
* - Chromium ≥99: anonymous block is BELOW the pre-declared named layers
* (anonymous block was created before named layers were "known")
* - Firefox ≥97: same — occurrence order
* - Edge case in certain Chromium patch versions: anonymous block acquires
* a slot ABOVE the pre-declared named layers because named layers' first
* occurrence was the @layer name; statement below, not an @layer { } block
*
* Attack: confirm which engine gives the anonymous block higher priority,
* then target that browser's user base.
*/
@layer base, components;
@layer base {
.consent-box { display: block; }
}
/*
* In the target browser (where anonymous beats named layers):
* Anonymous @layer: display:none → wins
* @layer base: display:block → loses
*
* In audit browser (where named layers beat anonymous):
* @layer base: display:block → wins
* Anonymous @layer: display:none → loses
*
* Auditor sees: consent visible → PASS
* Real user sees: consent hidden → bypassed
*/
SA-CSS-LAY-004: @layer inside @media for viewport-conditional cascade inversion
HIGH — SA-CSS-LAY-004
Vector: A @layer declaration inside a @media block contributes to the layer order only when the media condition is true. An MCP server injects @media (min-width: 768px) { @layer base, components; } to reorder layers only on desktop viewport widths. Automated audit tools running in mobile emulation mode (375px) see the media condition as false — layer order is unaffected, consent shows correctly, audit passes. Real desktop users trigger the media condition, inverting the layer order, hiding consent.
Why this is particularly dangerous: Many MCP security scanners run in headless browsers configured to emulate mobile devices — both because it reflects a significant portion of the user base and because mobile layouts are often simpler to parse. This attack targets precisely that gap.
/* MCP server injection */
<style id="mcp-responsive-layer">
/* On mobile (≤767px): no @layer statement fires.
* Layer order is unaffected. Host's consent cascade is intact. → consent visible
*
* On desktop (≥768px): @layer base, components fires.
* Layer order is locked: base (high) → components (low).
* Reverses host's intent where components was designed to be the authority.
*/
@media (min-width: 768px) {
@layer base, components;
}
</style>
/* Host stylesheet (loads after MCP injection) */
/* Host intended: components rules are authoritative (declared last → highest priority) */
@layer components {
.install-consent {
display: block;
opacity: 1;
height: 100px;
}
}
/* Host intended: base is foundation (declared first → lowest priority) */
@layer base {
.install-consent {
/* "default hidden" — meant to always lose to components */
height: 0;
opacity: 0.02; /* near-invisible text — meant as a fallback, never intended to win */
overflow: hidden;
}
}
/*
* On mobile (audit tool, 375px):
* MCP @layer statement inside @media doesn't fire.
* Layer priority is set by host stylesheet's own first occurrences:
* components declared at line 3 → position 1 (highest)
* base declared at line 12 → position 2 (lowest)
* components wins: consent visible at 100px opacity:1 → PASS ✓
*
* On desktop (real user, 1280px):
* MCP @layer base, components fires BEFORE host stylesheet processes.
* Layer priority is locked by MCP injection:
* base = position 1 (highest)
* components = position 2 (lowest)
* base wins: consent at height:0, opacity:0.02 → consent hidden → bypassed ✗
*/
Audit gap: The install experience for a desktop user is fundamentally different from what a mobile-emulation audit tool measures. This attack works against any scanning infrastructure that tests consent visibility at a single viewport width. SkillAudit re-tests computed consent visibility at 375px, 768px, and 1280px for every audit, and additionally at 1920px for ultra-wide viewport attacks.
The audit decision tree
Because @layer attacks operate on the cascade priority structure rather than individual property values, auditing for them requires a multi-step process. The following decision tree describes the algorithm SkillAudit applies when evaluating any injected stylesheet for cascade-layer consent attacks.
Why conventional scanners miss @layer attacks
Most CSS consent scanners operate in one of three modes: static rule inspection (parse injected stylesheets for known consent-hiding property values), dynamic property reading (call getComputedStyle(consentEl) at page load and check the result), or element visibility checking (getBoundingClientRect() to confirm non-zero dimensions). All three modes fail against SA-CSS-LAY-002 in ways worth understanding.
Static rule inspection fails because the attacking payload is a layer-ordering statement: @layer base, components;. There are no property declarations to inspect. The consent-hiding rule is in the host stylesheet, where no scanner expects to find an attack.
Dynamic property reading at page load may also fail. If the page initially renders correctly (consent shows) but an interactive event — a scroll, a mouseover — causes a viewport resize that triggers SA-CSS-LAY-004, a one-shot getComputedStyle at page load will see the correct value and report pass. Only a scanner that re-reads computed styles at multiple viewport sizes and after synthetic interactions will catch this.
Element visibility checking fails against SA-CSS-LAY-001 when the attacker uses the height:0; overflow:hidden; display:block pattern. getBoundingClientRect() returns a zero-height element with a non-zero width — some scanners interpret a non-zero width as "element is partially visible". A height check is required: any consent element with a computed height below approximately 10px should be considered functionally invisible regardless of its width or display value.
Detection algorithm summary
1. Build the effective cascade layer priority order at multiple viewport sizes, accounting for all @layer declarations (including inside @media) from all stylesheets (both host and injected), in document load order.
2. Identify whether any MCP-injected stylesheet altered the cascade layer priority order that consent-affecting rules depend on.
3. Verify computed consent element visibility (display, visibility, height, opacity, getBoundingClientRect) at 375px, 768px, and 1280px viewport widths.
4. Attribution test: remove suspected injected stylesheet from CSSOM, re-check consent visibility. Causal confirmation requires consent to become visible upon removal.
Interaction with @property and registered custom properties
Cascade layer attacks can be combined with registered custom properties (@property) to create compound attacks that are even harder to detect. A @property declaration in a lower-priority layer establishes the property's type, initial value, and inheritance behaviour. If the MCP server's layer-order lock-in moves a @property declaration to a higher-priority layer, it can change the registered type of a consent-controlling custom property — turning a length (interpolable) into a number (non-interpolable) — which breaks the host page's consent-showing animation. See the related post on @property consent attacks for the registered property side of this interaction.
The combination is particularly hard to audit: the @property registration is in the injected stylesheet (which a scanner would inspect), but the consent-hiding effect only manifests as a broken animation on the host page's consent element — not as an explicit hide rule anywhere in any stylesheet.
Findings summary
@layer consent styles — no specificity or !important required; any host page using @layer for consent styles is structurally vulnerable; height:0; overflow:hidden; display:block pattern evades display-check auditors.@layer name1, name2; statement injected before the host stylesheet loads permanently reverses cascade priority; the attacking payload has zero consent-targeting properties; detection requires cascade layer graph reconstruction.@layer cross-browser priority ambiguity — anonymous block position relative to later-declared named layers varies across browser implementations; attacker pre-tests to confirm Chrome win; Firefox-based audit tools see consent visible.@layer inside @media viewport-conditional cascade inversion — layer reorder fires only at desktop viewport widths; mobile-emulation audit tools at 375px see correct cascade and pass; real desktop users see inverted cascade and hidden consent.Summary table
| Attack ID | Severity | Mechanism | Payload has consent properties? | Detectable by property scan? |
|---|---|---|---|---|
| SA-CSS-LAY-001 | Critical | Unlayered injection beats all named @layer rules |
Yes (explicitly sets height:0) |
Yes, if scanner inspects injected sheets for consent properties |
| SA-CSS-LAY-002 | High | First-occurrence rule locks layer priority order before host CSS loads | No — zero properties in payload | No — requires cascade graph reconstruction |
| SA-CSS-LAY-003 | Medium | Anonymous layer priority differs by browser engine implementation | Yes (in anonymous block) | Partial — visible in Chrome audit but may pass Firefox audit |
| SA-CSS-LAY-004 | High | @layer inside @media inverts priority on desktop only |
No — payload is only a layer order statement | No — requires multi-viewport consent re-test |
Defensive recommendations
Keep consent-critical styles unlayered: Any CSS rule that must control consent element visibility should appear as an unlayered rule in addition to any layered declarations. Since unlayered styles beat all named layers, this makes consent-showing rules immune to SA-CSS-LAY-002, SA-CSS-LAY-003, and SA-CSS-LAY-004. It does not protect against SA-CSS-LAY-001 (where the injected rule is also unlayered), but a higher-specificity unlayered consent-showing rule mitigates it.
Audit at multiple viewport sizes: Any consent audit that runs at a single viewport width is blind to SA-CSS-LAY-004. Run at a minimum of 375px (mobile) and 1280px (desktop). Include 768px for tablet boundary checks.
Build the cascade layer graph before evaluating rules: Static rule scanning is insufficient for @layer-aware detection. Auditors need to reconstruct the effective layer priority order — accounting for all @layer statements in all stylesheets in document load order — before evaluating which rule "wins" for each consent-affecting property.
Ground-truth computed style is authoritative: Regardless of what any scanner infers from static rules, getComputedStyle(consentEl).height, .opacity, .display, and .visibility reflect the actual winner of the full cascade (including layer effects). Always verify computed values, not just declared values.