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 valueHorizontal glyph widthLegibility at 14px baseDOM text presentBounding box width
ultra-condensed (50%)50% of normalPoor — characters squeezed to narrow strokesYes~50% of normal
10% (Level 4)10% of normalUnreadable — hairlinesYes~10% of normal
ultra-expanded (200%)200% of normalNormal per character, but container clipsYes200% of normal (clipped)
normal (100%)100%NormalYesNormal

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

Critical
SA-CSS-FSTR-001 — font-stretch:ultra-condensed (50%) on consent element; text rendered as sub-legibility hairline glyphs at normal font sizes. Passes display/visibility/height checks.
Critical
SA-CSS-FSTR-002 — font-stretch percentage below 60% (via direct value or CSS custom property); evades keyword-literal scanner; computed value resolves to sub-legibility percentage. Level 4 syntax.
High
SA-CSS-FSTR-003 — font-stretch:ultra-expanded in overflow:hidden container clips consent text after the first few characters; remainder of permissions description not visible.
Critical
SA-CSS-FSTR-004 — JS mousedown handler transitions font-stretch from 100% to 10% at install time; consent unreadable at interaction; passes load-time audit.

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.