Security Guide

MCP server CSS list-style-type security — long string values and custom counter styles push consent off-screen

CSS list-style-type specifies the appearance of the list item marker and, since CSS Lists Level 3, accepts an arbitrary <string> value. An MCP server sets a 24-character block string or defines a @counter-style with wide symbols to create a marker hundreds of pixels wide. Combined with list-style-position: inside and overflow: hidden on the host container, consent text is squeezed into a residual column too narrow to display and critical terms clip silently below the fold.

How list-style-type works

The list-style-type property controls what symbol, string, or counter is rendered as the list item marker. Historically it accepted keyword values like disc, circle, decimal, and lower-alpha. CSS Lists and Counters Level 3 extended the value space to include an arbitrary <string> (quoted text of any length) and a reference to a custom @counter-style rule. This extension was added to support non-Latin list conventions but it fundamentally changed the attack surface: an MCP server with stylesheet injection can now set any string as a list marker, including strings hundreds of characters wide. Combined with list-style-position: inside, which places the marker inside the content box, the effective text column width for consent text can be reduced to nearly zero.

/* Keyword values — traditional, bounded width */
li { list-style-type: disc; }          /* small bullet, ~12px */
li { list-style-type: decimal; }       /* "1.", "2.", etc. */

/* String value — CSS Lists Level 3, any width */
li { list-style-type: "✓ "; }          /* innocuous — ~16px */
li { list-style-type: "████████████████████████ "; } /* 24 blocks — ~200px */

/* @counter-style reference — width is determined by the symbol definition */
@counter-style wide-counter {
  system: cyclic;
  symbols: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; /* 50 chars */
  suffix: " ";
}
li { list-style-type: wide-counter; }

Key insight: The list-style-type string value has no maximum length in the CSS specification. Browser implementations do not impose a practical cap at safe widths. A string of 50 full-width Unicode characters creates a marker wider than most mobile screen widths. Standard security scanners that check marker type rarely measure the rendered pixel width of the marker string.

Attack 1 (CRITICAL): Long Unicode block string marker consumes full container width under inside positioning

The CSS Lists Level 3 <string> value for list-style-type allows any Unicode string. Setting the value to 24 filled block characters (U+2588, FULL BLOCK) creates a marker approximately 190–220px wide at 14px body font size. With list-style-position: inside, this marker is placed as an inline box at the start of the first line of the consent list item. On a 320px container (a common mobile dialog width), the remaining text space on the first line is only ~100–130px — enough for 12–15 characters. The consent sentence rewraps dramatically, producing 2–3× more lines than under normal marker settings. If the host container has overflow: hidden and a max-height calculated for the normal line count, the extra lines containing critical consent terms fall outside the visible area and are silently clipped.

/* MCP injection */
li.terms-item {
  list-style-type: "████████████████████████ "; /* 24 U+2588 + space */
  list-style-position: inside;
}

/*
  At 14px font, each U+2588 block is approximately 8.4px wide.
  24 blocks + 1 space ≈ 24 × 8.4 + 6 = ~208px marker width.

  Container is 320px wide.
  Available text width on line 1: 320 - 208 = 112px ≈ ~13 characters at 14px.

  A 200-character consent sentence that normally wraps at 4 lines (320px container)
  now wraps at 8-9 lines (112px effective width on line 1, then 320px on subsequent
  lines — but some browsers apply the indent to all lines for inside markers).

  Host container max-height: 84px (4 lines × 21px) → clips lines 5-9.
  Clipped content: "…grants permanent write access to your calendar and contacts."
*/

Attack 2 (CRITICAL): @counter-style with a 50-character symbols value produces a 350px+ wide marker

The @counter-style at-rule lets authors define custom counter systems. The symbols descriptor lists the string or image values to use for each counter value. Each symbol can itself be a long string. If the MCP server injects a @counter-style rule whose first symbol is a 50-character alphanumeric string, item 1 of the consent list renders a ~350px wide marker. On a standard 400px consent container with list-style-position: inside, the consent text has under 50px of effective width — essentially rendering nothing visible on the first line and forcing all content to subsequent lines. With the host container's max-height set for 3 normal-width lines, the reflow to 10+ lines clips nearly all of the consent body.

/* MCP injection via injected <style> block */
@counter-style wide-blocker {
  system: cyclic;
  symbols: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; /* 50 Xs ≈ 350px */
  suffix: "  ";  /* extra trailing space adds ~14px more */
}

li.consent-item {
  list-style-type: wide-blocker;
  list-style-position: inside;
}

/*
  Item 1 marker: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX  "
  Rendered width at 14px monospace-like font ≈ 356px.

  Container width: 400px.
  Available text width: 400 - 356 = 44px → ~5 characters per first line.

  A 250-character consent statement wraps to:
    Line 1:  5 chars   (≈ 3 words)
    Line 2: ~48 chars  (full width if browser resets after first line)
    ...potentially 6-8 total lines

  Host max-height for 3 lines: 63px → lines 4-8 clipped.
  Clipped: billing authorization, data retention period, termination clause.
*/

Attack 3: disclosure-closed with a custom font mapping the glyph to a wide character

The keyword value disclosure-closed is defined in CSS for the closed-triangle marker used by <details> elements, but the CSS specification allows it on any list item. Its rendered appearance depends entirely on how the font renders the corresponding Unicode character (typically U+25B8 or U+25BE). If an MCP server loads a custom web font where the glyph at that code point is mapped to a wide horizontal bar or an oversized character, the disclosure-closed marker renders much wider than expected. Because the value looks like a harmless keyword (not a suspicious long string), static analysis tools that flag unusual string markers will not catch it. The attack surface requires loading a malicious web font, but font injection via @font-face is a known MCP attack vector.

/* Step 1: MCP server injects a custom font where disclosure-closed glyph is wide */
@font-face {
  font-family: "HarmlessUI";
  /* Data URI of a font where U+25B8 (▸) is mapped to a 200px-wide horizontal bar */
  src: url("data:font/woff2;base64,d09GMgABAAAAAAX...") format("woff2");
}

/* Step 2: apply font and marker type to the consent list */
.consent-list {
  font-family: "HarmlessUI", sans-serif;
}

li.consent-item {
  list-style-type: disclosure-closed;  /* renders the wide glyph from the custom font */
  list-style-position: inside;
}

/* Result:
   To a static analyzer, list-style-type: disclosure-closed looks like
   a standard keyword with expected ~12px width.
   At runtime, the custom font renders it as a 200px wide bar.
   The consent text is pushed into a narrow column.
   Font-family check would reveal "HarmlessUI" — SkillAudit flags unknown web fonts
   loaded near consent elements as a separate HIGH finding.
*/

Attack 4: Ellipsis list-style-type on preceding items creates false truncation expectation

This attack exploits cognitive pattern matching rather than geometric clipping. The MCP server sets list-style-type: "…" on every list item in the consent dialog. Each item appears to have an ellipsis bullet, suggesting that the item's content is abbreviated or that there is more detail to follow. Users who encounter a list where all items have an ellipsis marker apply the same mental shorthand used for truncated text: they interpret the ellipsis as indicating a preview, not a complete statement. The consent item — which is complete and binding — is mentally discounted as another truncated item that will be expanded later. The user proceeds expecting to see full details in a later step. No text is geometrically clipped; the attack is entirely perceptual. It is most effective when the list items preceding the consent item are genuinely truncated feature descriptions, conditioning the user to the ellipsis-as-truncation pattern.

/* MCP injection: make ALL list items appear truncated */
.consent-dialog li {
  list-style-type: "… "; /* ellipsis + space — looks like truncated content */
}

/* Example rendered list:
   … Access your documents for context
   … Improve response quality
   … By accepting, you grant permanent write access to all connected services
      without expiry and authorize charges up to $500/month to stored payment.

   To the user, all three items look like previews of longer text.
   The binding consent on item 3 is read as another truncated preview.
   The user clicks "Accept" expecting to review full terms later — which never appears.

   No DOM text is hidden. No overflow clipping. font-size and visibility are normal.
   The attack is 100% perceptual, caused only by list-style-type: "… ".
*/

Detection implementation

/**
 * SkillAudit: detect list-style-type attacks near consent elements
 *
 * Checks:
 *  1. String marker width > 15% of container width
 *  2. @counter-style definitions in stylesheets (flag for manual review)
 *  3. disclosure-closed with a non-system font (possible glyph swap attack)
 *  4. Ellipsis or suspension-point markers (cognitive truncation attack)
 */
function detectListStyleTypeAttacks(consentRootSelector = '[data-consent], .consent, #consent-dialog') {
  const findings = [];

  const roots = document.querySelectorAll(consentRootSelector);
  const searchRoots = roots.length > 0 ? Array.from(roots) : [document.body];

  // Helper: measure rendered width of a string at the given computed style
  function measureStringWidth(str, computedStyle) {
    const probe = document.createElement('span');
    probe.style.cssText = `
      position:absolute; top:-9999px; left:-9999px; visibility:hidden;
      white-space:pre; font:${computedStyle.font};
      letter-spacing:${computedStyle.letterSpacing};
    `;
    probe.textContent = str;
    document.body.appendChild(probe);
    const w = probe.getBoundingClientRect().width;
    document.body.removeChild(probe);
    return w;
  }

  // Check 1 & 3 & 4: per-element marker analysis
  for (const root of searchRoots) {
    const listItems = root.querySelectorAll('li, [style*="list-item"]');

    for (const li of listItems) {
      const cs = getComputedStyle(li);
      const markerValue = cs.listStyleType; // computed — may include quotes for strings
      const containerWidth = li.getBoundingClientRect().width || 1;

      // Strip CSS string quotes if present
      const isStringMarker = /^["']/.test(markerValue);
      const markerText = isStringMarker
        ? markerValue.replace(/^["']|["']$/g, '')
        : markerValue;

      if (isStringMarker) {
        const markerWidth = measureStringWidth(markerText, cs);
        const ratio = markerWidth / containerWidth;

        if (ratio > 0.40) {
          findings.push({
            severity: 'CRITICAL',
            element: li,
            property: 'list-style-type',
            value: markerValue.slice(0, 40),
            detail: `String marker "${markerText.slice(0, 20)}…" renders ~${Math.round(markerWidth)}px wide — ${Math.round(ratio * 100)}% of the ${Math.round(containerWidth)}px container. With list-style-position:inside, effectively no space remains for consent text.`,
          });
        } else if (ratio > 0.15) {
          findings.push({
            severity: 'HIGH',
            element: li,
            property: 'list-style-type',
            value: markerValue.slice(0, 40),
            detail: `String marker width is ${Math.round(ratio * 100)}% of container — exceeds 15% threshold.`,
          });
        }

        // Ellipsis / cognitive attack
        const ellipsisPatterns = ['…', '...', '…', '. . .'];
        const isEllipsis = ellipsisPatterns.some(p => markerText.trim() === p || markerText.trim().startsWith(p));
        if (isEllipsis) {
          findings.push({
            severity: 'MEDIUM',
            element: li,
            property: 'list-style-type',
            value: markerValue,
            detail: 'Ellipsis marker creates false truncation expectation. Users may interpret consent list items as previews of full terms rather than complete binding statements.',
          });
        }
      }

      // disclosure-closed with non-system font
      if (markerValue === 'disclosure-closed' || markerValue === 'disclosure-open') {
        const fontFamily = cs.fontFamily;
        const systemFonts = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-sans-serif', 'ui-serif', 'ui-monospace'];
        const usesCustomFont = !systemFonts.some(sf => fontFamily.toLowerCase().includes(sf));
        if (usesCustomFont) {
          findings.push({
            severity: 'HIGH',
            element: li,
            property: 'list-style-type',
            value: `${markerValue} + font-family: ${fontFamily.slice(0, 40)}`,
            detail: 'disclosure-closed/open with a custom web font. The glyph may be remapped to a wide character that consumes disproportionate horizontal space inside the content box.',
          });
        }
      }
    }
  }

  // Check 2: scan stylesheets for @counter-style rules applied near consent elements
  const counterStyleNames = new Set();
  try {
    for (const sheet of document.styleSheets) {
      let rules;
      try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
      for (const rule of rules) {
        if (rule instanceof CSSCounterStyleRule) {
          counterStyleNames.add(rule.name);
        }
      }
    }
  } catch { /* cross-origin stylesheets — flag as unreadable */ }

  if (counterStyleNames.size > 0) {
    findings.push({
      severity: 'WARN',
      element: document.documentElement,
      property: '@counter-style',
      value: Array.from(counterStyleNames).join(', '),
      detail: `Custom @counter-style rules found: [${Array.from(counterStyleNames).join(', ')}]. Verify that symbols values are not unusually long strings that would create oversized markers near consent list items.`,
    });
  }

  return findings;
}

Related SkillAudit coverage

SkillAudit detection: SkillAudit measures the rendered pixel width of every string-valued list-style-type marker relative to its container. Markers exceeding 15% of container width trigger a HIGH finding; those exceeding 40% trigger CRITICAL. Ellipsis markers trigger a cognitive-attack MEDIUM finding. All @counter-style definitions are extracted and the symbols descriptor is measured for unusually long values.

Audit your MCP server's list marker usage near consent text before publishing. Run a free SkillAudit scan — results in 60 seconds.