MCP server CSS text-box security: text-box-trim cap-height collapse, ex-leading removal, line-height compound height reduction, and icon-character descender elimination attacks
Published 2026-07-23 — SkillAudit Research
The CSS text-box shorthand — and its longhands text-box-trim and text-box-edge — control how much space is allocated above and below text within an inline line box. By default, browsers add half-leading above and below text characters to produce comfortable vertical spacing. The text-box-trim property removes this leading from the block-start, block-end, or both edges of the first and last line boxes in a block container. The text-box-edge property specifies exactly which text metric (cap height, x-height, alphabetic baseline, etc.) defines the trim boundary.
For a single-line consent disclosure, this creates a collapse attack: if leading is removed from both top and bottom of the line box, the element's rendered height shrinks to approximately the cap-height of the font rather than the full line-height. Combined with tight line-height values or specific text-box-edge metrics, an MCP server can reduce a single-line disclosure to a height that, while technically positive, is below the threshold at which text is legibly visible.
Browser support: text-box-trim and text-box-edge (combined as text-box) are supported in Chrome 123+, Safari 17.4+ (as -webkit-text-box-trim), and Firefox 130+. The properties require font metrics that vary by typeface. The specific pixel reduction from these attacks depends on the active font and font-size, and must be computed at render time rather than statically.
Attack 1: text-box-trim: both; text-box-edge: cap alphabetic — cap-height-only line box
Setting text-box-trim: both removes leading from both the block-start and block-end of the first line box. Setting text-box-edge: cap alphabetic means the block-start trim snaps to the cap height metric and the block-end trim snaps to the alphabetic baseline. The result is that the line box height is reduced to the distance from the alphabetic baseline to the cap height — eliminating both the above-cap leading and the descender space below the baseline:
/* Attack 1: text-box trim to cap/alphabetic edges only */
/* For a typical UI font at 14px:
Cap height (cap-height metric): ~70% of font-size = 9.8px
Alphabetic baseline: 0 (the reference baseline)
Descender depth: ~20% of font-size = 2.8px
Half-leading (with line-height: 1.5): (1.5 * 14 - 14) / 2 = 3.5px above and below
Normal line box height = 14px * 1.5 = 21px
Trimmed line box height (cap to alphabetic):
= cap-height + descent = 9.8px (no leading, no descender, just cap-to-baseline)
Actually: trim to cap-height above, alphabetic below = 9.8px above baseline + 0 below
Line box = 9.8px (much less than 21px normal) */
.consent-disclosure {
text-box-trim: both;
text-box-edge: cap alphabetic;
/* For most UI fonts at 14px, this produces a line box of approximately 9–11px.
With overflow: hidden on the parent or on the element itself, a guard that
checks offsetHeight might see 9–11px and consider the element "present".
But at 9px, single-line text is legible. The collapse is partial — not zero.
The attack becomes critical when combined with other properties (see Attack 3). */
}
/* Why this bypasses guards:
1. offsetHeight returns 9–11px — positive, guard passes (threshold usually 4px)
2. scrollHeight = clientHeight = same value — no overflow detected
3. getComputedStyle().height returns the same value — non-zero
4. textContent is intact — non-empty text check passes
5. The visual impact: text appears "tight" but may still be visible at 9–11px.
The attack alone is not devastating at 14px font-size.
At smaller font-sizes (10px–12px), the trimmed height becomes 6–8px — borderline. */
/* Scanning for this: */
function detectTextBoxTrimAttack(el) {
const cs = window.getComputedStyle(el);
// Check for text-box-trim presence (non-normal value)
const textBoxTrim = cs.getPropertyValue('text-box-trim');
const textBoxEdge = cs.getPropertyValue('text-box-edge');
const webkitTextBoxTrim = cs.getPropertyValue('-webkit-text-box-trim');
if ((textBoxTrim && textBoxTrim !== 'none' && textBoxTrim !== 'normal') ||
(webkitTextBoxTrim && webkitTextBoxTrim !== 'none')) {
const height = el.getBoundingClientRect().height;
const fontSize = parseFloat(cs.fontSize);
// If trimmed height < 80% of font-size, text is likely visually compressed
if (height > 0 && height < fontSize * 0.8) {
console.warn('SECURITY: text-box-trim reducing consent disclosure height below legibility threshold', {
textBoxTrim, textBoxEdge, height, fontSize,
heightToFontSizeRatio: (height / fontSize).toFixed(2)
});
return true;
}
}
return false;
}
Attack 2: text-box-trim: start; text-box-edge: ex leading — ex-height above-baseline removal
The ex metric in text-box-edge refers to the x-height of the font — the height of a lowercase "x" character, typically 50–55% of the cap height. Setting text-box-edge: ex leading on the block-start edge and text-box-trim: start removes the leading above the x-height, snapping the top of the line box down to the ex-height boundary:
/* Attack 2: text-box-trim: start with ex-height edge — removes above-x-height space */
/* Metrics for a typical UI font at 14px:
Font-size: 14px
x-height (ex): ~55% of cap-height ≈ 55% × 70% × 14 ≈ 5.39px above baseline
Leading (line-height:1.5): 3.5px half-leading above cap-height
text-box-trim: start → trims block-start of first line
text-box-edge: ex leading → trim point is the ex (x-height) metric, above-leading removed
This removes:
- The 3.5px half-leading above cap-height (leading above caps)
- The space between cap-height and x-height: (cap - x-height) ≈ (9.8 - 5.39) = 4.41px
Total removed from top: 3.5 + 4.41 = 7.91px
The descender and half-leading below remain (not trimmed at block-end).
Net effect: element's height decreases by ~7.91px for a 14px font with line-height:1.5
From 21px to ~13px. This is partial but significant. */
.consent-disclosure {
text-box-trim: start;
text-box-edge: ex leading;
/* The disclosure's block-start space is removed down to the x-height.
The top of the element's render box starts at the x-height of the first line.
Text is still visible but shifted and tighter than expected.
For disclosure text that appears at the very bottom of a fixed-height container
with overflow:hidden, this upward shift may cause the last line (or the only line)
to clip under the container's top edge. */
}
/* The 'leading' keyword in text-box-edge:
When text-box-edge includes 'leading' as the trim edge type, the trim extends
to include the leading (half-leading above cap or x-height).
This is the most aggressive trim: it removes both the leading AND the space
above the specified metric (cap vs. ex).
'text-box-edge: ex leading' = trim to the top of the x-height PLUS the leading above it.
In practice: the block-start of the line box becomes the x-height baseline. */
/* Detection: */
function detectExLeadingAttack(el) {
const cs = window.getComputedStyle(el);
const textBoxEdge = cs.getPropertyValue('text-box-edge');
const textBoxTrim = cs.getPropertyValue('text-box-trim');
// 'leading' in text-box-edge is the most aggressive form
if (textBoxEdge && textBoxEdge.includes('leading') &&
textBoxTrim && textBoxTrim !== 'none') {
const rect = el.getBoundingClientRect();
const fontSize = parseFloat(cs.fontSize);
const lineHeight = parseFloat(cs.lineHeight);
// Expected minimum height without trimming: font-size (or line-height if > font-size)
const expectedMin = Math.max(fontSize, isNaN(lineHeight) ? fontSize : lineHeight);
if (rect.height < expectedMin * 0.7) {
console.warn('SECURITY: text-box-edge with "leading" keyword collapsing consent disclosure', {
textBoxTrim, textBoxEdge,
renderedHeight: rect.height,
expectedMinimum: expectedMin
});
return true;
}
}
return false;
}
Attack 3: text-box-trim: both + tight line-height — compound near-zero height for single-line disclosures
The most effective variant combines text-box-trim: both with a very tight line-height value. The text-box-trim removes leading from both edges. If line-height is set to less than 1 (e.g., line-height: 0.5), the leading is already negative before trimming — and trimming it further collapses the line box to the minimum metric distance, which can be sub-pixel:
/* Attack 3: text-box-trim: both + fractional line-height — compound collapse */
/* How CSS line-height < 1 interacts with text-box-trim:
At line-height: 0.5 with font-size: 14px:
Line box height = 0.5 × 14 = 7px
Half-leading = (7 - 14) / 2 = -3.5px (negative leading — overlap)
With text-box-trim: both, the trim removes the (already negative) leading.
The line box shrinks further to the font's intrinsic metrics.
For text-box-edge: cap alphabetic at font-size 14px:
Cap height = 9.8px
Alphabetic baseline = 0
But with a 7px line box: the cap-to-alphabetic trim would produce
a box of height = min(line box, cap-to-alphabetic distance)
= min(7px, 9.8px) = 7px — the line box is already smaller.
The real collapse happens with text-box-edge: text text (text-over to text-under):
'text' metrics use the font's actual ascent and descent values from the font file.
Some fonts have very small ascent/descent values (e.g., icon fonts).
With 'text text' edge and line-height: 0.5, the collapse is to the font's
actual ascent + descent, which for icon fonts can be near zero. */
.consent-disclosure {
font-size: 14px;
line-height: 0.5; /* half line-height = 7px box, negative leading */
text-box-trim: both;
text-box-edge: cap alphabetic; /* trim above cap, trim below alphabetic baseline */
overflow: hidden; /* clip anything that escapes */
}
/* Result:
Line box is min(7px, cap-height=9.8px) = 7px for most fonts.
With overflow: hidden, text is clipped at 7px.
At font-size: 14px, 7px shows only the top half of uppercase letters.
The text "IS READABLE" partially but not the disclosure intent.
More aggressive (icon font variant):
Consent dialog uses an icon font for the acceptance button area.
MCP server applies: font-family: 'MaterialIcons'; line-height: 0.1;
text-box-trim: both; text-box-edge: text text;
Icon font has ascent=1000, descent=0 out of 2048 UPM → text metrics near-zero.
Result: near-zero line box height for disclosure text forced into icon font context. */
/* Effective combined check: */
function detectCompoundTextBoxCollapse(el) {
const cs = window.getComputedStyle(el);
const textBoxTrim = cs.getPropertyValue('text-box-trim');
const lineHeight = parseFloat(cs.lineHeight);
const fontSize = parseFloat(cs.fontSize);
const rect = el.getBoundingClientRect();
// Both conditions: text-box-trim active AND line-height < 0.8 × font-size
if (textBoxTrim && textBoxTrim !== 'none' && textBoxTrim !== 'normal') {
const lineHeightRatio = isNaN(lineHeight) ? 1.2 : lineHeight / fontSize;
if (lineHeightRatio < 0.8) {
console.warn('SECURITY: text-box-trim combined with tight line-height on consent disclosure', {
textBoxTrim,
lineHeight,
fontSize,
lineHeightRatio: lineHeightRatio.toFixed(2),
renderedHeight: rect.height
});
return true;
}
// Even without tight line-height, if rendered height is below threshold
if (rect.height > 0 && rect.height < fontSize * 0.5) {
console.warn('SECURITY: text-box-trim collapsing consent disclosure below 50% font-size height', {
textBoxTrim,
renderedHeight: rect.height,
fontSize
});
return true;
}
}
return false;
}
Attack 4: text-box-trim on icon-character sibling — cascading descender elimination affecting adjacent disclosure
The text-box-trim property affects the block-start and block-end of a block container's first and last line boxes. An MCP server can inject a styled sibling element that uses text-box-trim on a visually large icon character to eliminate the descender space in an inline formatting context shared with the disclosure, causing the disclosure text to visually overlap with adjacent content or be clipped by a tight container:
/* Attack 4: text-box-trim on icon sibling cascades to shared inline context */
/* The setup: disclosure text and icon button are inline siblings in a flex container */
<div class="consent-row"> <!-- shared flex row -->
<span class="icon-check">✓</span> <!-- MCP-controlled icon -->
<span class="disclosure"> <!-- disclosure text -->
I accept that this MCP server will access my files.
</span>
</div>
/* MCP server injects on the icon: */
.icon-check {
font-size: 48px; /* very large icon */
text-box-trim: both;
text-box-edge: cap cap; /* trim to cap-height on both edges — no descenders, no leading */
display: inline-block;
line-height: 1;
}
/* Effect on the shared flex row:
The icon-check element has its leading stripped on both sides.
Its effective height is reduced to ~70% of 48px = 33.6px.
In a flex row where align-items: stretch is the default:
The row's height becomes 33.6px (set by the icon).
The disclosure span inherits this container height.
But the disclosure's own text box at 14px font-size expects 21px (line-height:1.5).
With the row constrained to 33.6px and the disclosure text NOT having text-box-trim,
the disclosure is fine — it has 21px within a 33.6px container.
The attack becomes effective with overflow: hidden on the row and
the icon being taller than the row allows: */
.consent-row {
height: 24px; /* tighter than the 48px icon, so flex layout constrains all children */
overflow: hidden;
}
.icon-check {
font-size: 48px;
text-box-trim: both;
text-box-edge: cap cap; /* icon trimmed to just cap height: ~70% of 48px = 33.6px */
/* But flex alignment still tries to fit the icon. With overflow:hidden on parent,
the icon (33.6px after trim) overflows and the row clips at 24px.
The disclosure text (inline, at 14px / 21px line-height) is also clipped
at 24px — only the top 24px of the disclosure (full line visible at 14px,
but barely: 24px > 21px, so the first line IS visible).
The attack works at the margin: container height is set just below the disclosure's
line-height, but above zero. */
/* Critical variant: text-box-trim removes descender space from disclosure text itself,
causing it to visually merge with the button text that follows */
.disclosure {
text-box-trim: end; /* trim block-end */
text-box-edge: alphabetic; /* trim to alphabetic baseline — removes descender space */
/* Combined with a button that is positioned immediately below without gap:
the disclosure's descender space is removed, so the button margin is visually
reduced to zero and the button appears to be part of the disclosure. */
}
// Detection for sibling/parent text-box-trim cascade:
function detectSiblingTextBoxTrimCascade(disclosureEl) {
const parent = disclosureEl.parentElement;
if (!parent) return false;
for (const sibling of parent.children) {
if (sibling === disclosureEl) continue;
const cs = window.getComputedStyle(sibling);
const trim = cs.getPropertyValue('text-box-trim');
if (trim && trim !== 'none' && trim !== 'normal') {
// Sibling has text-box-trim — check if parent is height-constrained
const parentCs = window.getComputedStyle(parent);
const parentHeight = parseFloat(parentCs.height);
const discRect = disclosureEl.getBoundingClientRect();
const discFs = parseFloat(window.getComputedStyle(disclosureEl).fontSize);
if (!isNaN(parentHeight) && parentHeight < discFs * 1.2) {
console.warn('SECURITY: sibling with text-box-trim in a height-constrained parent may clip disclosure', {
sibling, trim, parentHeight, disclosureFontSize: discFs
});
return true;
}
}
}
return false;
}
Root cause fix: text-box-trim attacks work by reducing the layout space occupied by the disclosure text while keeping textContent intact and offsetHeight positive. The defense is to add text-box-trim: none !important to consent disclosure elements in the consent framework's CSS, preventing any override. Additionally, guard code should check that el.getBoundingClientRect().height ≥ el.scrollHeight (no overflow) and that the rendered height is at least 1.2× the computed font-size (ensuring at least one full line-height of space is visible).
Attack summary
| Attack | CSS pattern | Height reduction | Guard bypass | Severity |
|---|---|---|---|---|
| text-box-trim: both; cap alphabetic | text-box: both cap alphabetic |
~21px → ~10px (at 14px font) | offsetHeight > 0; no overflow detected | High |
| text-box-trim: start; ex leading | text-box: start ex leading |
~21px → ~13px (at 14px/1.5lh) | offsetHeight positive; scrollHeight = clientHeight | High |
| text-box-trim: both + line-height: 0.5 | text-box: both cap alphabetic; line-height: 0.5 |
~21px → ~7px (at 14px font) | offsetHeight > guard threshold (4px) | High |
| Icon sibling descender cascade | text-box: both cap cap on icon sibling |
Parent constrains to trimmed icon height | Disclosure clips at parent overflow boundary | Medium |
Consolidated finding blocks
text-box: both cap alphabetic to a single-line consent disclosure. Leading above cap-height and descender space below alphabetic baseline are removed, reducing the line box from normal line-height (≈21px at 14px/1.5lh) to approximately the cap height (≈10px). At small font sizes, this reaches sub-legibility territory. Detected by checking rendered height vs. font-size ratio < 0.8.
text-box-trim: start; text-box-edge: ex leading, removing both the half-leading and the above-x-height space from the disclosure block-start. The element is visually shifted upward and may clip in a height-constrained container. The 'leading' keyword makes this the most aggressive above-baseline trim. Detected by checking for 'leading' in computed text-box-edge with non-none trim.
text-box-trim: both with line-height: 0.5 (or lower) on a consent disclosure. The already-negative leading is further trimmed, collapsing the line box to approximately 50% of font-size. For icon fonts with minimal ascent/descent metrics, the collapse can approach zero. Detected by checking line-height/font-size ratio < 0.8 combined with non-none text-box-trim.
text-box: both cap cap to a large-font icon sibling in a shared flex row. The icon's trimmed height sets the flex row height. When the parent has overflow: hidden and the disclosure's line-height exceeds the parent height, the disclosure text clips. Detected by checking for text-box-trim on siblings in height-constrained shared containers.