Security reference · CSS injection · Font rendering · Consent hiding

MCP server CSS font-size security

CSS font-size controls the rendered height of text glyphs. Setting it to sub-readable values (1px, 0.01px) leaves consent text physically present in the DOM and in layout while making it visually indistinguishable from blank space. Unlike display:none or visibility:hidden, tiny-font-size text passes all DOM-presence checks, occupies measurable bounding box area, and is read by accessibility APIs — making it one of the harder consent-hiding vectors to detect without checking the computed font size of every consent-bearing element. Four attack patterns: direct tiny pixel values, sub-pixel decimal values, near-zero fractional values that evade integer scanners, and JS-deferred reduction triggered at mousedown.

CSS font-size property fundamentals

font-size accepts absolute lengths (px, pt, cm), relative lengths (em, rem, vw, vh), keywords (small, x-small, xx-small, medium, large, inherit), and the calc() function. The minimum rendered font size in browsers is theoretically 0px, but browsers impose a minimum readable font size floor that users can configure — by default 10px in Chrome (Blink), 10px in Firefox, and 9px in Safari. Values below this floor are clamped to the minimum for display, but the computed style value still returns the declared value. At 1px, Chrome does not clamp the glyph (1px > 0px), so each character renders as a literal 1px × 1px (or smaller) dot — present in layout, occupying line-height space, but not legible as text.

font-size valueRendered outputBounding boxDOM text presencea11y tree reading
16px (normal)Fully legible textNormal heightYesYes, full text
1pxSingle-pixel dots per character, not legiblePresent (line-height applies)YesYes, full text
0.01pxSub-pixel; all characters collapse to a pointNegligible (sub-pixel)YesYes, full text
0pxNo glyph rendered; all characters invisibleZero heightYesYes, full text

Why font-size attacks are harder to catch than opacity:0: An auditor checking for consent-hiding via opacity: 0, visibility: hidden, or display: none will miss font-size attacks entirely. The element is visible (opacity: 1), in the document flow (display: block), and has a non-zero bounding box area (line-height keeps it present). Only a direct check of getComputedStyle(el).fontSize against a legibility threshold catches this vector.

Attack 1: font-size: 1px — sub-readable pixel-dot text

Setting font-size: 1px on a consent disclosure renders each character as a 1px dot. At standard display densities (96 DPI, 1x pixel ratio), 1px is approximately 0.26mm — far below the 1mm minimum character height required for legibility. On high-DPI displays (2x or 3x), the character may occupy more physical pixels but renders to an equivalent visual angle of <0.2mm, still illegible. The text is present, the element has a non-zero bounding box (line-height typically adds 1.2–1.5× font-size of height, so at 1px font-size a line is 1.2–1.5px tall), and accessibility APIs read the full text:

/* Malicious CSS — SA-CSS-FTSZ-001 */
.mcp-consent-disclosure {
  font-size: 1px;
  /* Consent text renders as a row of 1px dots — visually blank space */
  /* line-height adds ~1.2px of height, keeping the element in layout */
  /* Element bounding box: offsetWidth=container-width, offsetHeight~1px */
  /* getComputedStyle(el).fontSize returns "1px" — detectable if checked */

  /* Why not font-size:0? font-size:0 collapses height and triggers font-size:0
     literal scans. font-size:1px evades "is font-size zero" checks while being
     equally unreadable. */
}

/* What an auditor sees at load time (without font-size check):
   el.offsetHeight     → ~1  (non-zero — passes height check)
   el.style.display    → ""  (no inline display:none)
   getComputedStyle(el).opacity → "1"  (fully "opaque")
   getComputedStyle(el).visibility → "visible"
   el.textContent      → "By installing this MCP server you grant it access to..."
                         (full consent text — passes textContent check)
   el.getBoundingClientRect() → {width:600, height:1.2, top:...} (non-zero)

   Without checking fontSize explicitly, this element appears present and visible. */

/* Detection: */
function checkConsentFontSize(el) {
  const fs = parseFloat(getComputedStyle(el).fontSize);
  if (fs < 10) {
    return { id: 'SA-CSS-FTSZ-001', severity: 'critical',
      message: `Consent element has font-size ${fs}px — below 10px legibility threshold. Text is physically present but not readable. Computed: "${getComputedStyle(el).fontSize}".` };
  }
}

Attack 2: font-size: 0.01px — sub-pixel invisible text

Setting font-size to a sub-pixel decimal value (0.01px, 0.001px, 0.1px) produces glyphs that collapse to a single sub-pixel point. Browsers handle sub-pixel font sizes differently: Chrome and Firefox render glyphs below ~0.5px as effectively invisible (zero rendered pixels at 1x DPI), while Safari may round up to 1px. In all cases, the visual output is indistinguishable from blank space. The attack advantage over font-size:0 is that the value is non-zero — evading "is font-size zero" literal checks:

/* Malicious CSS — SA-CSS-FTSZ-002 */
.mcp-terms-text {
  font-size: 0.01px;
  /* Sub-pixel: each character renders below one device pixel */
  /* Chrome: rounds to 0px rendering (glyphs are invisible) */
  /* Firefox: similar sub-pixel rounding to invisible */
  /* Safari: may render as 1px dot (still not legible) */

  /* Evasion advantage: scanner checking "font-size == 0" misses this */
  /* getComputedStyle returns "0.01px" — non-zero, but non-legible */
}

/* Fractional variants and their legibility: */
.variant-a { font-size: 0.5px; }   /* Chrome: ~0px rendered; Safari: 1px dot */
.variant-b { font-size: 0.1px; }   /* All browsers: effectively invisible */
.variant-c { font-size: 0.001px; } /* All browsers: sub-pixel, invisible */
.variant-d { font-size: 0.25px; }  /* Chrome: 0px rendered; Safari: 1px dot */

/* All variants: offsetHeight via line-height may still be non-zero (inherited
   line-height is applied to the font-size, so 1.5 * 0.01px = 0.015px ≈ 0px).
   getBoundingClientRect().height rounds to 0 for sub-pixel heights.
   This is the one case where height-checking CAN catch the attack. */

/* Detection (covers both 0 and sub-pixel): */
function detectSubPixelFontSize(el) {
  const fs = parseFloat(getComputedStyle(el).fontSize);
  const height = el.getBoundingClientRect().height;
  if (fs < 1 || height < 1) {
    return { id: 'SA-CSS-FTSZ-002', severity: 'critical',
      message: `Consent element font-size is ${fs}px (sub-pixel). getBoundingClientRect height is ${height}px. Text is not rendered at human-legible scale.` };
  }
}

Attack 3: near-zero fractional values — evading integer-literal scanners

A scanner that checks for font-size: 0, font-size: 0px, or font-size: 1px as literals can be evaded by any value that is not in the scanner's blocklist. Near-zero fractional values (2px, 3px, 4px) are above 0px but below the 10px legibility floor — a 4px character at 96 DPI renders at approximately 1.1mm, too small to read at normal viewing distances. A value of 2px is technically non-zero, non-one, and uses no decimal point — it passes both zero checks and integer-one checks while being visually unreadable:

/* Malicious CSS — SA-CSS-FTSZ-003 */

/* Near-zero values between 0 and 10px — all below minimum legibility floor */
.attack-2px  { font-size: 2px; }  /* 0.53mm — not readable at arm's length */
.attack-3px  { font-size: 3px; }  /* 0.79mm — not readable at arm's length */
.attack-4px  { font-size: 4px; }  /* 1.06mm — marginal; needs 5cm viewing distance */
.attack-6px  { font-size: 6px; }  /* 1.59mm — very small; needs 10cm */
.attack-9px  { font-size: 9px; }  /* 2.38mm — small but technically legible at close range */

/* CSS custom property obfuscation — scanner sees --mcp-ui-base without tracing the value */
:root { --mcp-ui-base: 0.02rem; } /* 0.02 * 16px = 0.32px — sub-pixel */
.mcp-consent-disclosure { font-size: var(--mcp-ui-base); }

/* calc() obfuscation: */
.mcp-terms { font-size: calc(100% - 99.9%); }
/* 100% of parent (16px) - 99.9% of parent (15.984px) = 0.016px — sub-pixel */

/* Detection: threshold check covers all near-zero variants */
const LEGIBILITY_THRESHOLD_PX = 10;
function detectNearZeroFontSize(el) {
  const fs = parseFloat(getComputedStyle(el).fontSize);
  if (isNaN(fs) || fs < LEGIBILITY_THRESHOLD_PX) {
    return { id: 'SA-CSS-FTSZ-003', severity: 'high',
      message: `Consent element computed font-size is ${fs}px — below ${LEGIBILITY_THRESHOLD_PX}px legibility threshold. May be set via custom property or calc() expression. Raw computed value: "${getComputedStyle(el).fontSize}".` };
  }
}

CSS custom property tracing: When font-size: var(--mcp-ui-base) is the declaration, a scanner must resolve the custom property chain to determine the actual computed size. getComputedStyle(el).fontSize always returns the fully resolved, computed pixel value — regardless of how many var() or calc() levels are involved. Always use the computed style, not the declared style, for font-size checks.

Attack 4: JS-deferred font-size reduction — normal at load, 1px at mousedown

The consent element renders at a normal, legible font-size (14px or 16px) at page load. A mousedown event listener on the install button reduces the font-size to 1px in the same event handler — before the click event fires and before the install action completes. By the time the user lifts their finger from the mouse button, the consent text has already collapsed. A load-time audit sees 16px and passes; the user sees consent disappear at the exact moment they commit to clicking:

/* Malicious CSS — SA-CSS-FTSZ-004 */
.mcp-consent-disclosure {
  font-size: 16px; /* Normal at load time — passes load-time font-size audit */
  transition: font-size 0s; /* Instant change, no smooth transition */
}

/* JS that runs at install click time: */
document.querySelector('#mcp-install-btn').addEventListener('mousedown', () => {
  /* mousedown fires before click — the install action hasn't started yet,
     but the user has already committed to clicking */
  const consent = document.querySelector('.mcp-consent-disclosure');
  consent.style.fontSize = '1px';
  /* Consent text collapses to 1px dot row instantly */
  /* User sees the consent disappear as they press the button */
  /* By the time click fires (mouseup + click events), consent is already hidden */
});

/* Timeline:
   t=0:        page load → font-size: 16px → audit PASSES
   t=2s:       user reads consent (visible, legible)
   t=4s mousedown: font-size set to 1px → consent collapses
   t=4s click:    install action starts → consent already gone */

/* More subtle version — uses class toggle to avoid inline style: */
.mcp-consent-disclosure.committing { font-size: 1px; }

document.querySelector('#mcp-install-btn').addEventListener('mousedown', () => {
  document.querySelector('.mcp-consent-disclosure').classList.add('committing');
});

/* Detection: MutationObserver on style attribute AND class attribute of consent element */
function monitorConsentFontSizeChanges(el) {
  const observer = new MutationObserver(() => {
    const fs = parseFloat(getComputedStyle(el).fontSize);
    if (fs < 10) {
      reportFinding({ id: 'SA-CSS-FTSZ-004', severity: 'critical',
        message: `Consent element font-size changed to ${fs}px during user interaction (mousedown/click). Load-time font-size was ≥10px. Dynamic font-size reduction collapses consent text at install-commit time.` });
    }
  });
  observer.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
  /* Also observe the parent element for class changes that affect inherited font-size */
  if (el.parentElement) observer.observe(el.parentElement, { attributes: true, attributeFilter: ['style', 'class'] });
}

Minimum legibility threshold for audits: The W3C WCAG 2.1 Success Criterion 1.4.4 (Resize Text) requires text to be resizable to 200% without loss of content. Baseline legibility starts at 9–10px at normal viewing distance. SkillAudit uses a 10px threshold for computed font-size checks on consent-bearing elements — any value below this threshold triggers SA-CSS-FTSZ findings regardless of how it's declared.

SkillAudit findings for CSS font-size consent attacks

CriticalSA-CSS-FTSZ-001 — Consent-bearing element has font-size: 1px or other integer pixel value below 10px. Text is present in DOM and layout but not legible at any normal viewing distance. Passes all DOM-presence, opacity, visibility, and display checks — only a computed font-size threshold check catches it.
CriticalSA-CSS-FTSZ-002 — Consent-bearing element has a sub-pixel font-size (below 1px). Glyphs collapse to a sub-pixel point indistinguishable from invisible. Non-zero value evades "font-size == 0" literal checks. getBoundingClientRect().height rounds to 0 — a height check will catch this case.
HighSA-CSS-FTSZ-003 — Consent-bearing element has a near-zero font-size (1–9px) set via custom property (var(--x)), calc() expression, or non-round integer value. Computed pixel value is below legibility threshold. Custom-property chain must be traced via getComputedStyle() for detection.
CriticalSA-CSS-FTSZ-004 — Consent-bearing element's font-size transitions from a normal value (≥10px) at load time to a sub-readable value (<10px) at user interaction (mousedown, click, or class addition). Load-time audit passes; real-time MutationObserver on style/class attributes detects the change.

Related MCP consent attack research

SkillAudit checks computed font-size on all consent-bearing elements against a 10px legibility threshold, resolving any var() or calc() chains via getComputedStyle(). A MutationObserver runs through the install flow to catch JS-deferred font-size reductions. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-FTSZ findings.