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:

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. */
}
AttackCSS propertyGuard bypassSeverity
text-wrap:balance + max-width:60px + max-height:1lh — narrow-render clipping of all but first 7 charstext-wrap:balanceel.textContent and el.innerText return full text; only scrollHeight vs clientHeight comparison reveals clipping; guards checking text content miss the visual cutoffHIGH
text-wrap:pretty orphan-avoidance — critical permission keyword pushed to line 3, clipped by max-height:2lhtext-wrap:prettyel.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 3HIGH
text-wrap:stable post-guard text injection — prefix added after guard passes shifts critical keywords below max-height cutofftext-wrap:stableGuard 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 stateMEDIUM
text-wrap:pretty keyword line-spanning — orphan-avoidance splits permission keywords across line breaks in visual renderingtext-wrap:prettyel.textContent returns unhyphenated full keyword; only OCR or visual scanners see the split; geometry-based visible-text extraction misses clipped keywordsMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHtext-wrap:balance + max-width:60px + max-height:1lh — narrow-container clipping of disclosure to first 7 characters: balance wraps text into many short lines at narrow max-width; only first line visible in max-height:1lh container; el.textContent full text — guard passes on content; scrollHeight>clientHeight detects clipping; guards checking text content but not geometry miss the attack
HIGHtext-wrap:pretty orphan-avoidance pushing "permissions" to line 3, clipped by max-height:2lh container: pretty algorithm reflowing text to avoid single-word orphan on last line; critical permission keyword lands on newly-created line 3; max-height:2lh clips line 3; el.innerText reveals full text; only scrollHeight/clientHeight ratio reveals clipping; must check geometry, not content
MEDIUMtext-wrap:stable post-guard text prefix injection — prefix added after guard passes shifts disclosure below max-height cutoff: guard at T=0 passes (full text visible); MCP injects prefix via insertAdjacentHTML at T=500ms; stable mode prevents last-line reflow-up; keywords shift below cutoff; only MutationObserver + ongoing geometry re-check detects post-injection clipping
MEDIUMtext-wrap:pretty keyword line-spanning — permission keywords split across line breaks in visual rendering, defeating screenshot-OCR scanners: DOM text content unchanged; visual rendering splits keywords at line breaks; only el.textContent-based guards immune; OCR-based visual scanners may miss split keyword; geometry-based visible-text extraction misses keywords on clipped lines beyond max-height cutoff

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.

← Blog  |  Security Checklist