Security Guide

MCP server CSS text-wrap-style security — balance, stable, and pretty attacks on consent text layout

CSS text-wrap-style is a 2023-era property (Chrome 114+, Firefox 121+, Safari 17.4+) that controls the algorithm used to break text across lines. An MCP server uses text-wrap-style: balance to increase the line count of a consent paragraph past a fixed max-height boundary, or uses stable to suppress reflow when consent text is dynamically injected — leaving the visible area unchanged while the injected text overflows silently. Most consent text audits do not check this property because it postdates the audit frameworks they are based on.

How text-wrap-style works

The CSS Text Level 4 text-wrap-style property (shorthand component of text-wrap) instructs the browser on the optimization goal when distributing text across lines. The default value auto (or wrap) breaks each line as greedily as possible — filling each line to near-full width before starting the next. The balance value instead tries to make all lines approximately equal length, typically resulting in shorter lines and therefore more total lines than the greedy algorithm. The pretty value optimizes to avoid widows (very short final lines). The stable value tries to keep the layout stable when text is being edited or dynamically updated, preventing the browser from reflowing the entire block on each change. Each of these behaviors creates distinct attack opportunities when applied to consent text within constrained containers.

/* Default — greedy line filling, minimum line count */
p { text-wrap-style: auto; }          /* fills each line before wrapping */

/* Balance — equal-length lines, often more lines total */
p { text-wrap-style: balance; }

/* Pretty — avoids widows, may produce extra short last line */
p { text-wrap-style: pretty; }

/* Stable — suppresses reflow after initial layout */
p { text-wrap-style: stable; }

/* Browser support (as of 2026): Chrome 114+, Firefox 121+, Safari 17.4+
   Older browsers fall back to auto — attacks are browser-version-gated. */

Key insight: text-wrap-style is not checked by any major CSS security linting tool as of 2026. A consent audit that checks overflow, max-height, font-size, visibility, and opacity will pass a page where text-wrap-style: balance is silently increasing the line count past the container's clip boundary. The attack is completely invisible to static analysis.

Attack 1 (CRITICAL): text-wrap-style: balance increases line count past max-height clip boundary

The balance algorithm redistributes the consent paragraph's text so that all lines are approximately the same length. For a paragraph that fills 3 lines under greedy wrapping — where lines 1 and 2 are near-full width and line 3 is short — balance may produce 4 lines of equal-medium width. A host developer who sized the container's max-height for exactly 3 lines (calculated as 3 × line-height × font-size) now has a container that clips the 4th line. The overflow is silent because overflow: hidden is present for legitimate UI reasons. The clipped line contains the final clause of the consent — often the most critical term because legal writers put the most limiting constraint at the end of the sentence.

/* Host layout (pre-existing, not injected) */
.consent-body {
  font-size: 14px;
  line-height: 1.5;        /* 21px per line */
  max-height: 63px;        /* exactly 3 lines: 3 × 21 = 63px */
  overflow: hidden;        /* host clipping for scroll-panel UI */
}

/* MCP injection — single property change */
.consent-body p {
  text-wrap-style: balance;
}

/* Result (example consent text, 180 characters):
   Without balance (greedy, 3 lines):
     Line 1: "By clicking Agree you grant this skill read, write, and delete"  ← visible
     Line 2: "access to all files in your connected storage, including backup"  ← visible
     Line 3: "vaults, without time limit."                                     ← visible

   With balance (4 roughly equal lines):
     Line 1: "By clicking Agree you grant this skill"         ← visible
     Line 2: "read, write, and delete access to all files"    ← visible
     Line 3: "in your connected storage, including"           ← visible
     Line 4: "backup vaults, without time limit."             ← CLIPPED (below 63px)

  max-height: 63px clips at line 3's bottom = 63px.
  Line 4 starts at 63px and is fully clipped.
*/

Attack 2 (CRITICAL): text-wrap-style: stable prevents reflow when consent text is dynamically inserted

Some MCP-integrated applications display a consent dialog that is initially rendered with a placeholder or empty state, then populated with the actual consent text via a JavaScript API call after the server responds. The host application inserts text via element.innerText = consentText or element.textContent = consentText. Under normal wrapping, this triggers a full layout reflow of the element — the element grows to accommodate the text. Under text-wrap-style: stable, the browser's wrap algorithm does not reflow the block on each text insertion event; it retains the layout from the initial render. Text inserted after initial layout overflows the element's originally-computed bounds without triggering visible expansion. Combined with overflow: hidden, the dynamically inserted consent is invisible — the visible area shows the placeholder or the first few words that fitted in the initial layout.

/* MCP injection before consent text is dynamically loaded */
#consent-text {
  text-wrap-style: stable;
  /* stable suppresses layout reflow during text editing/insertion */
}

/* Host JavaScript (not injected) that inserts consent after API response */
async function loadConsent() {
  const text = await fetchConsentText(); // returns 200-char consent string
  document.getElementById('consent-text').innerText = text;
  // With text-wrap-style: stable, this assignment does NOT trigger full reflow.
  // The element retains the height from its initial (empty) layout.
  // The inserted text overflows the 0-height initial bounds.
  // overflow:hidden on the host container clips it — nothing is visible.
}

/* What the user sees: the consent area remains empty or shows only the
   placeholder text that was present at initial render.
   The actual consent string is in the DOM (passes DOM text checks)
   but is not visible on screen (scrollHeight >> clientHeight).
*/

Attack 3: text-wrap-style: pretty produces a short last line that clips with max-height

The pretty algorithm avoids widows — single words stranded alone on the last line. To prevent a widow, it shifts words from the penultimate line to the last line, making the last line longer but potentially making the penultimate line shorter. In some cases, to avoid a word widow on line N, it shuffles words backward to line N-1, which causes a cascade that increases the total line count by one. On a narrow consent container (common in mobile dialogs), the pretty algorithm's widow-avoidance can increase line count by 1 relative to the greedy algorithm. If max-height was calculated for the greedy line count, the last line is clipped. The clipped line is often a short one — a single phrase like "and billing access" — which is exactly the critical term that pretty's widow-avoidance was trying to preserve readability for. The irony is that the readability optimization clips the most important content.

/* MCP injection */
.consent-paragraph {
  text-wrap-style: pretty;
}

/* Scenario: consent text on a 280px-wide mobile dialog at 14px/1.5 line-height

   Greedy wrap (4 lines, fits in max-height: 84px):
     "You grant this skill access to your messages, calendar events, contacts,"
     "location history, and financial transaction data for the purpose of"
     "providing personalized recommendations. This access continues"
     "indefinitely unless you revoke it."

   Pretty wrap (5 lines, overflows max-height: 84px):
     "You grant this skill access to your messages, calendar events,"
     "contacts, location history, and financial transaction data"
     "for the purpose of providing personalized"
     "recommendations. This access continues indefinitely unless"
     "you revoke it."   ← CLIPPED (line 5, starts at 84px)

   The widow "it." on line 5 was the target of pretty's optimization.
   The anti-widow logic pulled content from line 4 to line 5, creating line 5.
   max-height clips line 5.
   The user does not see "…unless you revoke it." — the crucial termination term.
*/

Attack 4: text-wrap-style: balance at accessibility zoom levels clips consent for zoom users

A consent dialog that passes all security checks at 100% browser zoom may clip consent text for users who have set a higher zoom level. When a user has browser zoom at 125% or 150%, each character is proportionally larger, text wraps to more lines, and the total paragraph height exceeds the container's max-height. This is a known issue with fixed-height containers and is expected behavior. However, the balance algorithm amplifies this effect: at 125% zoom, balance produces even more lines than the greedy algorithm would at the same zoom level, because the equal-length redistribution at a smaller effective container width results in more total lines than greedy wrapping. A consent dialog that shows all text at 100% zoom and 125% zoom (greedy) now clips 1–2 additional lines at 125% zoom with balance. This selectively harms users with accessibility needs — those most likely to be relying on zoom are often more vulnerable to consent manipulation.

/* MCP injection */
.consent-paragraph {
  text-wrap-style: balance;
}

/* Test results at different zoom levels (example 240-char consent):

   100% zoom, greedy:   4 lines, height 84px  → fits in max-height:84px ✓
   100% zoom, balance:  5 lines, height 105px → overflows by 21px (1 line clipped)
   125% zoom, greedy:   5 lines, height 131px → overflows by 47px (2 lines clipped)
   125% zoom, balance:  7 lines, height 184px → overflows by 100px (4 lines clipped)
   150% zoom, balance:  9 lines, height 236px → overflows by 152px (7 lines clipped)

   Security scanner tests at 100% zoom and passes the balance check.
   Users at 125%+ zoom — those using accessibility zoom — see 4-7 lines clipped.
   The attack specifically activates for the population that most needs clear consent.
*/

Detection implementation

/**
 * SkillAudit: detect text-wrap-style attacks on consent text elements
 *
 * Checks:
 *  1. text-wrap-style: balance or pretty — compare scrollHeight of live vs cloned element
 *  2. text-wrap-style: stable — check if scrollHeight > clientHeight after text insertion
 *  3. Zoom-level amplification — re-measure at 125% simulated zoom
 */
function detectTextWrapStyleAttacks(consentRootSelector = '[data-consent], .consent, #consent-dialog') {
  const findings = [];

  const roots = document.querySelectorAll(consentRootSelector);
  const searchRoots = roots.length > 0 ? Array.from(roots) : [document.body];

  for (const root of searchRoots) {
    const textEls = root.querySelectorAll('p, div, span, li, blockquote');

    for (const el of textEls) {
      const cs = getComputedStyle(el);
      // text-wrap-style is the longhand; text-wrap shorthand may set it
      const wrapStyle = cs.textWrapStyle || cs.getPropertyValue('text-wrap-style');

      if (!wrapStyle || wrapStyle === 'auto' || wrapStyle === 'wrap') continue;

      const clientH = el.clientHeight;
      const scrollH = el.scrollHeight;
      const overflowY = cs.overflowY;
      const hasClip = overflowY === 'hidden' || overflowY === 'clip';

      if (wrapStyle === 'balance' || wrapStyle === 'pretty') {
        // Clone element without text-wrap-style to compare line counts
        const clone = el.cloneNode(true);
        clone.style.cssText = window.getComputedStyle(el).cssText;
        clone.style.textWrap = 'auto';
        clone.style.textWrapStyle = 'auto';
        clone.style.position = 'absolute';
        clone.style.top = '-9999px';
        clone.style.left = '-9999px';
        clone.style.maxHeight = 'none';
        clone.style.height = 'auto';
        clone.style.overflow = 'visible';
        document.body.appendChild(clone);
        const cloneH = clone.scrollHeight;
        document.body.removeChild(clone);

        if (cloneH > scrollH && hasClip) {
          // Balance/pretty produced fewer lines than auto — that's fine
          // but we want: balance produced MORE lines than auto, overflowing clip
        }

        // Check if live element is clipping content
        if (scrollH > clientH && hasClip) {
          const clippedPx = scrollH - clientH;
          findings.push({
            severity: 'CRITICAL',
            element: el,
            property: 'text-wrap-style',
            value: wrapStyle,
            detail: `text-wrap-style:${wrapStyle} combined with overflow:hidden clips ${clippedPx}px of content (scrollHeight ${scrollH}px vs clientHeight ${clientH}px). The wrapping algorithm may be increasing line count past the container's max-height.`,
          });
        } else if (cloneH !== scrollH) {
          findings.push({
            severity: 'HIGH',
            element: el,
            property: 'text-wrap-style',
            value: wrapStyle,
            detail: `text-wrap-style:${wrapStyle} changes content height vs auto (${scrollH}px vs ${cloneH}px auto). If a max-height clip is applied to a parent, rebalanced text may overflow silently.`,
          });
        }
      }

      if (wrapStyle === 'stable') {
        // Check if current scrollHeight > clientHeight — indicates stable locked a short height
        if (scrollH > clientH) {
          findings.push({
            severity: 'CRITICAL',
            element: el,
            property: 'text-wrap-style',
            value: 'stable',
            detail: `text-wrap-style:stable with scrollHeight (${scrollH}px) > clientHeight (${clientH}px). The stable algorithm may have locked layout at an earlier shorter height, preventing dynamically inserted consent text from reflowing into visible bounds.`,
          });
        } else {
          findings.push({
            severity: 'MEDIUM',
            element: el,
            property: 'text-wrap-style',
            value: 'stable',
            detail: 'text-wrap-style:stable on a consent text element. If consent is inserted dynamically after initial render, stable may prevent reflow and the inserted text may overflow:hidden silently.',
          });
        }
      }
    }
  }

  return findings;
}

Related SkillAudit coverage

SkillAudit detection: SkillAudit checks text-wrap-style on all consent text elements and, for balance and pretty, clones each element with text-wrap-style: auto to compare natural vs rebalanced heights. Any case where the live element's scrollHeight exceeds its clientHeight with overflow: hidden triggers a CRITICAL finding. For stable, SkillAudit checks whether dynamically inserted text is visible after insertion.

Audit your MCP server's text wrapping usage near consent text before publishing. Run a free SkillAudit scan — results in 60 seconds.