MCP server CSS character and word spacing collapse consent security
Four SA-CSS-CHA consent attack patterns that collapse consent text into unintelligible glyph clusters by manipulating word spacing, letter spacing, glyph width (via font-stretch), and container width (via white-space: nowrap + zero-width container + overflow: hidden). Unlike color attacks, these operate in the physical layout domain: the text pixels are present and correctly colored, but the glyph shapes are either overlapping, compressed to hairlines, or clipped to zero horizontal extent.
CSS spacing properties are designed to improve readability by controlling the gaps between words and characters. Negative values are permitted by the specification, allowing characters or words to overlap. At extreme negative values, the spacing collapse exceeds the character advance widths, stacking all characters at the same horizontal position — producing a dense glyph pile that is visually indistinguishable from a decorative mark rather than readable text. The font size and text color are unchanged; the text is accessible; only the layout dimension has collapsed.
SA-CSS-CHA-001: word-spacing:-999px — inter-word gap collapse
word-spacing: -999px reduces the space between words by 999px. Since a space character in a 14px font is approximately 3-4px wide, a -999px word-spacing eliminates the gap and moves each subsequent word 995-996px to the left relative to where it would normally appear. For a 7-word consent sentence, all seven words overlap in a narrow region near the sentence start. The individual characters within each word remain at their default letter spacing (unless letter-spacing is also set), but word boundaries collapse completely.
.consent-text {
word-spacing: -999px;
/* space character advance: ~3-4px for 14px font */
/* -999px word-spacing: words shifted ~995px leftward per space */
/* sentence "By clicking Allow" → "By", "clicking", "Allow" all at x≈0..50 */
}
/* Layout analysis:
Normal rendering of 7-word sentence at 14px:
"By clicking Allow you grant access." ≈ 280px wide, 20px tall
With word-spacing:-999px:
Word 1 "By": x=0, width≈16px
Word 2 "clicking": x=16+3-999 = -980 → negative, browser clamps to some minimum
... all words shifted far left; most rendered near x=0..16px range
Browser behavior varies:
- Most browsers: render words at very negative x positions, clipping if overflow:hidden
- Some browsers: clamp word-spacing minimum to avoid excessive layout distortion
- In practice: creates illegible glyph overlap cluster at sentence start
getComputedStyle(el).wordSpacing: returns computed value in px — "DETECTABLE"
Check: parseFloat(getComputedStyle(el).wordSpacing) < -30px → suspicious
< -100px → CRITICAL (completely collapses inter-word spacing) */
word-spacing vs letter-spacing scope: word-spacing affects only the space between words (the gap between the last glyph of one word and the first glyph of the next). Characters within a word retain their default letter spacing unless letter-spacing is also set. An attack using only word-spacing: -999px collapses all words to a tight cluster but individual words may still be individually readable within the cluster. Combining both word-spacing: -999px and letter-spacing: -999px achieves complete collapse of both word and character boundaries.
word-spacing: -999px: all inter-word spaces collapse; words overlap into glyph cluster; consent sentence illegible as sequenced text. Detection: parseFloat(getComputedStyle(el).wordSpacing) < -100 → CRITICAL.SA-CSS-CHA-002: letter-spacing:-999px — complete character horizontal collapse
While the SA-CSS-FSZ-004 page documented letter-spacing: -1em (relative to font size), letter-spacing: -999px achieves even more severe collapse using a large absolute px value. Every character after the first is shifted 999px to the left, far past the start of the element. For a 14px font where characters are 6-12px wide, a -999px letter-spacing means each subsequent character is 987-993px to the left of where the previous character started. All characters collapse to a single-pixel-wide region at the far left of the text rendering area — or outside the element's left boundary entirely.
.consent-text {
font-size: 14px;
letter-spacing: -999px;
}
/* Letter-spacing mechanics:
letter-spacing is added AFTER each character's advance width.
Normally: advance_width ≈ 8-12px, letter-spacing = 0 → characters flow naturally
With letter-spacing = -999px:
Position of char[0]: x = 0
Position of char[1]: x = advance_width[0] + (-999) ≈ 10 - 999 = -989
Position of char[2]: x = advance_width[1] + advance_width[0] + 2*(-999) ≈ 20 - 1998 = -1978
→ All characters after the first are at large negative x positions
Browser rendering:
- Characters with negative x positions render to the left of the element's left edge
- If element or ancestor has overflow:hidden: all negative-x characters are clipped
- If overflow:visible: characters render off to the left of their container
Attack requires overflow:hidden ancestor or element to clip the off-screen chars.
Without clipping: visible character cluster to the left of the consent button area.
With clipping: all characters except the first one are invisible.
Detection:
parseFloat(getComputedStyle(el).letterSpacing) < -50px → HIGH
< -100px → CRITICAL (even for large font sizes, this fully collapses all characters) */
letter-spacing: -999px: each character shifted 999px left from previous; all characters collapse to single-pixel column (with overflow:hidden, all but first are clipped). Detection: parseFloat(getComputedStyle(el).letterSpacing) < -100 → CRITICAL.SA-CSS-CHA-003: font-stretch:50% — horizontal glyph condensing
font-stretch: 50% (or the keyword ultra-condensed) horizontally scales the glyph shapes to 50% of their normal width. For a font with 2px-wide stroke weight at normal width, 50% stretch produces 1px strokes — hairlines on standard-DPI displays. At this width, the character interiors fill with anti-aliasing gray rather than clear black strokes, significantly reducing contrast and legibility. For variable fonts that support the wdth axis, even lower stretch values are possible. In combination with a small font size, font-stretch: 50% makes text nearly illegible.
.consent-text {
font-size: 12px;
font-stretch: 50%; /* ultra-condensed variant */
}
/* font-stretch analysis:
font-stretch values:
ultra-condensed = 50% → glyphs at half normal width
extra-condensed = 62.5%
condensed = 75%
semi-condensed = 87.5%
normal = 100%
semi-expanded = 112.5%
expanded = 125%
extra-expanded = 150%
ultra-expanded = 200%
At 50% stretch, 12px font:
- Normal stroke weight: ~1.2px → at 50% width: ~0.6px → sub-pixel → gray anti-aliased
- Character interior (counter) heavily compressed → character shapes merge
- "m" (3-stroke letter) at 50%: strokes at ~1px width overlap at thin counter
- "e" (bowl letter): eye becomes very narrow, difficult to distinguish from "c"
- Entire sentence compressed to ~50% of normal width (but still wider than letter-spacing attacks)
At 50% stretch, contrast is technically preserved (pixels are still dark on white).
The attack is cognitive: glyphs are so compressed they look like line art, not text.
Note: font-stretch only works if the loaded font has condensed variants or is a
variable font with wdth axis. For fonts without condensed variants, the browser
may not apply the stretch (uses closest available variant → may be 75% minimum).
Variable fonts (common for system fonts on macOS/Windows) support arbitrary % values.
Detection: getComputedStyle(el).fontStretch returns percentage
flag: parseFloat(computedFontStretch) < 75 → HIGH
parseFloat(computedFontStretch) < 62.5 → CRITICAL */
Variable font support: font-stretch effectiveness depends on whether the loaded font supports condensed variants. For non-variable fonts, the browser uses the nearest condensed variant (often 75%). For variable fonts with a wdth axis (increasingly common on modern operating systems), the full percentage value is honored. Consent text auditors should check font-stretch values and flag any value below 75% on variable fonts or 62.5% on non-variable fonts.
font-stretch: 50%: glyphs compressed to half normal width; stroke weight sub-pixel on 12px font; character shapes merge; legibility severely reduced. Detection: parseFloat(getComputedStyle(el).fontStretch) < 75 (variable fonts) → HIGH; < 62.5 → CRITICAL.SA-CSS-CHA-004: white-space:nowrap + width:0 + overflow:hidden — single-line zero-width clip
white-space: nowrap prevents text wrapping, forcing all consent text onto a single line. Combined with width: 0; overflow: hidden on the container, the single-line text overflows the zero-width container and is clipped. The element has a layout width of 0, renders no visible text, but the DOM element exists with non-zero scrollWidth (the full natural text width). This attack exploits the combination of white-space: nowrap (prevents the text from wrapping to fit the container) and overflow: hidden (clips the single-line overflow).
.consent-container {
width: 0;
overflow: hidden;
white-space: nowrap;
}
/* What happens:
1. white-space:nowrap: consent text stays on one line regardless of container width
2. width:0: container is 0px wide (no space for text to display)
3. overflow:hidden: the single-line text overflowing the 0px-wide container is clipped
Result:
- container.getBoundingClientRect().width = 0 (layout width is 0)
- container.scrollWidth > 0 (natural text width — text IS there)
- container.clientWidth = 0 (visible area is 0)
- All text pixels: clipped by overflow:hidden at x=0
Without white-space:nowrap:
- Width:0 container → text would wrap to zero-width lines
- Each line would be zero-pixel wide → similar effect but via wrapping
- white-space:nowrap makes the attack more explicit and harder to accidentally break
Comparison to max-height:0 overflow:hidden (SA-CSS-OVH-001):
Both clip via overflow:hidden, one in vertical dimension, one in horizontal.
This pattern clips in the horizontal dimension.
Detection:
container.getBoundingClientRect().width === 0 (or very small) AND
container.scrollWidth > 0 (text present) AND
getComputedStyle(container).overflow === 'hidden'
→ consent text clipped: SA-CSS-CHA-004 CRITICAL
Note: also check getComputedStyle(container).whiteSpace — 'nowrap' value on a
zero-width element specifically locks the text to a single clipped line. */
white-space: nowrap; width: 0; overflow: hidden: text forced to single line; 0px container clips entire line; scrollWidth > 0 but clientWidth = 0. Detection: offsetWidth === 0 && scrollWidth > 0 && overflow === 'hidden' → CRITICAL.Summary of SA-CSS-CHA findings
| ID | Severity | Properties | Collapse mechanism | Detection |
|---|---|---|---|---|
| SA-CSS-CHA-001 | CRITICAL | word-spacing: -999px | Inter-word gaps collapse; words overlap | wordSpacing < -100px |
| SA-CSS-CHA-002 | CRITICAL | letter-spacing: -999px | All characters collapse to single column | letterSpacing < -100px |
| SA-CSS-CHA-003 | HIGH | font-stretch: 50% | Glyphs condensed to half-width hairlines | fontStretch < 75% (variable font) |
| SA-CSS-CHA-004 | CRITICAL | white-space:nowrap + width:0 + overflow:hidden | Single-line text clipped at zero-width container | offsetWidth=0 && scrollWidth>0 && overflow=hidden |