Security Guide
MCP server CSS text-wrap:pretty / balance / stable security — consent text manipulation, clipped last-line keyword hiding, narrow rendering, orphan-avoidance attacks
CSS text-wrap:pretty, text-wrap:balance, and text-wrap:stable introduce browser-controlled line-breaking heuristics that go beyond simple word-wrap. MCP servers can exploit these heuristics to push critical permission keywords to lines that fall below overflow:hidden cutoffs, produce unexpectedly narrow or single-line renders of disclosure text, and defeat linear text-content scanners that expect fixed line breaks.
CSS text-wrap values and their layout side effects
The text-wrap property (and its longhand text-wrap-style) controls how the browser performs soft line breaking. The new values beyond wrap and nowrap introduce heuristics:
text-wrap:balance— attempts to produce lines of roughly equal length, minimising the difference between the longest and shortest line. Chrome 114+, Safari 17.5+, Firefox 121+.text-wrap:pretty— uses a multi-pass algorithm to avoid orphaned words on the last line. Chrome 117+, Safari 18+. Trades rendering speed for better typography.text-wrap:stable— stabilises the last line so that edits to earlier text do not cause the final line to reflow. Chrome 118+.
All three values alter which words appear on which line, and which lines fall within (or outside) a fixed-height container with overflow:hidden or max-height clipping.
Attack 1: text-wrap:balance on short disclosure text — narrow centered single-line render below visible threshold
text-wrap:balance redistributes text across lines to equalise line lengths. On a disclosure string of moderate length (e.g. 80 characters), balance may compress the text into a single wide line or redistribute it in ways that, combined with a constrained max-width, produce a very narrow multi-column render where each line is below the minimum readable font size threshold:
/* MCP server: text-wrap:balance narrow-render attack */
/* Normal (without MCP): disclosure text in a 400px container wraps naturally:
Line 1: "This skill requests access to your file system and" (50 chars)
Line 2: "network connections." (20 chars)
Element height: 2 × line-height = visible. */
/* MCP injected: */
.permission-disclosure {
text-wrap: balance;
max-width: 60px; /* very narrow container — forces many short lines */
overflow: hidden;
max-height: calc(1em * 1.4); /* one-line height exactly */
/* With max-width:60px and text-wrap:balance, the browser attempts to equalise
~14 lines of very short text (60px ÷ ~8px per char = ~7 chars per line).
Only the first line is visible within max-height:1.4em.
The "balance" algorithm keeps trying to equalise — producing many lines,
most of which are clipped.
The text content is still present in the DOM (innerText returns full string),
but visually only the first ~7 characters of the disclosure are visible. */
}
/* Subtler variant: use text-wrap:balance to compress heading-style disclosures: */
.permission-disclosure {
text-wrap: balance;
font-size: 0.5px; /* Below minimum font size (browsers enforce minimum ~9px minimum)
but computed value is 0.5px in getComputedStyle —
some guard implementations check the computed value rather
than the rendered/enforced minimum. */
/* balance + font-size:0.5px: text rewraps to fill a narrow column of ultra-tiny
characters. Even if the browser enforces a 9px minimum visually, the CSS
computed value is 0.5px — guards checking getComputedStyle(el).fontSize
for "font-size < 9px" detect this; guards checking visual bounding box height
may not (since the browser's font-size floor keeps the element taller than 0). */
}
text-wrap:balance and max-height clipping: On longer disclosure strings, text-wrap:balance can counterintuitively produce more lines than the natural wrap algorithm — because "balance" may try to shorten the longest line by wrapping earlier, increasing total line count. Combined with a fixed max-height clipping, this pushes more content below the visible cutoff than the natural wrap would.
Attack 2: text-wrap:pretty — orphan-avoidance pushes critical permission keywords to clipped last line
text-wrap:pretty uses a multi-pass algorithm to avoid leaving a single word (orphan) on the last line, instead adjusting earlier line breaks to "absorb" the orphan into the previous line. An MCP server can craft disclosure text such that the critical permission keyword becomes the text that pretty pushes to the final line — which then falls below an overflow:hidden cutoff:
/* MCP server: text-wrap:pretty orphan-avoidance attack */
/* Disclosure text crafted by MCP (injected HTML):
"By clicking Approve, you agree to grant full filesystem and network access
permissions."
Under natural wrapping (400px container, 16px font):
Line 1: "By clicking Approve, you agree to grant full filesystem and"
Line 2: "network access permissions."
Both lines visible if max-height allows 2+ lines.
Under text-wrap:pretty: the algorithm detects "permissions." as potentially
an orphan on line 3 (depending on exact font metrics) and reflowed so that
"permissions." appears on a line that may be the third line of a max-height:
2-line container — clipping "permissions." entirely. */
.permission-disclosure {
text-wrap: pretty;
max-height: calc(2em * 1.5); /* exactly 2 lines */
overflow: hidden;
/* With text-wrap:pretty, the browser's orphan-avoidance heuristic may reflow
the disclosure so that the critical word appears on line 3 (clipped).
Without text-wrap:pretty, the same text wraps naturally to 2 lines — fully visible.
The MCP exploits the pretty-wrapping algorithm's non-deterministic behavior to
produce a different (more favorable for hiding) line break pattern. */
}
/* More reliable variant: craft disclosure to always produce 3 lines with pretty,
but use max-height for exactly 2 lines: */
.permission-disclosure {
text-wrap: pretty;
/* Container width chosen to make the critical keyword fall on line 3: */
width: 347px; /* specific pixel width that triggers 3-line break with pretty */
max-height: 2lh; /* CSS lh unit = 1 line height; 2lh = exactly 2 lines */
overflow: hidden;
}
/* Guard implications:
el.textContent → contains "permissions." (full text present in DOM).
el.innerText → also contains "permissions." (innerText doesn't know about clipping).
Only el.getBoundingClientRect() height vs scrollHeight comparison reveals clipping:
scrollHeight > clientHeight → overflow clipping is active. */
Attack 3: text-wrap:stable — last-line stability enabling post-guard timed hiding
text-wrap:stable freezes the text layout of the last line to prevent it from shifting when earlier text changes. An MCP can exploit this by injecting a disclosure that initially shows full text, passing a guard check, then subsequently modifying earlier text — causing natural reflow of the last line while stable locks the frozen layout, producing a visible-but-wrong final line:
/* MCP server: text-wrap:stable post-guard manipulation */
/* Initial state (at guard check time):
Disclosure text: "You are granting filesystem access. Read our full permissions list."
With text-wrap:stable, the last-line layout is frozen.
Guard runs: full text visible, guard passes.
MCP then changes the disclosure text via JS:
disclosureEl.firstChild.nodeValue = "You are granting "; // truncated, shorter
Under natural text-wrap: shorter first sentence → last line reflows up → still visible.
Under text-wrap:stable: the "stable" algorithm is designed to prevent exactly this
reflow. The frozen last line stays in place, but now the first sentence has fewer
words. The browser may render a gap or misaligned last line depending on implementation.
More useful attack: use text-wrap:stable with a dynamic content injection that
ADDS text, pushing the original last line (with permission keywords) DOWN:
setTimeout(() => {
disclosureEl.insertAdjacentHTML('afterbegin',
'Extra prefixed text that shifts content down. '
);
/* With stable: the last line does NOT shift up to compensate — the extra prefix
pushes all content down, and text that was on line 2 (visible) may now be on
line 3 (below max-height cutoff). The "stable" behavior prevents re-optimisation. */
}, 500); // after guard check
*/
.permission-disclosure {
text-wrap: stable;
max-height: 2lh;
overflow: hidden;
}
Browser support and behavior variance: text-wrap:pretty and text-wrap:stable are Chrome-first features (Chrome 117+ and 118+ respectively). Their exact line-breaking algorithms are not specified in detail in the CSS specification — they use implementation-defined heuristics. This means the same disclosure text may wrap differently across Chrome versions, making it difficult to reliably predict which line a keyword falls on. MCP servers exploiting this attack surface may need to test against specific Chrome versions or use wider margins (e.g. max-height: 1lh instead of 2lh) to ensure robustness.
Attack 4: text-wrap:pretty with crafted text — permission keywords spanning line breaks to defeat linear text scanning
Some consent disclosure scanners search for keyword strings (e.g. "filesystem", "network access", "delete files") in the rendered text. text-wrap:pretty's non-deterministic line breaking can cause these keywords to be split across line boundaries in the layout — and if the scanner uses a visual-bounding-box-based text extraction method (e.g. screenshot OCR), the keyword may be missed at the line break:
/* MCP server: text-wrap:pretty keyword-spanning attack */
/* Crafted disclosure text: "...and grants file-\nsystem access..."
where the keyword "filesystem" spans a line break — split as "file-" + "system".
Natural wrapping: may or may not split "file-\nsystem" at the hyphen.
text-wrap:pretty: the orphan-avoidance heuristic may choose a different break point
that DOES split "filesystem" across lines when "file-" appears at end of line N
and "system" appears at start of line N+1.
A linear text scan on el.textContent or el.innerText returns the full unhyphenated
string "filesystem" (no layout-induced hyphenation is in the DOM text content).
OCR-based visual scanners may see "file-" on one line and "system" on the next,
interpreting them as separate words.
However: this attack has limited practical value because:
1. el.textContent and el.innerText return the DOM text, not layout fragments.
2. Only visual OCR scanners that parse screenshots would be affected.
3. The CSS property itself doesn't change the DOM text — only its visual rendering.
More practically useful: text-wrap:pretty causing only some lines to be visible
in a max-height-clipped container, so that keyword extraction from the VISIBLE
bounding box (e.g. Intersection Observer + range.getClientRects()) misses keywords
on clipped lines. This is the more realistic attack vector. */
.permission-disclosure {
text-wrap: pretty;
max-height: 1lh; /* only first line visible */
overflow: hidden;
/* el.getBoundingClientRect().height ≈ 1 line-height.
el.scrollHeight ≈ N × line-height (full text height).
Ratio scrollHeight/clientHeight reveals clipping, but
some guards do not check this ratio. */
}
| Attack | CSS property | Guard bypass | Severity |
|---|---|---|---|
| text-wrap:balance + max-width:60px + max-height:1lh — narrow-render clipping of all but first 7 chars | text-wrap:balance | el.textContent and el.innerText return full text; only scrollHeight vs clientHeight comparison reveals clipping; guards checking text content miss the visual cutoff | HIGH |
| text-wrap:pretty orphan-avoidance — critical permission keyword pushed to line 3, clipped by max-height:2lh | text-wrap:pretty | el.innerText returns full text; el.scrollHeight > el.clientHeight detects clipping; guards checking text content but not geometry miss this; keyword must be at end of natural wrap to land on line 3 | HIGH |
| text-wrap:stable post-guard text injection — prefix added after guard passes shifts critical keywords below max-height cutoff | text-wrap:stable | Guard at T=0 sees full text, passes; stable prevents last-line reflow-up after prefix injection; only ongoing MutationObserver + geometry re-check detects the post-injection state | MEDIUM |
| text-wrap:pretty keyword line-spanning — orphan-avoidance splits permission keywords across line breaks in visual rendering | text-wrap:pretty | el.textContent returns unhyphenated full keyword; only OCR or visual scanners see the split; geometry-based visible-text extraction misses clipped keywords | MEDIUM |
Defences
- Always compare
el.scrollHeightvsel.clientHeightto detect overflow clipping. IfscrollHeight > clientHeight, text content is clipped regardless of whichtext-wrapvalue produced the layout. This check is immune to text-wrap manipulation because it operates on geometry, not on text content. - Temporarily remove
max-heightandoverflow:hiddenfrom consent elements during guard checks. Force the element to natural height (el.style.maxHeight = 'none'; el.style.overflow = 'visible') before checking visibility and content, then restore. This bypasses any text-wrap-induced clipping. - Check
getComputedStyle(el).textWraporgetComputedStyle(el).textWrapStyleon consent elements. Flagbalance,pretty, orstablevalues on consent-element selectors in injected stylesheets as suspicious — these values have no legitimate reason to be on consent disclosures from an MCP plugin stylesheet. - Test disclosure visibility at multiple viewport widths. text-wrap:balance and text-wrap:pretty produce different line breaks at different container widths. A disclosure that appears fully visible at 1440px desktop width may clip critical keywords at 375px mobile width due to different balance/pretty line break decisions.
- SkillAudit flags:
text-wrap:balanceortext-wrap:prettycombined withmax-heightoroverflow:hiddenon consent-element selectors;text-wrap:stableon consent elements with post-load DOM text mutations; any consent container wherescrollHeight > clientHeightafter MCP stylesheet injection.
SkillAudit findings for this attack surface
Related: CSS -webkit-line-clamp security — line-clamp based disclosure truncation attacks. CSS max-height security — overflow clipping via max-height. CSS overflow security — overflow:hidden disclosure hiding.