MCP Security Reference

MCP server CSS @font-face ascent-override / descent-override security

CSS Fonts Level 4 introduced three @font-face descriptors — ascent-override, descent-override, and line-gap-override — that replace the metrics embedded in the font file with custom percentage values. These metrics control the vertical space allocated per line box: the ascent (space above the baseline) and descent (space below). MCP servers abuse these descriptors in two opposite directions: (1) Inflation — setting ascent-override: 300%; descent-override: 200% makes each line take 5× its normal height, so a fixed-height consent dialog can show only one or two lines; (2) Collapse — setting ascent-override: 0%; descent-override: 0% collapses line boxes to zero height, stacking all consent lines on the baseline in an illegible overlapping mass. getComputedStyle(el).lineHeight does not reveal these overrides — the attack lives in the @font-face rule.

Attack findings

HIGHSA-CSS-FASCI-001 — @font-face ascent-override:300% descent-override:200% on consent font; each 14px line takes ~70px total line box height; 400px dialog shows only 5 lines; 15-line consent has 10 lines below fold; getComputedStyle.lineHeight reports 'normal' (unchanged); only @font-face rule inspection reveals it
HIGHSA-CSS-FASCI-002 — @font-face ascent-override:0% descent-override:0% line-gap-override:0% on consent font; all line boxes collapse to zero height; all text rendered on a single baseline; 15 consent lines stacked on each other; visual: single-line of overlapping characters; offsetHeight reports 0px or near-0px but element is in DOM
HIGHSA-CSS-FASCI-003 — @font-face descent-override:500% only (ascent normal); large blank space below each line's baseline; each 14px line has 70px of blank below; 10-line consent is 700px+ tall in a 300px dialog; critical middle clauses below fold; asymmetric — only descent inflated; ascent-override check alone misses it
MEDIUMSA-CSS-FASCI-004 — JS mousedown dynamically inserts <style> with @font-face containing ascent-override:300%; font-family matches consent element's loaded font; consent font metrics change at install click; static page load audit finds no suspicious @font-face overrides; runtime injection via MutationObserver on document.head

Background: @font-face metric override descriptors

Every font file embeds four values in its OS/2 or HHEA table: sTypoAscender, sTypoDescender, sTypoLineGap, and usWinAscent/usWinDescent. These values, expressed as units relative to the font's units-per-em, determine how much vertical space the browser allocates above and below each glyph in a line box. CSS Fonts Level 4 introduced override descriptors that replace these embedded values:

The total line box height allocated for a given font is proportional to (ascent + descent + line-gap) / 1000 × fontSize. Doubling the ascent percentage doubles the line box height for that font, even if line-height: normal is set — because line-height: normal asks the browser to use the font's natural metrics, which are now overridden. Only an explicitly declared line-height value (e.g., line-height: 1.5) can override the metric overrides.

Detection gap: getComputedStyle(el).lineHeight returns 'normal' when no line-height is explicitly set — regardless of what the @font-face metric overrides do to the actual rendered line spacing. The attack inflates line spacing at the font metric level, not the CSS line-height level. Detection requires iterating document.styleSheets, finding @font-face rules whose font-family matches the consent element's computed font, and checking the ascent-override, descent-override, and line-gap-override descriptors for values outside the normal range (roughly 75%–120%).

Attack 1 — metric inflation pushes consent below fold (SA-CSS-FASCI-001)

With a standard 14px font, typical ascent + descent metrics sum to approximately 100–130% of the em size. A @font-face rule setting ascent-override: 300% and descent-override: 200% produces a combined metric of 500% — roughly 5× the em size. At 14px font-size, each line box becomes approximately 70px tall (5 × 14 = 70px), even though the glyphs themselves are still 14px high. The extra space appears as unusually large line spacing with no other visual hint of manipulation. In a fixed-height dialog of 400px with overflow: hidden, only ⌊400/70⌋ = 5 lines of consent are visible above the fold. A 15-line consent block has the final 10 lines pushed below the dialog height and hidden by overflow clipping. The consent header ("By installing you agree to the following...") is visible; the key permission clauses in the middle and end are below the fold. The install button is a separate fixed element at the top of the dialog — not affected by the inflated line metrics — and remains fully visible.

/* Attack: metric inflation — each line takes 70px in a 14px font */
@font-face {
  font-family: 'AppFont';
  src: url('/fonts/app.woff2');
  ascent-override: 300%;    /* 300% of 14px = 42px above baseline per line */
  descent-override: 200%;   /* 200% of 14px = 28px below baseline per line */
  /* total per-line height: ~70px vs. normal ~18px */
}

.consent-text {
  font-family: 'AppFont', sans-serif;
  font-size: 14px;
  line-height: normal;  /* 'normal' uses font metrics — overridden to 500% em */
  /* getComputedStyle.lineHeight: 'normal' — no indication of 70px actual height */
}

SA-CSS-FASCI-001 (High). Detection: iterate document.styleSheets, find CSSFontFaceRule entries matching the consent element's font-family, extract ascent-override and descent-override values, parse as floats. Flag if ascentOverride + descentOverride > 250 (combined percentage above 2.5× em suggests intentional inflation).

/* Detection: @font-face metric override inspection */
function checkFontMetricOverride(consentEl) {
  const consentFamily = getComputedStyle(consentEl).fontFamily.toLowerCase();
  const findings = [];
  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }
    for (const rule of rules) {
      if (rule.type !== CSSRule.FONT_FACE_RULE) continue;
      const family = rule.style.getPropertyValue('font-family')
        .replace(/['"]/g, '').toLowerCase();
      if (!consentFamily.includes(family)) continue;
      const ao = parseFloat(rule.style.getPropertyValue('ascent-override')) || null;
      const dso = parseFloat(rule.style.getPropertyValue('descent-override')) || null;
      const lgo = parseFloat(rule.style.getPropertyValue('line-gap-override')) || null;
      const combined = (ao || 0) + (dso || 0) + (lgo || 0);
      if (combined > 250 || ao === 0 || dso === 0) {
        findings.push({
          vuln: 'SA-CSS-FASCI-001',
          family,
          ascentOverride: ao,
          descentOverride: dso,
          lineGapOverride: lgo,
          combined
        });
      }
    }
  }
  return findings.length ? findings : null;
}

Attack 2 — metric collapse causes line overlap (SA-CSS-FASCI-002)

The opposite attack sets all three metrics to 0%: ascent-override: 0%; descent-override: 0%; line-gap-override: 0%. The resulting line box height is 0px. All consent lines collapse to the baseline and are rendered stacked on top of each other. The visual result is a single line of completely overlapping text characters from all 15 consent lines simultaneously — an undifferentiated cluster of overlapping ink that no human can read. The element's offsetHeight reports 0 or near-0. getBoundingClientRect().height also reports 0. A geometric height check flags this as an invisible element — but the reason is the font metric collapse, not a standard display/visibility/opacity attack. Standard checks for display: none and visibility: hidden both return normal values.

/* Attack: metric collapse — all lines stack on baseline */
@font-face {
  font-family: 'AppFont';
  src: url('/fonts/app.woff2');
  ascent-override: 0%;      /* zero height above baseline */
  descent-override: 0%;     /* zero height below baseline */
  line-gap-override: 0%;    /* no additional gap */
  /* result: line box height = 0; all text stacked on single baseline */
}

/* Detection: flag ascent-override:0 or descent-override:0 */
/* Also check BCR height — if zero for a populated text element, font collapse likely */
function checkLineBoxCollapse(consentEl) {
  const bcr = consentEl.getBoundingClientRect();
  const cs = getComputedStyle(consentEl);
  if (bcr.height < 2 && consentEl.textContent.trim().length > 10) {
    if (cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0') {
      return { vuln: 'SA-CSS-FASCI-002', detail: 'zero BCR height with visible text — possible font metric collapse' };
    }
  }
  return null;
}

Attack 3 — asymmetric descent-only inflation (SA-CSS-FASCI-003)

A detector that checks only ascent-override misses an attack using only descent-override. Setting descent-override: 500% with a normal ascent creates a very large blank space below each line's baseline (below the visible glyphs). Each line renders with its characters in the upper portion and 70px of blank below (500% × 14px = 70px). The 10-line consent paragraph becomes 700px+ tall in a 300px dialog, with most lines' glyphs starting below the dialog fold. A per-line detector that checks ascent finds a normal value; only the descent check reveals the attack. The line-gap-override descriptor adds a third attack axis: line-gap-override: 500% inserts 70px of gap between each line without affecting ascent or descent separately — evading detectors that only check ascent and descent.

/* Attack: descent-only metric inflation */
@font-face {
  font-family: 'AppUI';
  src: url('/fonts/app.woff2');
  /* ascent-override: normal — looks fine */
  descent-override: 500%;  /* 70px blank below each line at 14px */
}

/* Detection: check each metric individually, not just combined sum */
function checkIndividualMetrics(consentEl) {
  const consentFamily = getComputedStyle(consentEl).fontFamily.toLowerCase();
  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }
    for (const rule of rules) {
      if (rule.type !== CSSRule.FONT_FACE_RULE) continue;
      const family = rule.style.getPropertyValue('font-family')
        .replace(/['"]/g, '').toLowerCase();
      if (!consentFamily.includes(family)) continue;
      for (const prop of ['ascent-override', 'descent-override', 'line-gap-override']) {
        const val = rule.style.getPropertyValue(prop);
        if (!val || val === 'normal') continue;
        const pct = parseFloat(val);
        if (pct === 0) {
          return { vuln: 'SA-CSS-FASCI-003', detail: `${prop}: 0% — line box collapse` };
        }
        if (pct > 150) {
          return { vuln: 'SA-CSS-FASCI-003', detail: `${prop}: ${pct}% — inflated metric pushes consent below fold` };
        }
      }
    }
  }
  return null;
}

Attack 4 — runtime @font-face metric injection at mousedown (SA-CSS-FASCI-004)

At page load, the consent element uses a normal font with default @font-face rules (or no custom font). The page may load the target font file via <link rel="preload"> — a common performance optimization that raises no security flags. At mousedown, JS inserts a <style> element containing a new @font-face rule for the same font-family name with ascent-override: 300%. Because the font file is already cached (preloaded), the metric override applies immediately without a network round-trip. The consent element's line spacing jumps from 18px to 70px per line in the click frame. By the time the click event fires, the consent is mostly below the dialog fold. A static page-load audit finds no override — the only @font-face rule at load time is the normal preload declaration without metric overrides.

/* Attack: preload font, inject metric override at mousedown */
/* In HTML: */
<link rel="preload" href="/fonts/app.woff2" as="font" crossorigin>

/* At mousedown: */
installBtn.addEventListener('mousedown', () => {
  const style = document.createElement('style');
  style.textContent = `
    @font-face {
      font-family: 'AppFont';
      src: url('/fonts/app.woff2');
      ascent-override: 300%;
      descent-override: 200%;
    }
  `;
  document.head.appendChild(style);
  /* font is already cached — override applies immediately */
});

/* Detection: MutationObserver on document.head */
new MutationObserver((mutations) => {
  for (const m of mutations) {
    for (const node of m.addedNodes) {
      if (node.tagName === 'STYLE' || node.tagName === 'LINK') {
        const finding = checkFontMetricOverride(consentEl);
        if (finding) {
          flagTampering('SA-CSS-FASCI-004');
          installBtn.disabled = true;
        }
      }
    }
  }
}).observe(document.head, { childList: true });

SkillAudit detection: SkillAudit inspects all @font-face rules in document.styleSheets and extracts ascent-override, descent-override, and line-gap-override descriptors. It flags values of 0% (line-box collapse), values above 150% (space inflation), and combined totals above 250% (multi-axis inflation). It also monitors for runtime @font-face injection via MutationObserver on document.head and re-audits on each style insertion. Run a free audit →

Detection summary

Attack IDProperties involvedKey detection signal
SA-CSS-FASCI-001@font-face ascent-override:300% + descent-override:200%; combined 500% em; each line 70px; consent below foldCSSFontFaceRule ascent-override + descent-override combined > 250% AND font-family matches consent element
SA-CSS-FASCI-002@font-face ascent-override:0% descent-override:0% line-gap-override:0%; all line boxes collapse to 0px; text overlapCSSFontFaceRule any metric override = 0% OR BCR.height < 2 with non-empty textContent and visible element
SA-CSS-FASCI-003@font-face descent-override:500% only; large blank below each line; consent pushed below fold; ascent-only detector missescheck each metric individually: any single metric > 150% OR = 0% flags independently
SA-CSS-FASCI-004preloaded font + JS mousedown inserts @font-face with inflated metric overrides; static audit cleanMutationObserver on document.head for style/link insertion + re-run font metric audit on each insert