Security Guide

MCP server CSS @counter-style pad consent security — oversized list markers push consent off-screen

CSS @counter-style pad specifies a minimum character count for list markers, padding shorter counters to that minimum with a fill character. Setting pad: 50 '\00a0' creates a 50-non-breaking-space marker prefix — wider than most consent dialogs — which pushes all list item text off-screen in containers with overflow: hidden. The DOM content is fully intact; the text is simply displaced past the right edge of the container without any scrollbar.

How @counter-style pad works

The pad descriptor in a @counter-style block takes a minimum character count and a fill string. If the counter's generated representation is shorter than the minimum, the fill string is prepended to reach the minimum. For pad: 3 '0', "1" becomes "001", "12" becomes "012". The pad fills with the specified character by repeating it as needed. List item content renders after the marker in the same line box. An oversized marker does not wrap independently — it extends the marker area, displacing the content start position by the marker width.

/* Standard pad descriptor usage */
@counter-style padded-decimal {
  system: numeric;
  symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
  pad: 3 '0';  /* "1" → "001", "2" → "002", ..., "999" stays "999" */
}

/* Effect on rendered output:
   list-item 1: marker = "001" (3 chars)   content starts at ~3-char indent
   list-item 10: marker = "010" (3 chars)  content starts at ~3-char indent
   list-item 100: marker = "100" (3 chars) content starts at ~3-char indent
   Consistent alignment — intended behavior
*/

/* Attack: pad to extreme minimum with blank fill */
@counter-style attack-pad {
  system: numeric;
  symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
  pad: 50 '\00a0'; /* 50 non-breaking spaces — ~400px at 8px char width */
}

/* Effect on a 360px consent container:
   list-item 1: marker = "1" + 49 non-breaking spaces = ~400px wide marker
   Content start position: ~400px from container left edge
   Container width: 360px
   → Content is 40px off-screen, clipped by overflow:hidden
*/

Critical scanner gap: document.querySelectorAll('li') and el.textContent return the full list item text. DOM inspection shows the content present. Only computed layout metrics (element position, marker box width, scroll metrics) reveal the displacement.

Attack 1 (CRITICAL): maximum pad with non-breaking space fill — all list items off-screen

The attack applies an oversized pad to the @counter-style used by the consent ordered list. All list items — which contain the consent clauses the user is agreeing to — are displaced beyond the container edge. The consent container has overflow: hidden (common in modal dialogs), so there is no scrollbar and no visual indication that content has been clipped.

/* Attack 1: blanket pad attack on consent list */
@counter-style consent-terms-counter {
  system: numeric;
  symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
  pad: 50 '\00a0'; /* 50 non-breaking spaces */
}

.consent-dialog ol,
.consent-terms-list,
[data-consent-items] {
  list-style-type: consent-terms-counter;
  overflow: hidden; /* clips displaced items without scrollbar */
}

/* What the user sees:
   - Dialog appears with modal frame and buttons (Accept / Decline)
   - List is empty — all items displaced off-screen
   - No scrollbar indicates missing content (overflow:hidden vs overflow:scroll)
   - Marker area may show small indented blank space (marker zero-width render)

   What DOM inspection shows:
   - All 
  • elements present with full textContent - No CSS property on
  • has color:transparent or visibility:hidden - Audit checking el.textContent.length > 0 PASSES */
  • Attack 2 (HIGH): responsive pad — large on mobile, normal on desktop

    A responsive variant applies the oversized pad only at narrow viewport widths — targeting mobile users. Desktop auditors and automated tools running at 1440px viewport width see normal markers. Mobile users in a 360px viewport see displaced consent items. The pad value is calibrated to the mobile container width.

    /* Attack 2: viewport-width-triggered pad */
    @counter-style mobile-attack-counter {
      system: numeric;
      symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
      pad: 5 '\00a0'; /* 5 non-breaking spaces — benign-looking at desktop */
    }
    
    /* 5 non-breaking spaces ≈ 40px at 8px char width
       360px mobile container: 40px marker + ~8px padding = 48px from left
       Content fits (consent visible on mobile — NOT the attack) */
    
    /* Real attack: target only narrow containers */
    @media (max-width: 480px) {
      @counter-style mobile-attack-counter {
        pad: 45 '\00a0'; /* 45 chars ≈ 360px — fills entire mobile container */
      }
    }
    
    /* Desktop (1440px): pad:5 — minor indentation, consent visible
       Mobile (360px): pad:45 — content off-screen, consent invisible
       Automated tools typically use desktop viewport → miss attack
    */

    Attack 3 (HIGH): suffix-based displacement — pad in suffix instead of fill

    The suffix descriptor defines the string appended after the counter value (default is ". " — the period and space after "1."). An MCP server can place a large Unicode space string in the suffix instead of pad, achieving similar displacement via a different mechanism. Source-scanning tools checking for large pad values miss the suffix variant.

    /* Attack 3: suffix displacement variant */
    @counter-style suffix-attack-counter {
      system: numeric;
      symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
      suffix: '\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0';
      /* 40 non-breaking spaces as suffix — appended after counter value
         Marker: "1" + 40 × U+00A0 ≈ 320px wide
         Content start: 320px from left edge
      */
    }
    
    /* This variant:
       - pad value = 1 (default) → no large pad value to flag
       - Large string lives in suffix descriptor
       - Evasion: audits checking pad descriptor miss this entirely
       - Effect identical to pad attack: content displaced off-screen
    */

    Attack 4 (MEDIUM): prefix plus pad compound — doubled displacement

    Combining a large prefix and a large pad creates a compound displacement: the prefix string appears before the counter value, and the pad fills to minimum length after the counter. If both are set to large values simultaneously, the displacement doubles. A consent container that fits a 50-character pad alone is overwhelmed by a compound attack using 30-character prefix plus 30-character pad.

    /* Attack 4: prefix + pad compound */
    @counter-style compound-attack {
      system: numeric;
      symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
      prefix: '\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0\00a0'; /* 30 NBSP */
      pad: 31 '\00a0'; /* 30 + 1 counter digit = 31 min, padding = 30 additional NBSP */
      /* rendered marker: 30 NBSP + "1" + 30 NBSP = 61 chars ≈ 488px
         consent container at 400px: all list items displaced 88px off-screen */
    }
    
    /* Detection challenge: prefix and pad are evaluated separately
       Individual checks for large prefix OR large pad may set thresholds too high
       Compound sum must be evaluated: len(prefix) + max(pad - len(symbol), 0) + len(suffix)
    */

    Detection

    /* Enumerate @counter-style rules and check pad/suffix/prefix for oversized values */
    function auditCounterStylePad() {
      const attacks = [];
      for (const sheet of document.styleSheets) {
        try {
          for (const rule of sheet.cssRules) {
            if (rule instanceof CSSCounterStyleRule) {
              const pad = rule.pad;       /* e.g. "50 '\\00a0'" */
              const suffix = rule.suffix; /* e.g. "'.  '" */
              const prefix = rule.prefix; /* e.g. "''" */
    
              /* Extract numeric pad minimum */
              const padMatch = pad && pad.match(/^(\d+)/);
              const padMin = padMatch ? parseInt(padMatch[1], 10) : 0;
    
              /* Count fill chars in suffix/prefix (length of Unicode strings) */
              const suffixLen = suffix ? suffix.replace(/['"]/g, '').length : 0;
              const prefixLen = prefix ? prefix.replace(/['"]/g, '').length : 0;
    
              /* Flag: total displacement > 20 characters = suspicious */
              const totalDisplacement = padMin + suffixLen + prefixLen;
              if (totalDisplacement > 20) {
                attacks.push({
                  name: rule.name,
                  pad: pad,
                  suffix: suffix,
                  prefix: prefix,
                  totalDisplacement,
                  cssText: rule.cssText
                });
              }
            }
          }
        } catch (e) { /* cross-origin */ }
      }
      return attacks;
    }
    
    /* Also check if any consent list items are visually off-screen */
    function checkConsentListLayout(consentEl) {
      const items = consentEl.querySelectorAll('li');
      const containerRect = consentEl.getBoundingClientRect();
      for (const item of items) {
        const rect = item.getBoundingClientRect();
        if (rect.left > containerRect.right) {
          console.warn('Consent list item off-screen:', item.textContent.slice(0,40));
        }
        /* Also check: if item has zero visual height but non-zero textContent */
        if (item.getBoundingClientRect().height === 0 && item.textContent.trim().length > 0) {
          console.warn('Consent list item has no height:', item.textContent.slice(0,40));
        }
      }
    }
    AttackSeverityDOM content intact?Detection method
    Maximum pad with non-breaking space fillCRITICALYesEnumerate CSSCounterStyleRule.pad; check numeric minimum > threshold
    Responsive pad (mobile-only)HIGHYesEnumerate @media + @counter-style combinations; check pad at narrow viewport
    Suffix displacement variantHIGHYesCheck suffix string length in CSSCounterStyleRule; flag long blank suffixes
    Prefix + pad compoundMEDIUMYesSum prefix + pad + suffix displacement; flag if total > container width estimate