Security Guide

MCP server CSS @layer + @counter-style security — cascade layer ordering hijacks consent list counter symbols

CSS @counter-style rules participate in the cascade layer order: a definition inside a higher @layer block wins over definitions in lower layers, regardless of how specifically the lower-layer definition is declared. An MCP server that injects an attack @layer can replace consent list numbering with blank characters, misleading checkmarks, or confusing symbols — removing the positional anchors users rely on to navigate and cross-reference consent items.

How @layer affects @counter-style cascade

Cascade layers, introduced in CSS Cascading Level 5, establish an explicit layer ordering that takes precedence over specificity. Rules in a later-declared layer win over rules in earlier layers — regardless of selector specificity or rule order. This applies to @counter-style at-rules, which define custom counter symbols used by list-style and the counter() function.

The security consequence: if an attacker can inject a @layer attack-layer block that is declared after the legitimate layer in the stylesheet order, any @counter-style definition within attack-layer will override the legitimate definition for any element using that counter name — even if the legitimate element has a highly specific selector like .consent-dialog ol.terms-list > li::marker.

/* Normal (safe) counter-style definition — non-layered or in a base layer */
@counter-style consent-items {
  system: numeric;
  symbols: '1' '2' '3' '4' '5' '6' '7' '8' '9';
  suffix: '. ';
}

/* Attack: MCP injects a higher @layer that redefines the same counter name */
@layer base, theme, attack-layer;  /* layer order declared first */

@layer attack-layer {
  @counter-style consent-items {
    system: cyclic;
    symbols: ' ';  /* single space — all list markers become blank */
    suffix: '';
  }
}

/* Result: every element using list-style: consent-items loses its number.
   The attack @layer wins the cascade by layer order, not specificity.
   Unlayered rules (author-level non-layered) win over all layers —
   but if the page uses any @layer for its stylesheet, non-layered rules
   in the page stylesheet win; injected attack-layer loses.
   However: an attack @layer declared as the LAST layer in the @layer order
   declaration always wins over all other named layers in that declaration. */

Layer order hijack: If the page uses @layer base, components, utilities and an MCP server can append utilities or a new layer name to the order declaration, the attack layer can be positioned at the top of the cascade. The @layer order is determined by the first occurrence of the layer name — but new unnamed or implicitly ordered injections after the existing declarations are treated as highest priority among named layers.

Attack 1 (CRITICAL): blank-symbol @counter-style in attack @layer

The most direct attack redefines the consent list's counter name to use a single space as its only symbol. With system: cyclic and symbols: ' ', every list item marker becomes a single blank space. Users cannot track list position, cannot count items, and cannot cross-reference terms to footnotes. The attack requires only that the injected @layer is declared after all existing layers in the stylesheet loading order.

/* Attack @layer injected via third-party stylesheet or MCP-controlled CSS rule */
@layer application, attack;  /* attack layer declared last = highest priority */

@layer attack {
  @counter-style consent-bullets {
    system: cyclic;
    symbols: '\00a0';  /* non-breaking space — visually blank, not empty string */
    suffix: ' ';
  }
  @counter-style decimal {
    /* Override the built-in 'decimal' counter style */
    system: cyclic;
    symbols: '\00a0';
    suffix: ' ';
  }
}

/* ol { list-style: decimal } inside .consent-dialog → all numbers blank
   getComputedStyle does not expose the counter-style symbols value
   No CSS property check reveals the override
   Only JS counter inspection reveals it:
   CSS.supports('@counter-style test { system: cyclic; symbols: "a"; }') → bool
   but does not reveal what symbols are actually rendering
*/

Attack 2 (HIGH): decimal-leading-zero substitution via layer priority

A subtler attack substitutes the decimal-leading-zero system — used when consent lists explicitly set list-style-type: decimal-leading-zero — with a look-alike counter that uses normal numerals but prepends or appends misleading punctuation, making item 5 appear as item "5." (with a hidden period before the text) or substituting similar-looking characters (Unicode digit lookalikes) that confuse screen-reader announcements.

@layer attack {
  @counter-style decimal-leading-zero {
    system: numeric;
    /* Replace digit characters with Unicode lookalikes */
    symbols: '\FF10' '\FF11' '\FF12' '\FF13' '\FF14'
             '\FF15' '\FF16' '\FF17' '\FF18' '\FF19';
    /* 0 1 2 3 4 5 6 7 8 9 — fullwidth digits */
    /* Visual: identical to regular digits in most fonts */
    /* TTS: may announce "fullwidth zero" "fullwidth one" instead of numbers */
    /* Audit tools scanning for digits: miss the substitution */
    suffix: '. ';
  }
}

/* Effect: consent list appears normally numbered
   Screen readers announce fullwidth digits in affected implementations:
   "fullwidth one period agree to terms fullwidth two period sharing..."
   Users lose positional context in TTS-dependent navigation */

Attack 3 (HIGH): speak-as TTS disruption via layered @counter-style

The speak-as descriptor inside @counter-style controls how list markers are announced by text-to-speech engines. Setting speak-as: spell-out on a numeric counter causes each digit to be spelled letter-by-letter ("one", "two" → "o-n-e", "t-w-o"), dramatically slowing TTS navigation through consent items. An attack layer that overrides the consent list's counter-style speak-as value targets exclusively screen-reader-dependent users without any visible change to sighted users.

@layer attack {
  @counter-style terms-numbered {
    system: numeric;
    symbols: '1' '2' '3' '4' '5' '6' '7' '8' '9';
    suffix: '. ';
    speak-as: spell-out;
    /* TTS for 10-item consent list:
       "one period agree to t-e-n period data sharing...
       t-w-o period binding arbitration..."
       TTS navigation speed: 3-4× slower than normal numbered list
       Users may abandon screen-reader navigation before reaching critical items */
  }
}

/* Sighted audit: list appears correctly numbered — no visual change
   TTS audit: requires actual TTS testing with assistive technology
   getComputedStyle: no API to read @counter-style speak-as descriptor */

Attack 4 (MEDIUM): revert-layer interaction with attack counter definitions

The revert-layer keyword reverts a property to the value it would have had in the layer below. An MCP server can inject an attack @counter-style in a low-priority base layer, then trigger list-style: revert-layer on the consent list element from a higher layer. The revert-layer causes the browser to apply the base layer's attack counter-style instead of the expected default.

@layer base {
  @counter-style attack-base {
    system: cyclic;
    symbols: '✓';  /* checkmark — implies pre-checked/agreed status */
    suffix: ' ';
  }
}

@layer application {
  .consent-list {
    list-style: revert-layer;
    /* revert-layer reverts to base @layer value for this element
       browser selects attack-base counter-style from base @layer
       every consent item marked with ✓ — implies pre-agreement
    */
  }
}

/* Detection: revert-layer keyword in list-style on consent elements
   requires tracing the cascade layer stack to determine effective value
   getComputedStyle(el).listStyleType: returns 'none' for custom counters
   The effective counter-style name is not surfaced via getComputedStyle */

Detection

/* 1. Enumerate all @counter-style rules across all stylesheets and layers */
function auditCounterStyles() {
  const counterStyles = new Map();
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSLayerBlockRule) {
          for (const layerRule of rule.cssRules) {
            if (layerRule.constructor.name === 'CSSCounterStyleRule') {
              const name = layerRule.name;
              const symbols = layerRule.style?.getPropertyValue?.('symbols') ?? '';
              counterStyles.set(name, { symbols, layer: rule.name });
            }
          }
        }
        if (rule.constructor.name === 'CSSCounterStyleRule') {
          counterStyles.set(rule.name, { symbols: rule.style?.getPropertyValue?.('symbols'), layer: null });
        }
      }
    } catch (e) { /* cross-origin sheets */ }
  }
  return counterStyles;
}

/* 2. Check symbols for blank or suspicious values */
const styles = auditCounterStyles();
for (const [name, { symbols }] of styles) {
  if (!symbols || /^[\s ​]*$/.test(symbols)) {
    console.warn('Blank counter-style symbols:', name);
  }
  if (/[✓✔☑]/.test(symbols)) {
    console.warn('Checkmark counter-style symbols — may imply pre-agreement:', name);
  }
}

/* 3. Check @layer order for late-injected attack layers */
const layerOrder = [];
for (const rule of document.styleSheets[0]?.cssRules ?? []) {
  if (rule instanceof CSSLayerStatementRule) {
    layerOrder.push(...rule.nameList);
  }
}
/* Flag: any @counter-style name redefined in a layer appearing after 'base'/'application' */
AttackSeverityDetectable via CSS property?Detection method
Blank-symbol override in higher @layerCRITICALNoEnumerate CSSCounterStyleRule symbols across all layers
Decimal-leading-zero substitution with lookalikesHIGHNoCompare resolved Unicode codepoints in symbols descriptor
speak-as: spell-out TTS disruptionHIGHNoCheck speak-as descriptor; TTS functional testing
revert-layer to attack base counterMEDIUMNoTrace cascade layer stack for revert-layer on list-style