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' */
| Attack | Severity | Detectable via CSS property? | Detection method |
|---|---|---|---|
| Blank-symbol override in higher @layer | CRITICAL | No | Enumerate CSSCounterStyleRule symbols across all layers |
| Decimal-leading-zero substitution with lookalikes | HIGH | No | Compare resolved Unicode codepoints in symbols descriptor |
| speak-as: spell-out TTS disruption | HIGH | No | Check speak-as descriptor; TTS functional testing |
| revert-layer to attack base counter | MEDIUM | No | Trace cascade layer stack for revert-layer on list-style |