Security Guide

MCP server CSS font-display: swap security — two-phase consent attack activates on font swap

The CSS font-display: swap value in @font-face creates a Flash of Unstyled Text (FOUT) window: text renders in the fallback system font at first paint, then swaps to the web font once loaded. An MCP server exploits this timing by delivering an adversarial web font with metric overrides. During the FOUT period the consent text is readable — the user builds confidence in what they see. When the adversarial web font swaps in, the consent text becomes invisible or clipped. The attack activates precisely at the moment the user acts on the visible consent, after they have already seen it as correct.

The font-display swap timeline

The font-display descriptor in @font-face controls font rendering behavior during the font-load lifecycle. It defines two periods: a block period (text is invisible while the browser waits for the font) and a swap period (text renders in a fallback font if the web font has not arrived). The five values create different durations for each period:

/* font-display values and their rendering timelines */
font-display: auto;      /* Browser chooses — typically block:0ms, swap:infinite */
font-display: block;     /* block:3s, swap:infinite — invisible then swap */
font-display: swap;      /* block:0ms, swap:infinite — FOUT: fallback immediately, swap when loaded */
font-display: fallback;  /* block:100ms, swap:3s — brief block, then fallback, no swap after 3s */
font-display: optional;  /* block:100ms, swap:0ms — uses fallback only if font not instant-cached */

/* For font-display:swap:
   t=0ms: text renders in fallback font (e.g. system-ui, Arial)
   t=100-500ms: web font finishes loading
   t=100-500ms: font swap occurs — text re-renders in web font

   Attack window:
   t=0 to t=swap: user reads consent in correct fallback font
   t=swap: adversarial web font replaces fallback — consent becomes invisible
   User has already read/begun to act on the consent at the moment of attack activation */

Why this is worse than a static attack: A static metric-override attack (no font-display:swap) makes consent text invisible from first paint. Users who scroll to the consent area see nothing and may notice something is wrong. With font-display:swap, consent text is readable at first paint in the fallback font. Users see and begin to act on the consent. The adversarial web font swaps in after the user has already visually confirmed the content. The false readability window is a dark pattern: the user's trust is built on text that will be replaced by the attack rendering.

Attack 1 (CRITICAL): font-display:swap + metric override web font — two-phase consent attack

The MCP server registers the consent font with font-display: swap and points the source to an adversarial web font with extreme metric overrides (ascent-override: 300%, descent-override: 300%). At first paint (FOUT period), the consent text renders in the system fallback font — correct and readable at the intended size. When the adversarial font loads and swaps in, the metric overrides inflate the line box to 3× its normal height, collapsing the number of visible lines in the fixed-height consent container from ~10 to ~3. Critical consent terms that were visible during the FOUT period are now outside the scrollable viewport. The user saw the correct text at first paint and did not notice that the terms they agreed to were replaced by an attack rendering.

/* Attack: font-display:swap with metric-override adversarial font */
@font-face {
  font-family: 'ConsentFont';
  src: url('https://fonts.attacker.example/consent-metric-attack.woff2') format('woff2');
  font-display: swap;          /* Block:0ms, swap on load */
  /* The web font has embedded metric overrides in its OS/2 table
     OR the @font-face uses descriptor overrides: */
  ascent-override: 300%;
  descent-override: 300%;
}

/* Timeline:
   t=0:        Consent dialog opens. Fallback font (system-ui) renders consent.
               User sees: 10 full consent paragraphs, all readable.
               User reads paragraph 1 ("By accepting, you agree to...")

   t=200ms:    adversarial font loads. Browser performs font swap.
               ascent-override:300% + descent-override:300% activate.
               Line boxes inflate: each text line now occupies ~96px.
               A 400px-height consent container shows ~4 lines.
               Paragraphs 5-10 are now below the fold.

   t=200ms+:   User continues reading. They saw 10 paragraphs; now see 4.
               The critical arbitration clause was in paragraph 8. It is gone.
               User clicks Accept, believing they read all terms. */

Attack 2 (HIGH): font-display:swap with blank-glyph web font — readable then invisible

A more aggressive variant loads a web font where every glyph has a blank (zero-ink) outline while maintaining correct advance widths. During the FOUT period, the system fallback font renders all consent text correctly. When the blank-glyph font swaps in, every character in the consent dialog becomes invisible — the text is present in the DOM, the layout unchanged, but every glyph is blank. If the user has been reading the consent and is mid-paragraph, they may not notice that the text they were reading has gone blank, particularly if the swap is fast (200ms or less) and they were not directly focused on the text at that moment.

/* Blank-glyph font with font-display:swap */
@font-face {
  font-family: 'ConsentFont';
  src: url('data:font/woff2;base64,d09GMgAB...BLANK_GLYPHS...') format('woff2');
  font-display: swap;
  /* Web font loads from data: URI — loads faster than external URL
     Data URI fonts are parsed synchronously — swap may occur in <50ms
     No network latency: FOUT window may be very short (50-100ms)
     But user still sees a brief flash of correct text before blanking */
}

/* Fastest attack variant using preloaded blank font:
   <link rel="preload" href="/fonts/consent-font.woff2" as="font" crossorigin>
   Combined with font-display:swap — the font is already cached by the time
   the consent dialog opens; swap occurs at first paint or immediately after.
   FOUT window may be <16ms — one frame — but still technically present. */

Audit timing issue: Automated consent audits that check font rendering after page load may run after the font swap has already occurred, seeing only the attack rendering. Alternatively, audits that snapshot at first paint may see the clean fallback rendering and miss the attack. Neither timing alone is sufficient — detection requires monitoring the full font-load lifecycle including the swap event.

Attack 3: font-display:swap timed to user interaction — click-moment attack

The attacker controls the web font's load timing by serving the font file with a deliberate HTTP delay. The font server receives the request for consent-font.woff2 and holds the response for a calculated delay — typically 2-3 seconds. This ensures that the FOUT period aligns with the user's attention span for the consent dialog. The user opens the consent dialog, reads the correct fallback-font text for 2 seconds, and then clicks Accept. The font swap occurs at approximately the same moment, creating a visual hiccup as the text changes appearance. The user's click-confirm action is concurrent with the attack activation. Because the click already registered, the consent is accepted under the state visible at click time (fallback font, correct text).

/* Server-controlled delay attack */

/* @font-face on the page */
@font-face {
  font-family: 'ConsentFont';
  src: url('/api/fonts/consent.woff2?delay=2500') format('woff2');
  font-display: swap;
  /* Server introduces 2500ms delay before streaming the font file */
  /* FOUT period: 0ms to 2500ms — matches typical consent-reading time */
}

/* Empirical basis for 2500ms:
   Eye-tracking studies show average consent dialog reading time: 2-4 seconds
   (skimming, not thorough reading). Delay set to match median reading time.
   The adversarial font arrives as the user is about to click —
   the swap confirms/validates the click visually rather than alerting the user. */

/* Font loading time can also be inferred from PerformanceObserver:
   Performance entries of type 'resource' for font URLs reveal load times.
   An MCP server running in an extension context could adapt the delay
   to the observed network speed of the client. */

Attack 4: font-display:fallback as consent-hiding mechanism — the 3-second block period

While font-display:swap is the primary two-phase attack vector, font-display: fallback creates a distinct attack window: a 100ms block period (text invisible) followed by a 3-second swap window. If the adversarial web font loads within the 3-second swap window, the attack font is applied. If it loads after 3 seconds, the fallback font remains permanently — the attack fails gracefully. The 100ms block period is itself exploitable: consent text is invisible for the first 100ms after the dialog opens. If the user's click is pre-primed (e.g., by a prior UI interaction that positions the mouse over the Accept button), the first 100ms is a consent-invisible window during which a click can be triggered. Combined with a fast-loading local font, the 100ms block period and the swap together constitute a reliable consent-hiding sequence on fast connections.

/* font-display:fallback — 100ms block + 3s swap window */
@font-face {
  font-family: 'ConsentFont';
  src: url('/fonts/attack.woff2') format('woff2');
  font-display: fallback;
  ascent-override: 400%;   /* Attack activates if font loads within 3s of first render */
}

/* Timeline:
   t=0:        Dialog opens. Block period begins. Text INVISIBLE for 100ms.
   t=100ms:    Fallback font shown (swap period begins, lasts 3s).
   t=500ms:    attack.woff2 loads. Font swap. ascent-override:400% activates.
   t=3100ms:   Swap period ends. If font not loaded, fallback is permanent.

   Attack window: t=500ms to t=3100ms — consent text is clipped/hidden during swap.

   The 100ms block period (t=0 to t=100ms) is also a consent-invisible window:
   Any click or keyboard event during this window interacts with invisible consent. */

Detection implementation

/**
 * SkillAudit: detect font-display swap-timing consent attacks
 */
function detectFontDisplaySwapAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
  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 display = rule.style.getPropertyValue('font-display');
      const ascent  = rule.style.getPropertyValue('ascent-override');
      const descent = rule.style.getPropertyValue('descent-override');
      const lineGap = rule.style.getPropertyValue('line-gap-override');
      const sizeAdj = rule.style.getPropertyValue('size-adjust');
      const src     = rule.style.getPropertyValue('src') || '';

      const hasSwap      = display === 'swap' || display === 'fallback';
      const hasOverride  = [ascent, descent, lineGap].some(v => v && parseFloat(v) > 120);
      const hasSizeAdj   = sizeAdj && parseFloat(sizeAdj) < 50;

      if (hasSwap && (hasOverride || hasSizeAdj)) {
        findings.push({
          severity: 'CRITICAL',
          family: rule.style.getPropertyValue('font-family'),
          fontDisplay: display,
          ascent, descent, lineGap, sizeAdjust: sizeAdj,
          detail: `@font-face with font-display:${display} has metric overrides. Two-phase consent attack: fallback font renders correct text at first paint; attack overrides activate on font swap. FOUT window creates false-readability period.`,
        });
      }
    }
  }

  // Monitor font-load events and check consent rendering before and after swap
  if (typeof FontFace !== 'undefined') {
    document.fonts.ready.then(() => {
      const consentEls = document.querySelectorAll(consentSelector);
      for (const el of consentEls) {
        const scrollRatio = el.scrollHeight / el.clientHeight;
        if (scrollRatio > 4) {
          findings.push({
            severity: 'HIGH',
            element: el,
            scrollRatio,
            detail: `Consent element scrollHeight/clientHeight ratio is ${scrollRatio.toFixed(1)}× after fonts loaded. Line-box inflation via font-display:swap metric overrides suspected (expected ratio ≤ 1.5).`,
          });
        }
      }
    });

    // Check if any fonts are still loading when consent is visible
    const loadingFonts = [...document.fonts].filter(f => f.status === 'loading');
    for (const font of loadingFonts) {
      font.loaded.then(() => {
        // Font just loaded — re-check consent visibility immediately post-swap
        const consentEls = document.querySelectorAll(consentSelector);
        for (const el of consentEls) {
          const cs = getComputedStyle(el);
          if (cs.fontFamily.includes(font.family.replace(/['"]/g, ''))) {
            findings.push({
              severity: 'HIGH',
              font: font.family,
              detail: `Font "${font.family}" loaded after consent was visible (FOUT event detected). Consent text re-rendered post-swap. Verify scroll height and pixel rendering post-swap.`,
            });
          }
        }
      });
    }
  }

  return findings;
}
AttackMechanismDetection method
font-display:swap + metric override fontFOUT: correct text at first paint; attack overrides inflate line boxes on swapFlag @font-face font-display:swap with ascent/descent/line-gap/size-adjust overrides; check scrollHeight post-swap
font-display:swap + blank-glyph fontConsent readable during FOUT; all glyphs blank after swapCanvas pixel-sample consent text after fonts.ready; compare ink pixel count pre- and post-swap
Server-controlled delay timed to reading timeFont load delayed to match median consent reading time; swap at click momentMeasure font load time via PerformanceObserver; flag delays >1s on consent fonts
font-display:fallback 100ms block + 3s swap100ms invisible period + 3s window for attack font to swap inFlag font-display:fallback on consent font families with metric overrides; check block period consent visibility

Related SkillAudit coverage

SkillAudit detection: SkillAudit monitors the font-load lifecycle using PerformanceObserver and the FontFace API, capturing consent element rendering state both before and after each font swap event. Any consent element whose scroll height or ink pixel count changes adversely after a font swap event is flagged. Additionally, any @font-face rule combining font-display: swap or fallback with metric override descriptors is flagged CRITICAL regardless of rendering state — the combination is intentionally exploitable.

Audit your MCP server's font loading configuration before publishing. Run a free SkillAudit scan — results in 60 seconds.