Security Guide

MCP server font-display security — FOIT flash-of-invisible-text, font swap layout shift, and symbol font substitution attacks on consent disclosures

The CSS font-display descriptor inside @font-face rules controls how the browser handles the interval between page load and web font availability. It defines a block period — during which text may render invisibly or with a fallback — and a swap period — during which the browser will substitute the loaded web font once it arrives. For MCP servers where an attacker controls injected CSS, these timing mechanisms create four exploitable attack surfaces: a font-display:block rule that silences disclosure text for up to three seconds, a never-resolving font URL that extends that silence, a metric-mismatch between the loaded font and its fallback that causes visible text to overflow a hidden container, and an inline symbol font that maps ASCII permission text to unreadable glyphs while leaving the DOM's textContent intact. All four attacks pass naive DOM and accessibility checks.

How font-display works — and why it is an attack surface

When a browser encounters a @font-face rule and begins downloading the font file, it must decide what to show the user for text that depends on that font. Two periods govern this decision:

The five font-display values configure the duration of these two periods differently:

The critical detail for attackers: during the block period, the disclosure element's textContent, the DOM structure, and the accessibility tree all return the correct permission text. Only the rendered pixels are invisible. No standard DOM property, no getComputedStyle() call, and no screen reader check distinguishes "text invisibly rendered during FOIT" from "text normally rendered in a loaded font." An audit that does not simulate the font-loading lifecycle will not detect the attack window.

Attack 1 — font-display:block creating a 3-second FOIT window

The most direct attack uses font-display:block and an artificially slow-loading font server. The block period — up to 3 seconds — creates a Flash of Invisible Text (FOIT) window during which the disclosure text is present in the DOM but invisible on screen. If the consent dialog presents an Accept button during this window, a user can click Accept without ever seeing the disclosure text.

@font-face {
  font-family: 'ConsentFont';
  src: url('https://fonts.attacker.com/slow-font.woff2');  /* artificially slow load */
  font-display: block;  /* up to 3-second block period: text is invisible */
}
.mcp-disclosure {
  font-family: 'ConsentFont', sans-serif;
  /* during block period: disclosure text is invisible (FOIT) */
  /* textContent, DOM, accessibility tree all return correct permission text */
  /* only the rendered pixels show nothing — the disclosure appears blank */
}

Why this is dangerous: The block period duration is a browser-controlled upper bound, not a guaranteed duration. On a slow connection or with a rate-limited font server, the font may take 4–10 seconds to load — and the block period may expire before the font arrives, causing the fallback to render. But on fast connections where the font server deliberately throttles only the initial connection, the browser may hold the block period open for the full 3 seconds while the font eventually trickles in. In either case, the Accept button is active during the invisible window.

Detection requires more than checking font-display values in the stylesheet: the audit must also verify whether the font source URL responds quickly. A font that loads in 50ms poses no practical FOIT risk. A font that takes 4 seconds — whether due to server throttling, geographic latency, or deliberate delay — creates a real attack window. SkillAudit checks both the declared font-display value and the network response time of each font src URL in audit sandbox conditions.

Attack 2 — Never-loading font URL keeping disclosure invisible

A more reliable variant of the block-period attack uses a font source URL that returns HTTP 404 or that never responds at all. The browser enters the block period at page load and waits for the font. On fast networks, the 404 response arrives quickly, the font fails to load, and the block period ends after its nominal 3 seconds, swapping to the system sans-serif fallback. But on slow connections, the 404 response itself may take longer than 3 seconds to arrive — extending the FOIT window beyond what the font-display:block nominal limit suggests.

@font-face {
  font-family: 'ConsentText';
  src: url('https://cdn.example.com/nonexistent-font.woff2');  /* 404 */
  font-display: block;
}
.mcp-disclosure {
  font-family: 'ConsentText', sans-serif;
}

Most dangerous in slow network environments: In a simulated 3G environment, a 404 response from a geographically distant server can take 4–6 seconds, during which the browser has not yet confirmed the font failed to load. The block period countdown begins when the font is first needed, but the browser's font-loading state machine does not transition to "failed" until the network response completes. Users on slow mobile connections — exactly the users least likely to notice a blank disclosure box — experience the longest FOIT windows.

Detection: for every @font-face rule applied to a consent disclosure element, SkillAudit fetches the font src URL from the audit sandbox and checks the HTTP response status. URLs returning 4xx responses or connection timeouts on fonts applied to disclosures are flagged immediately. The audit also simulates the disclosure rendering in a throttled-network sandbox to measure the actual FOIT duration in slow-network conditions.

Attack 3 — font-display:fallback + metric-mismatch overflow

This attack targets the swap period rather than the block period, and exploits the difference in character metrics between the loaded web font and its fallback. With font-display:fallback, the block period is only 100ms — too short to be an effective FOIT window. Instead, the attack relies on the fact that the fallback font and the web font have very different character widths. When the disclosure is rendered in the fallback font (Arial Black), the text fits within the container. When users have the web font cached from a prior page load, the wider web font causes the disclosure text to overflow an overflow:hidden container — making it invisible for users who have visited the page before.

@font-face {
  font-family: 'FallbackTrap';
  src: url('https://very-slow-server.invalid/font.woff2');  /* connection refused / timeout */
  font-display: fallback;
  /* block period: 100ms — invisible text for 100ms */
  /* swap period: 3 seconds — if font loads within 3s, swap; otherwise use fallback forever */
}
.mcp-disclosure {
  font-family: 'FallbackTrap', 'Arial Black', sans-serif;
  /* 'Arial Black' is the fallback — metrics very different from web font metrics */
}

TOCTOU attack pattern: This is a time-of-check to time-of-use attack. The audit sandbox fetches the page fresh, with no cached fonts — so the fallback renders, the disclosure is visible, and the audit passes. But a real user who has visited the MCP server's consent page once before will have the web font cached. On their second visit, the browser loads the cached web font immediately (no block period, no swap delay) — and the wider font metrics push the disclosure text outside the overflow:hidden container. The disclosure is invisible for precisely the users who have already interacted with the server and are most likely to click Accept quickly.

Detection: SkillAudit checks whether the fallback font metrics differ significantly from the loaded web font metrics for fonts applied to disclosure elements. The audit simulates rendering the disclosure element with both the fallback font and the loaded font, measures the resulting text dimensions, and flags cases where the text overflows an overflow:hidden container in one scenario but not the other. A character width ratio of more than 1.3× between the web font and its fallback on disclosure elements is flagged as a metric-mismatch risk.

Attack 4 — Symbol font substituting permission text with glyphs

This attack does not rely on font loading delays at all. Instead, it uses a @font-face rule with an inline base64 data URI to load a custom font in which standard ASCII characters are mapped to Private Use Area symbol glyphs — a technique equivalent to applying Wingdings to the disclosure text. The disclosure element's textContent contains the correct English permission text. The rendered output is a sequence of unreadable dingbat symbols.

@font-face {
  font-family: 'PermissionFont';
  src: url('data:font/woff2;base64,...');  /* inline base64 — a Wingdings-style symbol font */
  font-display: swap;
}
.mcp-disclosure {
  font-family: 'PermissionFont';
  /* "READ EXECUTE DELETE access permissions requested" renders as */
  /* "♔♖♘♙ ♕♗♞♙♔♕ ♘♙♔♕♗♘ ♙♔♙♕♔♗♙ ♕♙♔♕♗♙♙♔♔♗♙♘♙♕ ♔♙♕♚♙♔♗♕♙♘" */
  /* the DOM has the correct ASCII text; the symbol font renders unreadable glyphs */
}

Why this bypasses both DOM and accessibility checks: The data URI font is embedded inline — there is no network request, no loading delay, and no FOIT. The symbols render immediately at page load. The element's textContent returns the correct English permission text because the DOM stores Unicode code points, not glyph indices. Screen readers read textContent — so accessibility checks announce the correct permission warning. Only a sighted user looking at the rendered consent dialog sees dingbat symbols instead of readable text. A security audit that reads textContent or uses a screen reader will not detect this attack.

Detection requires loading the font and inspecting its character-to-glyph mapping. SkillAudit extracts inline data URI fonts and loads them in a font analysis sandbox. For each font applied to a consent disclosure element, the audit checks whether the glyphs for common ASCII characters (Unicode range 0x41–0x7A: uppercase A through lowercase z, plus space and digits) are alphabetic letterforms or non-alphabetic symbol glyphs. Fonts where more than 20% of the standard ASCII range maps to symbol or Private Use Area glyphs are flagged as symbol substitution risks. Additionally, SkillAudit renders the disclosure element in the audit sandbox and compares the pixel output against a reference rendering using system sans-serif — significant visual divergence triggers a visual mismatch alert.

Summary table

Attack font-display value Disclosure appearance Severity
Slow font server block period block Invisible for up to 3 seconds during block period; fallback appears after High
Never-loading font URL block Invisible until block expires (up to 3s+); on slow networks, 404 response delays extend FOIT beyond nominal limit High
Metric-mismatch overflow fallback Visible with fallback; overflows overflow:hidden container when web font is cached — invisible on repeat visits High
Symbol font substitution swap Renders as unreadable symbol glyphs immediately; DOM and accessibility tree return correct text High

Detection checklist

Detecting all four attack variants requires a multi-layer audit that covers both static CSS analysis and dynamic font-loading simulation. SkillAudit's methodology covers the following controls:

SkillAudit findings

High @font-face { font-display:block } applied to consent disclosure. Font source URL https://slow.example.com/font.woff2 returned HTTP 200 after 4.2 seconds in audit sandbox. 3-second block period: disclosure text was invisible (FOIT) from load until t=3s. Accept button was available during FOIT window — user could click Accept without seeing disclosure text.
High @font-face with font-display:block and font source URL returning HTTP 404. Block period started at t=0. After 3 seconds, browser swapped to system sans-serif fallback. During the 3-second block window on slow connections, the 404 response had not completed, extending the FOIT beyond the nominal 3-second block period. Disclosure was invisible for 4.8 seconds in simulated 3G environment.
High font-display:fallback with font-family:'CustomWide', 'Arial Black'. Fallback renders correctly (textContent visible in Arial Black). Loaded font character width is 1.8× Arial Black for uppercase characters. With loaded font, disclosure at 280px container width overflows by 340px. Container has overflow:hidden. Audit run with fallback passes; users with cached font see no disclosure.
High Inline base64 @font-face font applied to disclosure. Character analysis: ASCII range 0x41–0x7A (A-z) maps to Private Use Area glyphs (dingbat symbols). textContent returns "HIGH RISK: This server requests DELETE access to your file system." Rendered output: unreadable symbol sequence. Screen reader announces correct text. Sighted user sees dingbat symbols only.

Related security guides: font-display attacks are one dimension of font-based consent disclosure abuse. See also Font Loading API attack surface (programmatic font loading via the CSS Font Loading API), font-feature-settings attacks (OpenType feature abuse for glyph substitution), and font-variant attacks on disclosure text (small-caps and ligature-based obfuscation).