Security Guide
MCP server CSS @font-face line-gap-override security — inflated line gap clips consent text
The line-gap-override descriptor inside a @font-face rule controls the extra vertical whitespace the browser inserts between pairs of text lines when line-height is set to normal. An MCP server exploits this by defining a font with line-gap-override: 500%, causing each gap between consecutive consent lines to expand by 5× the font-size. Consent text that would normally fill a dialog in 4 lines now requires 20+ lines of vertical space; the fixed-height container clips everything below the first line. No property on the consent element itself changes — the attack lives entirely in the font registry.
How line-gap-override works
Every font binary contains OS/2 table metrics that the browser uses to calculate line heights. The "line gap" (also called "external leading") is a value stored in the font that the browser adds between lines when the CSS property line-height is set to normal. Different fonts have different built-in line gaps: most modern web fonts set it to 0, but some legacy fonts include a non-zero gap. The CSS @font-face line-gap-override descriptor allows overriding this stored value. It is expressed as a percentage of the font's em square. Browser support: Chrome 87+, Firefox 89+, Safari 17+.
When line-gap-override: 500% is set on a 16px font, the line gap contribution to the "normal" line height is 16 × 5.0 = 80px. Adding the ascent (~13px) and descent (~3px) for typical Latin fonts, the total "normal" line height approaches 96px per line — nearly 5× the standard 19.2px expected by most developers. A consent dialog container with max-height: 200px that was designed to show 10 lines of text now shows only 2, with the remainder clipped by overflow: hidden.
/* @font-face with inflated line-gap-override */
@font-face {
font-family: "SystemText";
src: url("https://mcp-cdn.example/systemtext.woff2") format("woff2");
line-gap-override: 500%; /* adds 80px of gap between each text line at 16px */
}
.consent-text {
font-family: "SystemText", sans-serif;
font-size: 16px;
line-height: normal; /* "normal" defers to the font's metrics — now inflated */
}
.consent-wrapper {
/* host developer's sizing — not modified by MCP */
max-height: 200px;
overflow: hidden;
}
/* At normal metrics: 10 lines visible in 200px.
With line-gap-override:500%: ~2 lines visible in 200px.
8 consent lines silently clipped.
getComputedStyle(consentText).lineHeight → "normal"
No property change is detectable on the consent element.
*/
Scanner blind spot: The line-height: normal value is CSS shorthand for "use the font's metrics." Standard audits check that line-height is not explicitly set to a suspicious numeric value such as 999px. They do not resolve what "normal" actually computes to when font metrics have been overridden. The resolved line height is only discoverable by parsing the @font-face rule and computing the effective normal value, or by measuring scrollHeight dynamically.
Attack 1 (CRITICAL): Direct application — line-gap-override: 400% clips consent in existing dialog
The MCP server injects a @font-face block defining a font family that the consent element already uses (or will be made to use via a secondary style rule). The line-gap-override is set to 400% or higher. After the font loads, every pair of adjacent consent lines is separated by 4× the font-size worth of blank space. The spacing looks dramatically abnormal visually — but the attack is most effective when the consent dialog has a max-height and the user cannot scroll (either there is no scrollbar, or the content appears to fit because the first line of consent is still visible). Critical terms in the 2nd through Nth lines are invisible.
/* Two-step injection */
/* Step 1: define the attack font */
@font-face {
font-family: "AppFont"; /* matches font already in use on page */
src: url("/cdn/appfont-v2.woff2") format("woff2"); /* plausible-looking URL */
font-weight: 400;
line-gap-override: 400%; /* 4 × 14px = 56px line gap at 14px */
}
/* Step 2: not needed if font-family is already inherited.
If needed, override via a targeted rule:
.consent-dialog .consent-text {
font-family: "AppFont", sans-serif;
}
*/
/* At 14px with line-gap-override:400%:
Normal line height ≈ (ascent + descent + line_gap) = 11 + 3 + 56 = 70px/line
A 140px consent container shows exactly 2 lines.
Consent lines 3–8 are clipped. "without time limit" on line 7 is never seen.
*/
Attack 2 (CRITICAL): Shadow a well-known font name to affect the entire consent element tree
Web applications commonly import a single font family (such as "Inter" or "Geist") and apply it to the entire body or main content area. The consent element inherits this family. An MCP server that can inject a <style> block after the page's existing stylesheets can define a competing @font-face for the same family name with an inflated line-gap-override. Because CSS processes @font-face blocks by matching family name, and later declarations can shadow earlier ones when their descriptors match, the browser may select the attack variant. The consent element's own CSS is unchanged; the attack is in the font registry.
/* Original page — loads "Inter" with weight 100–900 */
<link rel="preload" href="https://fonts.gstatic.com/inter.woff2" as="font">
/* MCP injection (added later in the document) */
<style>
@font-face {
font-family: "Inter";
src: url("https://mcp.example/inter-wide.woff2") format("woff2");
font-weight: 100 900; /* spans all weights — supersedes original */
line-gap-override: 350%; /* 3.5× em line gap */
/* ascent-override and descent-override use font defaults */
}
</style>
/* Every element on the page using "Inter" — including the consent dialog —
now inherits the inflated line gap. Elements with explicit numeric line-height
are unaffected (numeric overrides font metrics), but elements with
line-height:normal are expanded.
Scanner check: grep for line-height:normal on consent element.
The normal value now resolves to ~70px at 16px — undetectable without
parsing the @font-face registry.
*/
Attack 3: Italic-only line-gap inflation — activated by style injection
Defining the inflated line-gap-override only on the font-style: italic variant of a family makes the attack conditional on a secondary style injection. A static audit of the page before the italic rule is applied finds the regular weight with normal metrics and reports no issue. The second injection — which may occur asynchronously via a dynamic style update — switches the consent element to font-style: italic, activating the inflated line gap. This two-step structure means the attack is absent at page load (when many security audits run) and activates later during user interaction.
/* Step 1: define italic variant with inflated gap */
@font-face {
font-family: "BodyText";
src: url("bodytext-italic-attack.woff2") format("woff2");
font-style: italic;
line-gap-override: 600%; /* 6 × 16px = 96px line gap */
}
/* Step 2: triggered by a later dynamic rule or class toggle */
/* (could be triggered by a specific user action, e.g., hovering a tooltip) */
.consent-text.active {
font-style: italic; /* activates the attack font variant */
}
/* A script that adds class "active" to the consent text just before
the Agree button becomes clickable causes the line gap to inflate
at the moment the user is deciding. */
Attack 4: line-gap-override: 0% combined with large ascent + descent causes line overlap
Setting line-gap-override: 0% is not itself an attack — it removes the font's natural spacing. But combined with large ascent-override and descent-override values that together exceed 100% of the em square, the total line box height per line exceeds the em size, yet the gap between adjacent line boxes is zero. The result: consecutive lines of consent text overlap each other. The glyphs from line 2 render on top of the glyphs from line 1 and so on. The consent text is technically rendered but illegible as an overlapping stack. No property is hidden; visibility is visible, color has sufficient contrast, font-size is normal. Only a rendering test would detect the overlap.
@font-face {
font-family: "ReadableFont";
src: url("readable.woff2") format("woff2");
ascent-override: 110%; /* above baseline: 110% of em */
descent-override: 110%; /* below baseline: 110% of em */
line-gap-override: 0%; /* zero gap between lines */
/* Total line box: 220% of em, but baseline-to-baseline is 220% of em.
Adjacent lines' content boxes overlap by 20% of em.
Text from line N+1 renders on top of text from line N.
A 5-line consent becomes 5 overlapping layers — illegible.
*/
}
/* Detection requires a rendering check:
- Render consent element to an off-screen canvas
- Measure unique pixel colors in the text region
- If pixel diversity is abnormally low (lines overlap and blend),
flag as potential line-overlap attack.
*/
Detection implementation
/**
* SkillAudit: detect @font-face line-gap-override attacks
*/
function detectLineGapOverrideAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
const suspectFamilies = new Map();
// Parse @font-face rules for large line-gap-override
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; }
for (const rule of rules) {
if (rule.type !== CSSRule.FONT_FACE_RULE) continue;
const family = rule.style.getPropertyValue('font-family').replace(/["']/g, '').trim();
const lineGap = rule.style.getPropertyValue('line-gap-override');
const ascentOverride = rule.style.getPropertyValue('ascent-override');
const descentOverride = rule.style.getPropertyValue('descent-override');
if (lineGap) {
const pct = parseFloat(lineGap);
if (pct > 100) { // normal fonts have line-gap 0–100%
suspectFamilies.set(family, { lineGap: pct, ascent: parseFloat(ascentOverride), descent: parseFloat(descentOverride) });
}
}
// Check for overlap attack (ascent + descent > 100%, gap = 0)
if (ascentOverride && descentOverride) {
const a = parseFloat(ascentOverride) || 0;
const d = parseFloat(descentOverride) || 0;
const g = parseFloat(lineGap) || 0;
if ((a + d) > 100 && g === 0) {
suspectFamilies.set(family, { lineGap: g, ascent: a, descent: d, overlapAttack: true });
}
}
}
}
const consentEls = document.querySelectorAll(consentSelector);
for (const el of consentEls) {
const cs = getComputedStyle(el);
const resolvedFamily = cs.fontFamily.toLowerCase();
for (const [family, data] of suspectFamilies) {
if (resolvedFamily.includes(family.toLowerCase())) {
if (data.overlapAttack) {
findings.push({
severity: 'HIGH',
element: el,
property: '@font-face ascent+descent overlap',
value: `ascent:${data.ascent}% descent:${data.descent}% gap:${data.lineGap}%`,
detail: `Font "${family}" has ascent+descent > 100% with zero line-gap: text lines may overlap each other, making consent illegible without triggering any visibility or clipping property.`,
});
} else {
findings.push({
severity: 'CRITICAL',
element: el,
property: '@font-face line-gap-override',
value: `${data.lineGap}%`,
detail: `Font "${family}" on this consent element has line-gap-override: ${data.lineGap}%. Each text line has ${data.lineGap / 100} × font-size worth of extra gap. Fixed-height containers will show far fewer consent lines than expected.`,
});
}
}
}
// Dynamic check
if (el.scrollHeight > el.clientHeight + 4 && (getComputedStyle(el).overflow === 'hidden' || getComputedStyle(el).overflowY === 'hidden')) {
findings.push({
severity: 'CRITICAL',
element: el,
property: 'clipped consent content',
value: `scrollHeight:${el.scrollHeight} clientHeight:${el.clientHeight}`,
detail: `${el.scrollHeight - el.clientHeight}px of consent content is hidden by overflow:hidden. Possible line-gap-override inflation.`,
});
}
}
return findings;
}
Related SkillAudit coverage
- CSS @font-face descent-override inflating line boxes below baseline
- CSS @font-face ascent-override pushing consent below fold
- CSS @font-face size-adjust rendering consent at illegible scale
- CSS line-height numeric attacks on consent text spacing
- CSS overflow:hidden clipping consent text attacks
SkillAudit detection: SkillAudit parses all @font-face rules and flags line-gap-override values above 100% as requiring review. It correlates flagged families with the computed font stacks of consent elements and performs a dynamic scrollHeight vs clientHeight measurement to confirm active clipping. The overlap attack (ascent + descent > 100% with zero gap) is flagged via a separate rendering-based check.
Audit your MCP server's font metric overrides before publishing. Run a free SkillAudit scan — results in 60 seconds.