Security Guide

MCP server CSS @font-face size-adjust security — consent text rendered at illegible scale

The CSS @font-face size-adjust descriptor scales every glyph in a font proportionally — after the browser's font-size pipeline has run. Setting size-adjust: 10% renders a 16px consent text as 1.6px-tall glyphs, visually indistinguishable from a horizontal smear. Yet getComputedStyle(el).fontSize still returns "16px", because size-adjust is a font-metric transformation below the CSS property layer. Every WCAG minimum-font-size check and automated accessibility audit passes. This is the only CSS mechanism that reduces the visual glyph size to near-zero while the computed font-size property remains untouched.

How size-adjust works

The size-adjust descriptor was introduced in CSS Fonts Level 5 (Chrome 92+, Firefox 89+, Safari 17+, covering approximately 93% of desktop browsers in 2026) to help web developers match web font sizes to system font fallback sizes, preventing cumulative layout shift during font load. It works by scaling all glyph outlines and advance widths by the specified percentage before they are rendered, without touching the computed font-size. A size-adjust: 110% value makes a web font visually match a slightly smaller fallback; a size-adjust: 90% shrinks the font slightly. When used maliciously with values near 0%, the effect is that the font renders at an arbitrarily small visual size while the CSS property layer reports a normal font-size.

This is categorically different from font-size reduction (changes the computed value, detectable by audits), font-variant-position: sub (reduces glyph size to ~58% via OpenType variant, computed font-size unchanged but the effect is bounded), and transform: scale(0.1) (changes layout geometry, detectable via getBoundingClientRect). With size-adjust: 10%, neither the computed font-size, nor the element's bounding rect, nor its visual overflow is modified in a trivially detectable way — the element occupies normal layout space, the text is technically present, and glyphs are legitimately rendered at 1.6px.

/* @font-face with extreme size-adjust */
@font-face {
  font-family: "NormalBodyFont";
  src: url("https://cdn.mcp-example.com/body.woff2") format("woff2");
  size-adjust: 8%;   /* scales glyphs to 8% of declared font-size */
}

.consent-text {
  font-family: "NormalBodyFont", sans-serif;
  font-size: 16px;       /* declared size — this is what auditors see */
  color: #111111;        /* passes color contrast */
  visibility: visible;
}

/* Visual result:
   Declared font-size: 16px  →  Rendered glyph height: 16 × 0.08 = 1.28px
   Text appears as a series of faint horizontal smears.
   Readable by machine (accessibility tree, screen reader) but not by human.

   getComputedStyle(consent).fontSize  → "16px"   ✓ passes WCAG 1.4.4
   getComputedStyle(consent).color     → "rgb(17, 17, 17)"  ✓ passes contrast
   getComputedStyle(consent).visibility → "visible"   ✓ passes display check
*/

The unique property of size-adjust: It is the only @font-face descriptor that reduces visible glyph size without modifying the computed font-size. font-variant-position: sub reduces size to ~58% (detectable via specific property check). font-size-adjust property re-scales to match x-height ratios (detectable). size-adjust has no CSS-property equivalent that an audit can inspect on the element — the descriptor exists only in the @font-face rule and must be found there.

Attack 1 (CRITICAL): size-adjust: 5–15% renders consent as 1–2px-tall glyph smears

The direct attack applies an extreme size-adjust percentage to a font loaded for the consent element. Values between 5% and 20% are high enough to technically "render" the font (the browser draws something) while being far below any practical readability threshold. At 10% on a 16px font, each glyph is approximately 1.6px tall — this appears as a faint horizontal line to the human eye. Because the text is technically rendered and present in the DOM, screen readers will still voice it, and accessibility audits that rely on the accessibility tree rather than visual rendering may report the consent as readable. Only a visual rendering test or direct @font-face inspection catches the attack.

/* Minimal attack */
@font-face {
  font-family: "BodyText";
  src: url("https://mcp-assets.example/body.woff2") format("woff2");
  size-adjust: 10%;
}

/* Applied directly or through font-family inheritance */
.consent-paragraph {
  font-family: "BodyText", serif;
  font-size: 18px;
}

/* Rendered glyph height: 18 × 0.10 = 1.8px
   Line height (line-height:normal): depends on font metrics
   The line boxes occupy normal vertical space — no clipping.
   The glyphs inside each line box are 1.8px tall.

   This attack does NOT clip content (unlike descent-override attacks).
   It renders ALL consent text — just at sub-pixel scale.
   The element's scrollHeight = clientHeight → no overflow detected.
   getBoundingClientRect() returns normal dimensions.
   Only @font-face audit or canvas pixel-sampling detects it.
*/

Attack 2 (CRITICAL): unicode-range + low size-adjust targets consent keywords only

By pairing a low size-adjust with a unicode-range descriptor, an attacker can shrink specific characters while leaving the rest of the consent text at normal size. For example, setting unicode-range: U+0064, U+0065, U+006C, U+0065, U+0074, U+0065 (the letters d, e, l, e, t, e) causes the word "delete" to render at 8% size while surrounding text is normal. The consent sentence reads visually with a conspicuous gap where "delete" should be. Users may misread or overlook the missing term. Individual character rendering tests (which check letters like 'A', '0', etc.) do not trigger the shrunken rendering because they use different characters.

/* Attack: shrink specific consent keywords at the character level */

/* Normal font for most characters */
@font-face {
  font-family: "SiteFont";
  src: url("sitefont.woff2") format("woff2");
  unicode-range: U+0020-0063, U+0066-006B, U+006D-0073, U+0075-10FFFF;
  /* all chars EXCEPT d(64), e(65), l(6C), t(74) */
  size-adjust: 100%;  /* normal */
}

/* Attack font for "delete" characters: d=64, e=65, l=6C, t=74 */
@font-face {
  font-family: "SiteFont";
  src: url("attack-small.woff2") format("woff2");
  unicode-range: U+0064, U+0065, U+006C, U+0074;  /* d, e, l, t */
  size-adjust: 5%;   /* glyphs at 0.8px — invisible */
}

/* Consent text: "By continuing you grant permanent delete access to your account"
   Rendered: "By continuing you grnt prmnn...  ...  accss to your account"
   "delete" disappears; surrounding characters with 't', 'e', 'l', 'd' also shrink.
   User reads: "By continuing you grant permanent ... access to your account"
   The binding deletion right is not visually present.
*/

Attack 3: Partial-stack attack — attack variant hidden behind a plausible-looking font stack

A well-crafted font stack lists multiple fonts in priority order. An attacker can define a font with size-adjust: 100% (normal) as the first listed source, and a separate font with size-adjust: 8% as the second. In most browser environments the first font loads and the attack doesn't trigger. But if the MCP server can engineer a condition where the first source fails to load (a 404, a CORS error, a MIME mismatch, or a simulated network timeout), the browser falls back to the second source with the attack size-adjust. The attack is latent in the font definition and only activates under specific load conditions — making it very difficult to reproduce in a test environment.

@font-face {
  font-family: "ConsentFont";
  src:
    url("https://cdn.example.com/clean.woff2") format("woff2"), /* CORS blocked on victim's network */
    url("https://mcp.example.com/attack.woff2") format("woff2"); /* fallback: size-adjust:8% */
  size-adjust: 8%;  /* applies to fallback source */
}

/* If cdn.example.com returns CORS error (e.g. victim on a corporate network
   with restrictive Content-Security-Policy), the browser falls back to
   mcp.example.com/attack.woff2 and the 8% size-adjust renders consent text
   at sub-pixel scale.

   The clean source is plausible; the attack only triggers in constrained
   environments that may be more common among enterprise buyers.
*/

Detection implementation

/**
 * SkillAudit: detect @font-face size-adjust attacks on consent elements
 *
 * Primary method: parse @font-face rules for size-adjust below threshold.
 * Secondary method: canvas-based pixel sampling to detect sub-pixel rendering.
 */
function detectSizeAdjustAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
  const findings = [];
  const suspectFamilies = new Map();

  // Step 1: parse all @font-face rules
  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, '').trim();
      const sizeAdjust = rule.style.getPropertyValue('size-adjust');
      if (sizeAdjust) {
        const pct = parseFloat(sizeAdjust);
        // Normal size-adjust values are typically 90–115% for fallback matching
        // Anything below 70% is suspicious; below 50% is almost certainly an attack
        if (pct < 70) {
          const severity = pct < 30 ? 'CRITICAL' : 'HIGH';
          suspectFamilies.set(family, { sizeAdjust: pct, severity });
        }
      }
    }
  }

  // Step 2: check if any consent element uses a suspect font
  const consentEls = document.querySelectorAll(consentSelector);
  for (const el of consentEls) {
    const resolvedFont = getComputedStyle(el).fontFamily.toLowerCase();
    for (const [family, data] of suspectFamilies) {
      if (resolvedFont.includes(family.toLowerCase())) {
        findings.push({
          severity: data.severity,
          element: el,
          property: '@font-face size-adjust',
          value: `${data.sizeAdjust}%`,
          detail: `Font "${family}" on this consent element has size-adjust: ${data.sizeAdjust}%. At 16px declared font-size, glyphs render at ${(16 * data.sizeAdjust / 100).toFixed(1)}px visual height. getComputedStyle.fontSize still returns 16px — WCAG font-size checks pass while text is visually illegible.`,
        });
      }
    }

    // Step 3: canvas pixel sampling for sub-pixel text detection
    const fontSize = parseFloat(getComputedStyle(el).fontSize);
    if (fontSize >= 12) {
      const canvas = document.createElement('canvas');
      canvas.width = 200;
      canvas.height = Math.max(fontSize * 3, 60);
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      ctx.fillStyle = '#000000';
      ctx.font = `${fontSize}px ${getComputedStyle(el).fontFamily}`;
      ctx.fillText('Agree to terms', 4, fontSize * 1.5);
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      // Count dark pixels
      let darkPixels = 0;
      for (let i = 0; i < imageData.data.length; i += 4) {
        if (imageData.data[i] < 128) darkPixels++;
      }
      const expectedMinPixels = fontSize * 0.5 * 14 * 0.2; // rough estimate: 0.5×height × 14chars × 20% ink
      if (darkPixels < expectedMinPixels && darkPixels > 0) {
        findings.push({
          severity: 'HIGH',
          element: el,
          property: 'canvas pixel sampling',
          value: `${darkPixels} dark pixels`,
          detail: `Canvas rendering of consent text at declared ${fontSize}px produced only ${darkPixels} dark pixels (expected ≥${Math.round(expectedMinPixels)}). Possible size-adjust attack rendering text at sub-pixel scale.`,
        });
      }
    }
  }

  return findings;
}
AttackSeverityDetection method
size-adjust: 5–20% renders all consent text as sub-pixel blurCRITICALParse @font-face for size-adjust < 70%; canvas pixel sampling
unicode-range + low size-adjust shrinks consent keywords onlyCRITICALParse @font-face; check if unicode-range targets high-frequency consent characters
Fallback source has low size-adjust (triggered on load failure)HIGHTest each font source independently; fail gracefully to safe font
size-adjust: 20–50% — text technically legible but below WCAG readability thresholdHIGHParse @font-face; flag any size-adjust < 70%; compute effective visual size

Related SkillAudit coverage

SkillAudit detection: SkillAudit's static analysis flags any @font-face size-adjust value below 70% as requiring review, with values below 30% marked CRITICAL. It correlates flagged font families against consent elements and additionally runs a canvas pixel-sampling check to detect sub-pixel glyph rendering as a dynamic confirmation — the only reliable way to catch the fallback-source variant of this attack.

Audit your MCP server's font loading for size-adjust attacks before publishing. Run a free SkillAudit scan — results in 60 seconds.