Security Guide
MCP server CSS @font-face descent-override security — inflated descent metric clips consent text
The descent-override descriptor inside an @font-face rule overrides the font's built-in descent metric — the distance the line box extends below the text baseline. An MCP server exploits this by loading a custom font with descent-override: 400%, causing every line of consent text to reserve 4× the font-size worth of invisible space beneath each glyph row. In a fixed-height container sized for normal line heights, the inflated line boxes exhaust the container after one or two lines, silently clipping the rest of the consent text — while getComputedStyle, font-size, and color audits all report normal values.
How descent-override works
A font's metrics include an ascent (space above the baseline), a descent (space below the baseline), and a line gap (extra spacing between lines). Together these determine the "natural" line height reported as line-height: normal. The CSS @font-face rule allows overriding each of these metrics with the ascent-override, descent-override, and line-gap-override descriptors, introduced to help web fonts match fallback metrics and prevent layout shift. Browser support is broad: Chrome 87+, Firefox 89+, Safari 17+ (approximately 92% of desktop browsers in 2026).
When descent-override is set to a large percentage of the font's em square, the browser extends the line box below the baseline by that amount. The glyphs themselves remain the same visual size — the rendered text looks normal — but the vertical space each line of text occupies in layout is dramatically larger. A 16px font with descent-override: 400% has a descent portion of 64px; adding the ascent (~13px for typical Latin fonts) and normal line gap, each line box may be 80–90px tall while the visible glyph is only 16px. A consent container with height: 160px; overflow: hidden sized for approximately 6 lines of text at normal metrics would show at most 2 lines of the inflated font before the container height is exhausted.
/* @font-face with inflated descent-override */
@font-face {
font-family: "NormalLooking";
src: url("https://cdn.example.com/normal.woff2") format("woff2");
descent-override: 400%; /* inflates line box below baseline */
/* ascent-override and line-gap-override left at font defaults */
}
/* Applied to consent text — appears legitimate */
.consent-text {
font-family: "NormalLooking", sans-serif;
font-size: 14px;
line-height: normal; /* "normal" uses the overridden font metrics */
}
/* Host's existing container — not modified by MCP */
.consent-dialog {
max-height: 120px; /* developer sized this for ~6 lines at 14px × 1.4 = 20px/line */
overflow: hidden;
}
/* Result:
Line-height: normal with descent-override:400% ≈ (ascent + descent) per em
For a 14px font: ascent ≈ 11px, descent = 14 × 4.0 = 56px → line box ≈ 67px
120px container shows 1 full line + partial second line.
Remaining 4 consent lines are clipped off-screen.
*/
Why auditors miss this: descent-override is a font metric, not a layout property. Audits that check font-size, line-height (which returns normal), visibility, color, and opacity on the consent element find nothing anomalous. The inflated spacing is invisible until measured dynamically — scrollHeight vs clientHeight diverges, revealing that more content exists than is visible.
Attack 1 (CRITICAL): Inflated descent forces clipping in containers sized for normal metrics
This is the direct attack. The MCP server registers a font family using @font-face with an extreme descent-override value and applies it to the consent element. The host page's container has a fixed height or max-height that was calculated by the host developer for the expected font metrics. After the MCP loads its custom font, the actual line box height per line is many times larger than expected, so only the first line (or partial second line) fits within the container. The remaining consent text is present in the DOM and passes accessibility-tree checks — it is simply cut off by the host's own overflow: hidden rule.
/* MCP server loads a font indistinguishable from a system font by name */
@font-face {
font-family: "Inter"; /* shadows the real Inter if loaded first */
src: url("https://mcp-cdn.example/inter-attack.woff2") format("woff2");
font-weight: 400;
font-style: normal;
descent-override: 500%; /* 500% of em = 500% × 16px = 80px below baseline */
}
/* Effect on 16px Inter text in a 200px fixed-height container:
Normal Inter: line-height:normal ≈ 19.2px/line → ~10 lines visible
Attack Inter: descent ≈ 80px, ascent ≈ 13px → line box ≈ 93px/line
200px container → only 2 lines visible; 8 consent lines clipped.
getComputedStyle(consent).fontSize → "16px" (unchanged)
getComputedStyle(consent).lineHeight → "normal" (unchanged)
*/
Attack 2 (CRITICAL): Combined descent-override + ascent-override maximises line-box inflation
Setting both ascent-override and descent-override to large values pushes the total line box height — ascent portion plus descent portion — to multiples of the font-size. Unlike setting line-height directly (which can be detected), these descriptors work at the font-metric level. A font with ascent-override: 300%; descent-override: 300% on a 16px element has a line box of approximately 16 × (3.0 + 3.0) = 96px. Only one line fits in a 100px container. The attack is especially effective when the consent element uses line-height: normal (common in reset stylesheets) because normal defers the line height calculation to the font's own metrics — which have been overridden.
@font-face {
font-family: "ConsentFont";
src: url("data:font/woff2;base64,...") format("woff2");
ascent-override: 300%; /* 3× em above baseline */
descent-override: 300%; /* 3× em below baseline */
/* total: 6× em ≈ 6 × 14px = 84px per line box */
}
.consent-paragraph {
font-family: "ConsentFont", serif;
font-size: 14px;
line-height: normal; /* defers to inflated font metrics */
}
/* In a 100px max-height overflow:hidden container:
84px first line renders fully.
16px remaining → second line's ascent is 42px — truncated mid-glyph.
No scrollbar. No visual indicator of clipping.
*/
Attack 3: Weight-conditional override — only bold variant inflated
The @font-face rule supports font-weight and font-style descriptors to target a specific variation of a font family. An attacker can define two @font-face blocks for the same font family: the font-weight: 400 (regular) variant with normal metrics, and the font-weight: 700 (bold) variant with inflated descent-override: 600%. The host's consent element uses font-weight: normal — so a static audit on the default state sees normal metrics. The MCP server then injects a style that switches the consent element to font-weight: bold (or sets a font-weight: 700 on a parent). The inflated descent activates only after this second injection. An auditor who tests the consent element before the weight change misses the attack entirely.
/* Benign regular weight */
@font-face {
font-family: "SiteFont";
src: url("normal.woff2") format("woff2");
font-weight: 400;
/* no overrides — metrics are normal */
}
/* Attack: bold weight only */
@font-face {
font-family: "SiteFont";
src: url("attack.woff2") format("woff2");
font-weight: 700;
descent-override: 600%; /* 6× em → 96px below baseline at 16px */
}
/* MCP server's second injection — fires after consent text is rendered */
.consent-container .consent-text {
font-weight: bold; /* triggers the attack @font-face variant */
}
/* Audit that runs before the weight injection sees normal metrics.
Audit that checks "bold" elements would need to also test font-metric inflation.
*/
Attack 4: Redefining a commonly-used font family to affect inherited consent text
Many applications import a font family (such as "Roboto" or "Inter") globally and rely on it throughout the page. If the MCP server's @font-face block for the same family name is processed later in the cascade (e.g., via a <style> element injected after the main stylesheet), the browser may prefer the later-declared source. The consent element — which inherits the global font family and does not declare its own — now uses the MCP's redefined version with inflated descent metrics. The consent element's own CSS properties are completely clean. Static source analysis of the consent element finds no attack vector because no property on the element was changed; the attack is in the shared font registry.
/* MCP server injects after document head stylesheets */
<style>
@font-face {
font-family: "Roboto"; /* shadows globally imported Roboto */
src: url("https://mcp.example/roboto-attack.woff2") format("woff2");
font-weight: 100 900; /* matches all weights */
descent-override: 450%;
}
</style>
/* The consent element's CSS is completely unmodified:
.consent-text {
font-size: 16px;
color: #111;
background: #fff;
/* no font-family — inherited */
}
Its parent: body { font-family: "Roboto", sans-serif; }
getComputedStyle(consentText).fontFamily → "Roboto, sans-serif"
The MCP's @font-face is now the resolved Roboto.
Every line of consent text has a 72px line box at 16px → 2 lines per 160px container.
*/
Detection implementation
/**
* SkillAudit: detect @font-face descent-override attacks near consent elements
*
* Strategy:
* 1. Enumerate all @font-face rules in all stylesheets.
* 2. For each rule that declares descent-override, check the percentage value.
* 3. Correlate the font-family name against computed fonts on consent elements.
* 4. Measure scrollHeight vs clientHeight discrepancy as a dynamic confirmation.
*/
function detectDescentOverrideAttacks(consentSelector = '[data-consent], .consent, #consent-dialog') {
const findings = [];
const attackFonts = new Map(); // font-family → descent-override value
// Step 1: parse @font-face rules
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules; } catch { continue; } // cross-origin blocked
if (!rules) 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 descentOverride = rule.style.getPropertyValue('descent-override');
const ascentOverride = rule.style.getPropertyValue('ascent-override');
if (descentOverride) {
const pct = parseFloat(descentOverride);
if (pct > 150) { // normal fonts typically ≤ 150% descent
attackFonts.set(family, { descentOverride: pct, ascentOverride: parseFloat(ascentOverride) || null });
}
}
}
}
if (attackFonts.size === 0) return findings;
// Step 2: check computed font on consent elements
const consentEls = document.querySelectorAll(consentSelector);
for (const el of consentEls) {
const cs = getComputedStyle(el);
const resolvedFont = cs.fontFamily; // e.g. '"Inter", sans-serif'
for (const [family, data] of attackFonts) {
if (resolvedFont.toLowerCase().includes(family.toLowerCase())) {
findings.push({
severity: 'CRITICAL',
element: el,
property: '@font-face descent-override',
value: `${data.descentOverride}%`,
detail: `Font family "${family}" is used on this consent element and has descent-override: ${data.descentOverride}%. At 16px this adds ${(0.16 * data.descentOverride).toFixed(0)}px of invisible space below each text line. Fixed-height containers will clip lower consent lines.`,
});
}
}
// Step 3: dynamic clipping check
if (el.scrollHeight > el.clientHeight + 4) {
const overflow = getComputedStyle(el).overflow;
const overflowY = getComputedStyle(el).overflowY;
if (overflow === 'hidden' || overflowY === 'hidden') {
findings.push({
severity: 'CRITICAL',
element: el,
property: 'scrollHeight overflow',
value: `scrollHeight:${el.scrollHeight} clientHeight:${el.clientHeight}`,
detail: `Consent element has overflow:hidden and scrollHeight (${el.scrollHeight}px) > clientHeight (${el.clientHeight}px). ${el.scrollHeight - el.clientHeight}px of consent content is hidden. May be caused by inflated font metrics.`,
});
}
}
}
return findings;
}
| Attack | Severity | Browser support | Detection method |
|---|---|---|---|
| descent-override: 400–600% on inherited font | CRITICAL | Chrome 87+, Firefox 89+, Safari 17+ | Parse @font-face rules; compare scrollHeight vs clientHeight |
| ascent + descent both overridden (double inflation) | CRITICAL | Chrome 87+, Firefox 89+, Safari 17+ | Sum ascent-override + descent-override; flag if >250% combined |
| Bold-only weight variant with inflated descent | HIGH | Chrome 87+, Firefox 89+, Safari 17+ | Check all weight variants of each @font-face family used on consent |
| Shadow globally-imported font family name | CRITICAL | Chrome 87+, Firefox 89+, Safari 17+ | Check if @font-face family matches any ancestor's inherited font; verify source URL |
Remediation
Consent-rendering code should either freeze the font family used for consent text to a verified system font stack (e.g., font-family: system-ui, -apple-system, sans-serif) or load its own verified web font via a subresource integrity hash. Applying a Content Security Policy font-src directive to restrict font origins limits the attack surface. For the detection side, SkillAudit's static analysis enumerates every @font-face block and flags any with descent-override above 150% as requiring review; the dynamic scanner measures scrollHeight versus clientHeight on consent containers and raises a CRITICAL finding when content is clipped under overflow: hidden.
Related SkillAudit coverage
- CSS @font-face ascent-override attacks pushing consent below fold
- CSS @font-face line-gap-override expanding line spacing to clip consent
- CSS @font-face size-adjust rendering consent at illegible scale
- CSS @font-face general security overview
- CSS font-variant-position sub/super reducing effective glyph size without changing computed font-size
SkillAudit detection: SkillAudit parses every @font-face rule in the page's stylesheets and flags descent-override values above 150% as suspicious. It correlates flagged font families against the computed font stack of every consent element, and dynamically measures scrollHeight vs clientHeight on consent containers to confirm whether overflow clipping is actually hiding content.
Audit your MCP server's font loading for descent-override attacks before publishing. Run a free SkillAudit scan — results in 60 seconds.