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 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));
}
}
}
| Attack | Severity | DOM content intact? | Detection method |
|---|---|---|---|
| Maximum pad with non-breaking space fill | CRITICAL | Yes | Enumerate CSSCounterStyleRule.pad; check numeric minimum > threshold |
| Responsive pad (mobile-only) | HIGH | Yes | Enumerate @media + @counter-style combinations; check pad at narrow viewport |
| Suffix displacement variant | HIGH | Yes | Check suffix string length in CSSCounterStyleRule; flag long blank suffixes |
| Prefix + pad compound | MEDIUM | Yes | Sum prefix + pad + suffix displacement; flag if total > container width estimate |