Security reference · CSS injection · Font geometry · Consent hiding
MCP server CSS font-stretch security
CSS font-stretch controls the horizontal width of rendered glyphs — selecting condensed or expanded axes in variable fonts and switching between condensed/expanded typeface variants in multi-face families. Setting it to extreme values (ultra-condensed 50%, ultra-expanded 200%) or sub-legibility percentages (10%, 20%) leaves consent text physically present in the DOM and in layout while making it visually unreadable or clipped. Unlike display:none, ultra-condensed consent text passes all display/visibility/dimension checks and is read by accessibility APIs — making it a viable and underdetected consent-hiding vector. Four attack patterns: keyword ultra-condensed/ultra-expanded values, CSS Level 4 sub-60% percentage values that evade keyword scanners, expansion-overflow clipping in fixed containers, and JS-deferred compression triggered at mousedown.
CSS font-stretch property fundamentals
font-stretch accepts keyword values (ultra-condensed 50%, extra-condensed 62.5%, condensed 75%, semi-condensed 87.5%, normal 100%, semi-expanded 112.5%, expanded 125%, extra-expanded 150%, ultra-expanded 200%) and, in CSS Fonts Level 4, percentage values from 50% to 200% (with browser support for values outside this range as a percentage of the normal width). For variable fonts with a wdth axis, any supported percentage value is rendered natively. For non-variable fonts, the browser selects the nearest available condensed or expanded face; if no condensed face exists, the browser may synthesize horizontal scaling by geometric transform.
| font-stretch value | Horizontal glyph width | Legibility at 14px base | DOM text present | Bounding box width |
|---|---|---|---|---|
| ultra-condensed (50%) | 50% of normal | Poor — characters squeezed to narrow strokes | Yes | ~50% of normal |
| 10% (Level 4) | 10% of normal | Unreadable — hairlines | Yes | ~10% of normal |
| ultra-expanded (200%) | 200% of normal | Normal per character, but container clips | Yes | 200% of normal (clipped) |
| normal (100%) | 100% | Normal | Yes | Normal |
Attack surface: SA-CSS-FSTR-001 — keyword ultra-condensed sub-legibility
font-stretch: ultra-condensed (50% width) compresses each character's horizontal dimension to half its normal width. At 14px base font-size, character strokes that are normally 1.5–2px wide become 0.75–1px — at the sub-pixel boundary for many display densities. Consent text rendered in ultra-condensed is technically present as characters (not as a blob or pixels) but is illegible at any practical reading distance on a 96-dpi screen. The text has a non-zero bounding box (roughly half the normal width), is present in the accessibility tree, and passes all display/visibility/opacity checks. Unlike font-size:1px, ultra-condensed text at 14px has measurable line height and non-zero element height — a height-based check does not detect it.
/* MCP inject — consent text with ultra-condensed font */
.consent-disclosure { font-stretch: ultra-condensed; }
/* Consent text occupies layout, passes all visibility checks,
DOM text intact, a11y reads it — but visually illegible */
Detection: compute parseFloat(getComputedStyle(el).fontStretch) — browsers normalize the value to a percentage string. A threshold of fontStretch < 75 (condensed or tighter) on consent text flags this finding. Note: some older browsers return keyword strings rather than percentages; handle both forms.
Attack surface: SA-CSS-FSTR-002 — CSS Level 4 percentage values below 60%
CSS Fonts Level 4 allows font-stretch percentage values outside the keyword range, including values below 50% (the ultra-condensed floor). font-stretch: 10% or font-stretch: 20% compresses glyphs to a fraction of their normal width — at 14px with 10% stretch, each character is ~1.4px wide, rendering as hairline marks. A scanner checking for keyword values (ultra-condensed, condensed) in the CSS source misses these percentage values entirely. Additionally, the percentage may be encoded via a CSS custom property: --mcp-ui-font-width: 10%; font-stretch: var(--mcp-ui-font-width) — the source property reads var() while the computed value resolves to 10%.
/* SA-CSS-FSTR-002: sub-60% percentage value */
:root { --mcp-width-axis: 10%; }
.consent-text { font-stretch: var(--mcp-width-axis); }
/* Keyword scanner: sees only "var(--mcp-width-axis)" — no match */
/* getComputedStyle().fontStretch: "10%" — threshold catches it */
Detection: Always read getComputedStyle(el).fontStretch — this resolves var() and calc() chains. Compare the numeric value against a threshold of 75% (condensed). Values below 75% on consent-bearing elements should be flagged.
Attack surface: SA-CSS-FSTR-003 — ultra-expanded overflow clipping in fixed containers
font-stretch: ultra-expanded (200%) doubles the horizontal width of each character. In a fixed-width container with overflow: hidden, the ultra-expanded text overflows the container starting partway through the first word. A 400px container holding "I agree to grant filesystem access" in normal font shows all 40 characters; in ultra-expanded, each character is ~2× wider and the container clips the text after approximately the 13th character — showing only "I agree to gran" (visually) before the clip boundary. The consent scope, permissions, and consequences are all after that point.
/* SA-CSS-FSTR-003: ultra-expanded overflow clip */
.mcp-dialog { width: 400px; overflow: hidden; }
.consent-text { font-stretch: ultra-expanded; }
/* Only first ~13 chars of consent visible; remainder clipped.
scrollWidth >> offsetWidth signals overflow. */
This is distinct from the sub-legibility attacks above: text is legible per character, but the content is truncated. Detection requires checking el.scrollWidth > el.offsetWidth + 10 on the consent element — if the natural content width exceeds the clipping container width, text is being truncated.
Attack surface: SA-CSS-FSTR-004 — JS-deferred compression at mousedown
All three static patterns above are detectable at page-load time. The timing-attack variant applies normal font-stretch: 100% at load and transitions to font-stretch: 10% at the moment the user initiates the install (mousedown event). font-stretch is an animatable CSS property in variable fonts — the transition from 100% to 10% can be animated over 150ms, producing a smooth "collapsing" animation that looks like an intentional UI behavior rather than a consent-hiding attack. At load time, computed font-stretch is 100%; after mousedown, it becomes 10%. Any load-time audit sees the normal value.
/* SA-CSS-FSTR-004: JS-triggered compression at mousedown */
.consent-text { font-stretch: 100%; transition: font-stretch 0.15s ease-in; }
// MCP JS:
installBtn.addEventListener('mousedown', () => {
consentEl.style.fontStretch = '10%';
// smooth compression to hairlines before click fires
});
Detection: MutationObserver on the style and class attributes of consent elements, plus interaction-time re-checking of computed font-stretch after simulating mousedown on the install button. If getComputedStyle(consentEl).fontStretch drops below 75% after interaction, flag SA-CSS-FSTR-004.
Findings summary
Key insight
Always check parseFloat(getComputedStyle(consentEl).fontStretch) against a threshold of 75 (condensed). Values below 75 signal that consent text is being compressed toward illegibility. For ultra-expanded attacks, check el.scrollWidth > el.offsetWidth + 10. For deferred attacks, simulate mousedown and re-check. getComputedStyle resolves var() chains automatically — do not rely on stylesheet source scanning for font-stretch values.
See also: font-size sub-readable attacks, font-variant attacks, zero-width container attacks for the full consent-hiding surface involving font and dimension manipulation.