Security Guide

MCP server CSS @counter-style speak-as security — TTS list marker manipulation targeting consent dialogs

The speak-as descriptor inside @counter-style controls how CSS list item markers are vocalized by text-to-speech engines. An MCP server that controls the site's stylesheet can define a custom @counter-style with speak-as: spell-out or a circular reference and apply it to consent dialog list markers, severely disrupting how screen reader users perceive the structure and content of consent terms.

The speak-as descriptor inside @counter-style

The @counter-style at-rule (CSS Lists Level 3) defines a custom counter style — controlling how list item markers look and sound. The speak-as descriptor inside @counter-style is distinct from the speak-as CSS Speech property: this descriptor specifically governs how the counter representation itself is announced in TTS output.

Valid values of the speak-as descriptor: auto (behavior determined by the system type), bullets (spoken as a generic bullet sound or nothing), numbers (spoken as a cardinal number), words (the symbol name spoken as a word), spell-out (each character spelled out individually), and <counter-style-name> (defer to another named counter style's speak-as). Browser support: Chrome 91+, Firefox 33+, Safari 17+ — approximately 80% of browsers in 2026.

/* Basic @counter-style syntax */
@counter-style thumbs-up {
  system: cyclic;
  symbols: "\1F44D";
  suffix: " ";
  speak-as: bullets; /* spoken as generic bullet, not as 👍 symbol name */
}

/* The speak-as descriptor controls ONLY how the marker is vocalized — not the text */
/* Values: auto | bullets | numbers | words | spell-out | <counter-style-name> */

/* Example: normal decimal counter */
@counter-style decimal-spoken {
  system: extends decimal;
  speak-as: numbers; /* "1" spoken as "one" — natural */
}

/* Example: spell-out mode */
@counter-style decimal-spelled {
  system: extends decimal;
  speak-as: spell-out; /* "1" spoken as "o-n-e" — unnatural, disruptive */
}

Accessibility attack surface: TTS manipulation via speak-as specifically targets users who rely on screen readers — visually impaired users, users with dyslexia, and motor-impaired users who cannot scroll. For these users, the TTS presentation is the consent dialog. Disrupting TTS navigation, numbering, or marker pronunciation is a consent accessibility attack with real-world harm potential.

Attack 1 (HIGH): speak-as:spell-out on consent list markers

Setting speak-as: spell-out on the @counter-style used for consent list items causes each item number to be spelled out character-by-character. In a consent list with 10 items ("1." through "10."), TTS output becomes "o-n-e. [item text]", "t-w-o. [item text]", etc. The spell-out dramatically slows TTS navigation. Users navigating with screen reader commands (e.g., NVDA's item-by-item navigation, VoiceOver's list traversal) hear each item prefixed with a slow, disruptive marker. Depending on the screen reader and user settings, this may cause the user to give up listening before reaching the critical consent terms toward the end of the list.

/* MCP-injected @counter-style with speak-as:spell-out */
@counter-style consent-counter {
  system: numeric;
  symbols: "0" "1" "2" "3" "4" "5" "6" "7" "8" "9";
  speak-as: spell-out;
  /* TTS: "1" → "one"; speak-as:spell-out → "o-n-e"
     For multi-digit numbers: "10" → "t-e-n" (letter by letter)
     In practice NVDA/JAWS may interpret this differently, but the intent is disruption.
  */
}

/* Apply to consent list items */
.consent-list {
  list-style: consent-counter;
}

/* Visual rendering is unchanged — sighted auditors see a normal numbered list.
   TTS rendering is disrupted — screen reader users hear garbled marker prefixes. */

Attack 2 (HIGH): speak-as circular reference — counter never resolves

The speak-as descriptor can reference another named @counter-style. If two custom counter styles mutually reference each other as their speak-as targets, the specification defines a circular reference resolution: the system falls back to speak-as: numbers. However, some browser implementations may handle this differently, potentially causing the TTS to announce the marker as a numeric sequence that does not correspond to the visual counter, or to skip the marker announcement entirely. The attack exploits implementation-specific TTS behavior across screen reader + browser combinations.

/* Circular speak-as reference */
@counter-style loop-a {
  system: extends decimal;
  speak-as: loop-b; /* references loop-b */
}

@counter-style loop-b {
  system: extends decimal;
  speak-as: loop-a; /* references loop-a — circular */
}

/* Spec says: resolve circular speak-as to speak-as:numbers
   But browser + screen reader combinations may:
   - Announce the marker twice (once for each style before resolution)
   - Skip the marker entirely
   - Use a different numbering that conflicts with visual numbering
*/

.consent-list {
  list-style: loop-a;
}

Attack 3: speak-as:bullets on numbered consent list — structure concealed

Setting speak-as: bullets on a visually numbered consent list causes TTS to announce each item as a generic "bullet" rather than the item number. Users navigating the consent list by item cannot track their position in the list — they cannot know whether they are on item 1 of 10 or item 8 of 10. This is particularly harmful for long consent dialogs where the order of consent terms matters legally. Users who hear "bullet. You consent to data sharing with third parties" cannot connect this to the visual "8." marker that a sighted user would associate with clause 8 of the consent form.

/* Visual: numbered list (1., 2., 3., ...) */
/* TTS: each item announced as "bullet" — no number */

@counter-style consent-bullets {
  system: extends decimal;
  speak-as: bullets;
  /* Visual: renders the decimal counter value (1, 2, 3, ...)
     TTS:    announces each marker as generic "bullet"
     Result: sighted user sees "3." and knows they're on item 3 of N.
             Screen reader user hears "bullet" and loses positional context.
  */
}

.consent-list {
  list-style: consent-bullets;
}

/* The discrepancy between visual and TTS representation means:
   - Accessibility-only users cannot cross-reference consent items with paper documents
   - Legal records of "item 3" from visual review are meaningless to TTS users
*/

Attack 4: Custom symbol counter with speak-as:words — symbol name spoken aloud

A @counter-style using custom Unicode symbols as markers can set speak-as: words, causing TTS to speak the symbol name rather than the counter position. An MCP server can choose symbols whose names are misleading or distracting when spoken. For example, using a checkmark symbol (✓) with speak-as: words might cause TTS to announce "check mark" before each consent item, creating an auditory impression that the user has already checked or agreed to each item — a manipulative dark pattern specifically for TTS users.

/* Checkmark symbol that says "check mark" before each consent item */
@counter-style check-consent {
  system: cyclic;
  symbols: "\2713"; /* ✓ checkmark */
  suffix: " ";
  speak-as: words;
  /* TTS announces: "check mark [item text]"
     Auditory dark pattern: sounds like each item has already been accepted.
     Equivalent of pre-ticked consent boxes, but for TTS users.
  */
}

.consent-items {
  list-style: check-consent;
}

/* Sighted auditors see a ✓ prefix — may interpret as a styling choice.
   Screen reader users hear "check mark" before every consent item.
*/

Detection implementation

/**
 * SkillAudit: detect @counter-style speak-as consent attacks
 */
function detectCounterStyleSpeakAsAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
  const findings = [];

  // Map of counter-style names to their speak-as values
  const counterStyles = new Map();

  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }

    for (const rule of rules) {
      // Check @counter-style rules
      if (rule.type === 6 /* CSSCounterStyleRule */) {
        const name = rule.name;
        const speakAs = rule.style?.getPropertyValue('speak-as') || '';
        if (speakAs) {
          counterStyles.set(name, speakAs);
          if (speakAs === 'spell-out' || speakAs === 'bullets') {
            findings.push({
              severity: 'HIGH',
              type: '@counter-style',
              name,
              speakAs,
              detail: `@counter-style "${name}" has speak-as:${speakAs}. If applied to a consent list, this disrupts TTS presentation for screen reader users.`,
            });
          } else if (speakAs === 'words') {
            findings.push({
              severity: 'MEDIUM',
              type: '@counter-style',
              name,
              speakAs,
              detail: `@counter-style "${name}" has speak-as:words. If the counter symbols are misleading (e.g. checkmarks), this creates an auditory dark pattern for TTS users.`,
            });
          }
        }
      }
    }
  }

  // Check for circular speak-as references
  for (const [name, speakAs] of counterStyles) {
    if (counterStyles.has(speakAs) && counterStyles.get(speakAs) === name) {
      findings.push({
        severity: 'MEDIUM',
        type: '@counter-style circular',
        name,
        detail: `@counter-style "${name}" and "${speakAs}" have a circular speak-as reference. TTS behavior is implementation-defined and may be inconsistent across screen readers.`,
      });
    }
  }

  // Check consent list elements for suspicious counter styles
  const consentEls = document.querySelectorAll(consentSelector);
  for (const el of consentEls) {
    const lists = el.querySelectorAll('ol, ul, [style*="list-style"]');
    for (const list of lists) {
      const ls = getComputedStyle(list).getPropertyValue('list-style-type');
      if (ls && counterStyles.has(ls)) {
        const speakAs = counterStyles.get(ls);
        if (speakAs === 'spell-out' || speakAs === 'bullets') {
          findings.push({
            severity: 'HIGH',
            element: list,
            counterStyle: ls,
            speakAs,
            detail: `Consent list uses @counter-style "${ls}" with speak-as:${speakAs}. TTS users will hear disrupted marker announcements, impairing consent comprehension.`,
          });
        }
      }
    }
  }

  return findings;
}
AttackTTS effectUser impact
speak-as:spell-out on decimal counterNumbers spelled letter by letter ("o-n-e")Severely slows list navigation; users may abort before critical items
speak-as circular referenceImplementation-defined; may skip markers or double-announceInconsistent TTS experience; users lose positional context
speak-as:bullets on numbered listNumbers replaced with generic "bullet" soundUsers cannot track position or cross-reference consent items by number
speak-as:words with checkmark symbol"check mark" spoken before each itemAuditory dark pattern suggesting pre-acceptance of each consent item

Related SkillAudit coverage

SkillAudit detection: SkillAudit scans all @counter-style rules for speak-as values that disrupt TTS list navigation (spell-out, bullets) or create misleading auditory cues (words with deceptive symbol names). For each flagged counter style, SkillAudit checks whether it is applied to list elements inside consent selectors. Circular speak-as references are detected and flagged separately for implementation-specific TTS risk.

Audit your MCP server's counter style TTS configuration before publishing. Run a free SkillAudit scan — results in 60 seconds.