Security Research

CSS @font-face Metric Overrides as a Consent Attack Toolkit

Four @font-face descriptors — ascent-override, descent-override, line-gap-override, and size-adjust — each target a different dimension of how a browser lays out text. Designed to prevent Cumulative Layout Shift when swapping web fonts, they collectively form a complete consent-text manipulation toolkit. The attacks operate below the CSS property layer: getComputedStyle returns correct-looking values while the rendered output is invisible, unreadable, or clipped. Canvas pixel-sampling and scrollHeight vs clientHeight comparison are the only reliable detection methods.

By SkillAudit · September 24, 2026 · 12 min read

The problem they were designed to solve — and what that design enables

When a browser loads a page, it immediately lays out text using a fallback font (usually a system font like Arial or Georgia). When the declared web font finishes downloading, the browser substitutes it. If the web font has different vertical metrics than the fallback — a taller cap height, deeper descenders, wider line gaps — the substitution causes a visible layout shift. Elements jump. Lines reflow. This is measured as Cumulative Layout Shift (CLS) and negatively affects Core Web Vitals scores.

The CSS Fonts Working Group introduced four metric override descriptors to solve this: you can tell the browser to use the fallback font's metrics for the substituted web font, preventing the layout shift during font loading. The descriptors:

ascent-override

CSS Fonts Level 4Chrome 87+ / Firefox 89+ / Safari 17+

Controls the ascent metric — the distance above the baseline allocated to each glyph in line box computation. Expressed as a percentage of the font-size (e.g., ascent-override: 80%). In normal fonts, ascent is set in the font's OS/2 table. The descriptor overrides that value for all glyphs of this @font-face.

descent-override

CSS Fonts Level 4Chrome 87+ / Firefox 89+ / Safari 17+

Controls the descent metric — the distance below the baseline allocated to each glyph in line box computation. Expressed as a percentage of font-size. Normal descent is approximately 20–30% of font-size for Latin fonts. Overriding to 400% adds ~64px of below-baseline space per line at 16px.

line-gap-override

CSS Fonts Level 4Chrome 87+ / Firefox 89+ / Safari 17+

Controls the line gap — the additional spacing above and below a line box, beyond the ascent and descent. Expressed as a percentage of font-size. Normal line gaps range from 0–20% of font-size. Overriding to 500% adds ~80px of line gap at 16px — inflating each line box by ~96px total height.

size-adjust

CSS Fonts Level 4Chrome 92+ / Firefox 92+ / Safari 17+

Scales all glyph advance widths and metrics uniformly by a percentage. size-adjust: 80% renders each glyph at 80% of its declared size. The computed font-size CSS property is unchanged — only the rendered glyph dimensions scale. This is the only @font-face descriptor that reduces visible glyph size without touching the computed font-size property.

Each descriptor was built to solve a legitimate problem. Together, they give an attacker unprecedented control over how text is rendered — and critically, all of this control lives in the @font-face rule, below the CSS properties that most audit tools inspect.

Why line-height: normal is the critical prerequisite

The metric override descriptors only have their full effect when the element's line-height is computed from font metrics rather than a fixed value. If a consent element has an explicit line-height: 1.5, the browser multiplies the computed font-size (e.g., 16px) by 1.5 to get the line box height (24px) — regardless of what the font metrics say. The metric override descriptors have no effect on this calculation.

But when line-height: normal (the default for most elements that haven't been explicitly styled), the browser computes line box height from the font's actual metrics: ascent + descent + line-gap. This is where the metric overrides apply. An attacker setting descent-override: 400% on a consent element with line-height: normal expands each line box from approximately 22px to approximately 87px at a 16px base — a 4× expansion. A consent container sized at 192px (enough for ~8 normally-spaced lines) shows only 2 inflated lines, with the remaining 6 silently clipped by overflow: hidden.

/* line-height:normal is the prerequisite — metric overrides only affect
   line boxes when line height is derived from font metrics */

/* SAFE (from metric-override perspective): */
.consent-text {
  line-height: 1.5;  /* Fixed multiplier — metric overrides ignored */
  /* 1.5 × 16px = 24px line box regardless of @font-face metrics */
}

/* VULNERABLE: */
.consent-text {
  /* line-height: normal is the CSS initial value */
  /* Most consent text inherits line-height:normal unless explicitly set */
  /* Browser computes line box from: ascent + descent + line-gap */
  /* → Metric overrides in @font-face directly control line box height */
}

/* An MCP server that cannot add explicit CSS to the consent element
   can instead remove an explicit line-height value, reverting to
   line-height:normal — re-enabling the vulnerability. */

The default creates the attack surface: line-height: normal is the CSS initial value. Consent dialogs built without explicit line-height declarations are vulnerable by default. The attacker does not need to add line-height: normal to the consent element — they only need to ensure no explicit line-height override exists, which is the natural state of most simply-styled consent components.

How getComputedStyle fails to detect the attack

The central reason metric override attacks are so difficult to detect via standard JavaScript inspection is that getComputedStyle reports the CSS properties — not the rendered font metrics. Consider this scenario:

None of these values indicate anything wrong. The consent text appears intact at the DOM level. The CSS properties all look correct. But the rendered output may be showing 2.4px glyphs with 87px line boxes, clipping all but one line of the consent agreement.

The only properties that reveal the attack require combining CSS introspection with layout measurement:

/* getComputedStyle gives no signal about metric override attacks */

const el = document.querySelector('.consent-dialog');
const cs = getComputedStyle(el);

console.log(cs.fontSize);    // "16px"    — correct, size-adjust doesn't touch this
console.log(cs.lineHeight);  // "normal"  — this is a string, not a pixel value
console.log(cs.visibility);  // "visible" — correct

/* What DOES reveal the attack: */

/* 1. scrollHeight vs clientHeight */
console.log(el.scrollHeight > el.clientHeight);
// true if content is clipped by overflow:hidden
// Doesn't tell you WHY — could be metric overflow, too much text, etc.

/* 2. getBoundingClientRect() on individual elements vs expected height */
for (const line of el.querySelectorAll('p')) {
  const h = line.getBoundingClientRect().height;
  const fs = parseFloat(cs.fontSize);
  if (h / fs > 4) {
    console.log(`Line box ${h}px is >4× font-size ${fs}px — metric inflation suspected`);
  }
}

/* 3. Canvas pixel-sampling for actual rendered glyph size */
/* (see detection section below) */

The triple-override: maximising line-box inflation with ascent + descent + gap

The three line-box metric descriptors — ascent-override, descent-override, and line-gap-override — each control a different additive component of line box height. At line-height: normal, the browser sums all three to compute the final line box height. A triple-override attack sets all three to large values simultaneously, maximising the per-line inflation and minimising how many consent lines fit in a fixed-height container.

/* Triple-override: maximise line box inflation */
@font-face {
  font-family: 'ConsentFont';
  src: url('...') format('woff2');

  /* Line box height = ascent + descent + line-gap (all as % of font-size) */
  /* Default values for a typical Latin web font (approximate): */
  /*   ascent:   ~80% of font-size  */
  /*   descent:  ~20% of font-size  */
  /*   line-gap: ~0-10% of font-size */
  /* Default line box at 16px: ~(0.80 + 0.20 + 0.05) × 16px ≈ 16.8px */

  /* Triple-override attack: */
  ascent-override:   300%;   /* +48px above baseline at 16px */
  descent-override:  300%;   /* +48px below baseline at 16px */
  line-gap-override: 200%;   /* +32px additional gap at 16px */
  /* Total: (3.0 + 3.0 + 2.0) × 16px = 128px per line box */
}

/* A consent container normally showing 10 lines at 22px line height (220px):
   Triple-override: 10 lines × 128px = 1280px total height
   Container is 220px tall with overflow:hidden → shows ~1.7 lines
   User sees the consent title and the first half-sentence. That's it.
   The remaining 8+ consent clauses are silently clipped. */

/* getComputedStyle(consentEl).lineHeight → "normal"
   el.scrollHeight → 1280px
   el.clientHeight → 220px
   el.scrollTop    → 0 (if overflow:hidden — no scrollbar, no scroll position)

   The only way to see the attack: scrollHeight >> clientHeight with overflow:hidden. */

size-adjust: the unique "shrink without changing font-size" descriptor

While the three line-metric descriptors affect line box height, size-adjust operates on a different axis: it scales the actual rendered size of each glyph. A value of size-adjust: 15% renders each glyph at 15% of its declared size. The glyphs of "consent" in a 16px font render at approximately 2.4px tall — below the threshold of visibility on any standard display. But the CSS property font-size remains 16px.

This creates a unique detection blind spot. WCAG Success Criterion 1.4.4 (Resize Text) and minimum font size checks typically inspect the computed font-size CSS property. WCAG 1.4.4 compliance is often tested by verifying that getComputedStyle(el).fontSize returns a value above a minimum threshold. But size-adjust makes the actual rendered glyph smaller without changing the property that these checks inspect. The property says 16px; the pixel says 2.4px.

/* size-adjust: the detector-invisible glyph shrinker */

@font-face {
  font-family: 'ConsentFont';
  size-adjust: 15%;
  src: url('...') format('woff2');
}

.consent-text {
  font-family: 'ConsentFont', sans-serif;
  font-size: 16px;  /* Declared and computed: 16px */
                    /* Actual rendered glyph height: 2.4px */
}

/* What WCAG audit tools see: */
getComputedStyle(el).fontSize  // "16px" — PASSES minimum size check
el.textContent.length          // Non-zero — PASSES non-empty check
el.getBoundingClientRect()     // Has non-zero dimensions — PASSES visibility check

/* What the user sees: */
/* A line of horizontal smears at 2.4px height — effectively invisible text */

/* The only CSS-API-based detection: */
/* getBoundingClientRect().height / textContent.split('\n').length */
/* → line height per line; if much smaller than declared font-size, flag */

/* More reliable: canvas pixel-sampling */
/* Render one character at known coords; sample the pixel column;
   count consecutive non-background pixels; that's the actual glyph height. */

size-adjust vs font-variant-position: font-variant-position: sub is the only other CSS mechanism that shrinks text visually — but it only applies to subscript/superscript and is bounded at approximately 58% of font-size (the typical subscript scale factor). size-adjust is bounded only by the specified percentage — you can set it to 1% — and applies to all glyphs, not just positional variants. It is the only @font-face descriptor that can reduce all consent text to sub-pixel size without any CSS property reflecting a suspicious value.

Combining all four: a fully calibrated consent attack

The four descriptors can be combined in a single @font-face block to create a precisely calibrated compound attack. The combination provides two simultaneous effects: visibility destruction (size-adjust makes glyphs invisible) and layout destruction (triple metric overrides inflate line boxes to cause clipping). Either alone is detectable via a specific method; both together force the detection tooling to check against multiple independent failure modes simultaneously.

/* Full four-descriptor compound attack */
@font-face {
  font-family: 'UIFont';
  src: url('data:font/woff2;base64,...') format('woff2');

  /* Visibility destruction */
  size-adjust: 12%;          /* Glyphs rendered at 12% of 16px = 1.9px — sub-pixel */

  /* Layout destruction */
  ascent-override: 250%;     /* +40px above baseline */
  descent-override: 250%;    /* +40px below baseline */
  line-gap-override: 200%;   /* +32px additional gap */
  /* Total line box: (2.5 + 2.5 + 2.0) × 16px = 112px per line */

  /* Optional: scope to consent-specific codepoints */
  unicode-range: U+0020-007E; /* Full ASCII printable — all consent prose */
}

.consent-dialog {
  font-family: 'UIFont', Arial, sans-serif;
  max-height: 200px;
  overflow: hidden;
  /* line-height: not specified — defaults to "normal" → metric overrides apply */
}

/* Combined effect:
   - All consent glyphs render at 1.9px — sub-pixel invisible
   - Each consent line box is 112px tall
   - A 200px container shows approximately 1.8 inflated line boxes
   - scrollHeight ≈ 112px × N_lines; clientHeight = 200px
   - Visible area contains glyphs too small to read
   - Clipped area would also contain glyphs too small to read
   - Both effects reinforce each other: even if the user could scroll, text is invisible */

/* What getComputedStyle reports on the consent element:
   - fontSize: "16px"           → correct, unaffected by size-adjust
   - color: "rgb(26,26,26)"     → correct, untouched
   - visibility: "visible"      → correct, untouched
   - lineHeight: "normal"       → correct (it IS normal — the inflation is in metrics)
   - overflow: "hidden"         → correct, the explicit property
   - fontFamily: "UIFont"       → reveals the injected font family name
   Everything looks correct. The font name is the only lead. */

Canvas pixel-sampling: the only reliable rendered-size check

Because getComputedStyle is blind to font metric attacks and size-adjust in particular, canvas pixel-sampling is the gold-standard detection method for rendered glyph size. The technique: render a single test character from the consent font on an off-screen canvas at a known font-size, then scan pixel columns and rows to measure the actual height of the ink pixels. If the measured height is significantly smaller than the declared font-size, the font is using an adversarial size-adjust value.

/**
 * Canvas pixel-sampling: measure actual rendered glyph height
 * Returns the glyph's rendered height in pixels for the given character and font.
 */
function measureGlyphHeight(char, fontFamily, fontSize = 48) {
  const canvas = document.createElement('canvas');
  canvas.width = fontSize * 2;
  canvas.height = fontSize * 3;
  const ctx = canvas.getContext('2d');

  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#000000';
  ctx.font = `${fontSize}px "${fontFamily}", sans-serif`;
  ctx.textBaseline = 'middle';
  ctx.fillText(char, fontSize / 2, canvas.height / 2);

  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;

  let firstInkRow = -1, lastInkRow = -1;
  for (let y = 0; y < canvas.height; y++) {
    for (let x = 0; x < canvas.width; x++) {
      const i = (y * canvas.width + x) * 4;
      if (data[i] < 200) { // dark pixel = ink
        if (firstInkRow === -1) firstInkRow = y;
        lastInkRow = y;
        break;
      }
    }
  }

  if (firstInkRow === -1) return 0; // no ink = blank glyph
  return lastInkRow - firstInkRow + 1;
}

// Usage:
const measured = measureGlyphHeight('A', 'ConsentFont', 48);
const ratio = measured / 48;
if (ratio < 0.3) {
  console.error(`CRITICAL: ConsentFont glyph height ${measured}px for 48px declared size (${(ratio*100).toFixed(1)}%). size-adjust attack suspected.`);
}

scrollHeight comparison: the only reliable line-inflation check

For the metric override line-inflation attacks (ascent/descent/line-gap), the reliable detection method is comparing scrollHeight against clientHeight on consent containers. A container with overflow: hidden and a fixed height will have scrollHeight equal to its natural content height — which includes all the inflated line boxes — while clientHeight reflects only the visible height. A significant disparity (e.g., scrollHeight > 3 × clientHeight) in a consent container indicates metric inflation.

/**
 * scrollHeight check: detect line-box metric inflation in consent containers
 */
function detectLineInflation(consentSelector = '[data-consent], .consent, #consent-dialog') {
  const findings = [];

  function walk(el) {
    const cs = getComputedStyle(el);
    const isClipping = cs.overflow === 'hidden' || cs.overflowY === 'hidden';

    if (isClipping && el.scrollHeight > el.clientHeight) {
      const ratio = el.scrollHeight / el.clientHeight;
      if (ratio > 2) {
        findings.push({
          severity: ratio > 4 ? 'CRITICAL' : 'HIGH',
          element: el,
          scrollHeight: el.scrollHeight,
          clientHeight: el.clientHeight,
          ratio: ratio.toFixed(2),
          detail: `Consent element has scrollHeight ${el.scrollHeight}px vs clientHeight ${el.clientHeight}px (${ratio.toFixed(1)}× ratio). Content is clipped by overflow:hidden. If caused by @font-face metric overrides, the hidden content is not scrollable. Verify @font-face descent-override/line-gap-override values on the element's font family.`,
        });
      }
    }

    for (const child of el.children) walk(child);
  }

  for (const el of document.querySelectorAll(consentSelector)) {
    walk(el);
  }

  return findings;
}

Important caveat: scrollHeight > clientHeight alone does not prove a metric inflation attack — the container could simply have more text content than fits. The diagnostic value increases when combined with @font-face inspection: if the consent font family has descent-override or line-gap-override values above 100%, a scrollHeight mismatch strongly suggests metric inflation rather than content volume. Always cross-reference the two signals.

Remediation: freezing the consent font

The root cause of metric override attacks is that the browser accepts an arbitrary @font-face declaration for a font family that consent text uses. Remediation focuses on removing this trust:

RemediationWhat it preventsImplementation
Freeze consent font to system-ui All @font-face attacks: no downloadable font, no metric override, no blank glyph .consent-dialog { font-family: system-ui, -apple-system, sans-serif !important; }
CSP font-src 'self' Cross-origin font loading (blocks Attack 1 variants using external CDN fonts) Content-Security-Policy: font-src 'self' — note: does NOT block data: URI fonts
CSP font-src 'self' excluding data: Inline data: URI @font-face sources font-src 'self' — must not include data: in the directive; data: is blocked by default when not listed
Subresource Integrity (SRI) on font files Serves a legitimate-looking font URL that serves a modified binary @font-face { src: url('...') format('woff2') integrity('sha256-...'); } — CSS SRI descriptor (CSS Fonts Level 5 draft)
Explicit line-height on consent elements All line-box metric inflation attacks (ascent/descent/line-gap overrides) .consent-dialog * { line-height: 1.5 !important; } — removes dependence on font metrics for line height
MutationObserver on document.head / document.styleSheets Runtime @font-face injection by MCP skill scripts Observe style/link element additions; parse new @font-face rules; reject or flag suspicious metric descriptors before they are applied

The detection stack SkillAudit applies

Detecting @font-face metric override attacks requires multiple independent checks because no single method catches all four descriptors:

Step 1 Parse all @font-face rules — extract ascent-override, descent-override, line-gap-override, size-adjust values for every loaded @font-face block. Flag values outside normal ranges: size-adjust below 50%, descent-override above 150%, line-gap-override above 100%, ascent-override above 200% or below 50%.
Step 2 Cross-reference with consent elements — identify which @font-face families are used by consent dialog elements (via computed fontFamily). Flag metric-override @font-face blocks that apply to consent-font families.
Step 3 Check for line-height:normal — verify whether the consent element's computed line-height is the string "normal" (making it vulnerable to metric inflation). If so, compute the expected line box height from the @font-face metric values and compare to the container height.
Step 4 scrollHeight vs clientHeight comparison — measure the ratio on all overflow:hidden consent containers. Ratios above 2× trigger investigation. Ratios above 4× trigger automatic CRITICAL flag.
Step 5 Canvas pixel-sampling — render test characters from the consent font on an off-screen canvas at 48px. Measure actual glyph ink height. Flag fonts where the ratio of measured height to declared size is below 0.3 (rendering at less than 30% of declared size).
Step 6 unicode-range compound analysis — for any @font-face with non-default unicode-range AND one or more metric descriptors outside normal range, escalate to CRITICAL (compound targeting of specific character subsets). See our combined unicode-range attack analysis.

What this means for MCP skill reviews

The metric override attack surface is particularly relevant to MCP skill reviews because MCP skills run JavaScript in the user's browser context and can inject arbitrary stylesheets. A skill that injects even a single <style> element containing a @font-face block with adversarial metric descriptors can manipulate any consent dialog on the page — not just dialogs the skill itself controls. The attack is cross-consent: it targets the rendering of all text using the shadowed font family, wherever that family appears.

Combined with the font tech() and COLRv1 attack surface we covered in a previous post, the @font-face rule has become the primary attack vector for sub-CSS consent manipulation. Every MCP skill that handles user consent — displaying agreement text, presenting pricing tiers, showing data-sharing opt-out controls — should be audited for its entire @font-face chain, with special attention to metric descriptor values and their compound effects on consent-text rendering.

The descent-override, line-gap-override, and size-adjust individual attack analyses cover each descriptor's full attack pattern and detection code. The combined unicode-range attack shows how these descriptors combine with character-range targeting for surgical price disclosure manipulation.

Run a SkillAudit scan: SkillAudit performs all six detection steps above on every MCP skill submitted for audit. The @font-face metric analysis runs in a headless browser with canvas pixel-sampling and scrollHeight comparison against a consent-element heuristic. Free for public repositories — results in 60 seconds.