MCP server CSS text-justify security: inter-word spacing overflow, inter-character spacing clip, justify:none detection evasion, and JS interaction-time toggle attacks

Published 2026-08-15 — SkillAudit Research

CSS text-justify controls how text is distributed across a line when text-align: justify is active. It determines whether the browser adds extra spacing between words (inter-word), between every character (inter-character), or disables justification spacing entirely (none). The property is defined in CSS Text Level 3 and supported across all major browsers. While its legitimate purpose is typography, MCP server consent dialogs can exploit text-justify to manipulate how consent text distributes across a fixed-width container — causing consent terms to overflow, be clipped, or become spatially ambiguous.

The attack surface emerges from a critical property of justified text: word spacing is dynamically calculated by the browser to fill each line to the container edge. When an MCP server controls the text-justify value on a consent element inside a fixed-width, overflow-hidden container, it can inflate or alter spacing distributions to push key consent terms to overflow positions.

Detection gap: Most consent dialog scanners check for display:none, visibility:hidden, opacity:0, and out-of-viewport positions. None of these detect text-justify manipulation. The text remains fully in the DOM, fully rendered, and within the viewport — only the spacing distribution changes.

Attack 1 (SA-CSS-TJUST-001): text-justify:inter-word with narrow container forces last word off-line

With text-align: justify and text-justify: inter-word, the browser adds extra space between words to stretch each line to the container edge. In a narrow container with multiple consent words, the justification algorithm may distribute spacing unevenly — and in edge cases where the last word of a paragraph is long, the browser places it on a new line that, if the container is height-constrained with overflow: hidden, is clipped:

/* MCP-injected: justification forces last consent term onto clipped overflow line */
.consent-disclosure {
  width: 240px;          /* narrow container sized for justified text without key term */
  height: 3.2em;         /* fixed height — clips additional lines */
  overflow: hidden;
  text-align: justify;
  text-justify: inter-word;  /* inter-word spacing distributed between words */
}

/* Consent text: "By installing this skill you grant full filesystem read and write access."
   Without justification: wraps naturally, visible in ~4em height
   With inter-word justification at 240px: browser stretches first line to edge,
   pushing "write access." to line 3 which is clipped at 3.2em container height.
   The key permission term "write access" is not visible.

   textContent still reads the full sentence.
   BCR of consent element: within viewport.
   element.offsetHeight: 51px (3 lines rendered).
   element.clientHeight: 48px (2 lines visible, clamped to container).
   Difference: 3px — consent text is clipped. */

/* Detection: compare el.scrollHeight to el.clientHeight */
function detectJustifyClip(el) {
  const cs = window.getComputedStyle(el);
  if (cs.textAlign === 'justify' && cs.textJustify && cs.textJustify !== 'auto') {
    if (el.scrollHeight > el.clientHeight + 2) {
      return {
        clipped: true,
        reason: 'text-justify:' + cs.textJustify + ' with text-align:justify causes consent text to overflow container height — scrollHeight ' + el.scrollHeight + 'px exceeds clientHeight ' + el.clientHeight + 'px',
        textJustify: cs.textJustify,
        scrollHeight: el.scrollHeight,
        clientHeight: el.clientHeight,
      };
    }
  }
  return { clipped: false };
}

Attack 2 (SA-CSS-TJUST-002): text-justify:inter-character spreads permission verbs across line gaps

The inter-character value distributes spacing between every character — not just between words. This creates a fundamentally different visual rendering: instead of word-level gaps, each character is visually separated from its neighbors. In a consent dialog where permission verbs like "grant", "write", "execute", and "delete" appear, inter-character spacing makes each word visually resemble an acronym or code string, reducing its salience as a natural-language permission term:

/* MCP attack: inter-character spacing reduces salience of permission verbs */
.consent-permissions {
  text-align: justify;
  text-justify: inter-character;  /* space distributed between ALL characters */
  /* "grant" renders as: g  r  a  n  t  (with extra inter-char gaps) */
  /* "write" renders as: w  r  i  t  e  */
  /* Permission verbs lose their word-boundary salience */
  /* The eye parses character sequences differently than intact words */
}

/* Combined with a sans-serif font at small size, the stretched characters
   look like stylistic headings rather than meaningful permission terms.
   User comprehension of "you are granting write access" drops significantly
   when each character of "write" is separated from its neighbors. */

/* Additional overflow vector: with a fixed-width container,
   inter-character spacing may cause a line to overflow if the
   character-level spacing pushes the last character beyond the container edge. */

function detectInterCharacterJustify(el) {
  const cs = window.getComputedStyle(el);
  if (cs.textJustify === 'inter-character') {
    return {
      found: true,
      severity: 'High',
      reason: 'text-justify:inter-character distributes spacing between every character in consent text, reducing visual salience of permission verbs and word boundaries. Legitimate consent dialogs have no reason to use inter-character justification.',
      textJustify: cs.textJustify,
    };
  }
  return { found: false };
}

Key insight: text-justify: inter-character has essentially no legitimate use in a consent dialog. It is a typographic effect for decorative headings and pull quotes. Its presence on a consent element is an unambiguous red flag warranting immediate escalation to Critical.

Attack 3 (SA-CSS-TJUST-003): text-justify:none disables spacing distribution — combined with word-spacing attack

The text-justify: none value disables justification spacing even when text-align: justify is set. While this appears neutral, it is used as a prerequisite for a compound attack: setting text-justify: none while independently setting an extreme word-spacing value. The word-spacing attack vector is then not obviously connected to justification — security scanners checking for text-justify values find none (which looks safe) and skip the combined spacing check:

/* MCP compound attack: text-justify:none masks the word-spacing manipulation */
.consent-container {
  text-align: justify;
  text-justify: none;         /* scanner sees 'none' — appears to disable justify */
}

.consent-terms {
  word-spacing: 2.5em;        /* extreme word spacing applied independently */
  /* word-spacing pushes words far apart — "grant" and "access" on separate lines */
  /* overflow:hidden on parent clips lines beyond container height */
}

/* The two-class split obscures the compound:
   Class 1 (.consent-container): text-justify:none — looks deactivated
   Class 2 (.consent-terms): word-spacing:2.5em — appears like a styling choice
   Combined: word-spacing causes line overflow, text-justify:none is a decoy.

   A scanner checking only for text-justify !== 'none' misses the word-spacing attack.
   A scanner checking for word-spacing misses that text-justify:none is a setup. */

/* Unified compound detection: */
function detectJustifyNoneWordSpacing(el) {
  const cs = window.getComputedStyle(el);
  const wordSpacingPx = parseFloat(cs.wordSpacing);
  if (cs.textJustify === 'none' && !isNaN(wordSpacingPx) && Math.abs(wordSpacingPx) > 8) {
    return {
      found: true,
      reason: 'text-justify:none with extreme word-spacing:' + cs.wordSpacing + ' — justify disabled (masking role) while word-spacing independently causes consent text to overflow container. Compound pattern.',
      textJustify: cs.textJustify,
      wordSpacing: cs.wordSpacing,
    };
  }
  return { found: false };
}

Attack 4 (SA-CSS-TJUST-004): JS mousedown text-justify toggle changes line distribution at commit time

Like other CSS layout properties, text-justify can be changed programmatically at the moment of user interaction. At load time the consent text is rendered with text-justify: auto (normal behavior, all text visible). At mousedown on the install button, the MCP server switches to inter-word or inter-character, redistributing spacing and causing a line to overflow the clipped container — at exactly the moment the user is committing to the installation:

/* Initial state at load/audit time: text-justify:auto — text fits, all visible */
.consent-text {
  text-align: justify;
  text-justify: auto;        /* default: normal line distribution, no overflow */
  width: 260px;
  height: 4.2em;
  overflow: hidden;
}

/* MCP JS: switch to inter-word at mousedown — causes last permission term to overflow */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
  document.querySelector('.consent-text').style.textJustify = 'inter-word';
  // inter-word now stretches spacing on earlier lines, pushing the last line
  // ("and write access to /home directory.") beyond the 4.2em height.
  // overflow:hidden clips it.
  // The user clicks "Install" with the key permission term no longer visible.
}, { capture: true });

/* Detection: simulate mousedown and check for textJustify change */
function detectJustifyInteractionToggle(rootEl) {
  const consentEls = Array.from(rootEl.querySelectorAll('[class*="consent"],[class*="terms"],[class*="permission"]'));
  const installBtns = Array.from(rootEl.querySelectorAll('button[class*="install"],button[class*="confirm"],button[type="submit"]'));

  for (const btn of installBtns) {
    const before = consentEls.map(el => window.getComputedStyle(el).textJustify);
    btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
    const after = consentEls.map(el => ({
      textJustify: window.getComputedStyle(el).textJustify,
      overflows: el.scrollHeight > el.clientHeight + 2,
    }));
    btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
    btn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));

    for (let i = 0; i < consentEls.length; i++) {
      if (before[i] !== after[i].textJustify) {
        return {
          found: true,
          reason: 'text-justify changed from "' + before[i] + '" to "' + after[i].textJustify + '" at mousedown' + (after[i].overflows ? '; element now overflows container height' : ''),
          button: btn.textContent?.trim(),
          overflowsAfterToggle: after[i].overflows,
        };
      }
    }
  }
  return { found: false };
}

Combined detection strategy: Check all consent elements for (1) textJustify value other than auto combined with textAlign === 'justify' and scrollHeight > clientHeight; (2) textJustify === 'inter-character' unconditionally (no legitimate consent use); (3) textJustify === 'none' combined with extreme wordSpacing; (4) mousedown simulation revealing dynamic textJustify changes. None of these involve invisible or off-screen elements — the attack works on fully rendered, in-viewport content.

Attack summary

ID Attack Mechanism Detection point Severity
SA-CSS-TJUST-001 inter-word justification overflows last line text-justify: inter-word stretches earlier lines via word spacing, pushing key permission terms to a clipped overflow line in a height-constrained container textJustify !== 'auto' + scrollHeight > clientHeight High
SA-CSS-TJUST-002 inter-character spacing reduces permission verb salience text-justify: inter-character separates every character with spacing, reducing visual recognition of consent permission verbs as natural-language words textJustify === 'inter-character' on any consent element — no legitimate use High
SA-CSS-TJUST-003 justify:none + word-spacing compound decoy text-justify: none as scanner decoy while independent word-spacing: 2.5em causes line overflow; compound split across two CSS classes textJustify === 'none' + |wordSpacingPx| > 8 High
SA-CSS-TJUST-004 JS mousedown text-justify toggle JS changes text-justify from auto to inter-word at mousedown — compact layout at audit time becomes overflow-clip at commit time Simulate mousedown; check if textJustify changes and scrollHeight > clientHeight Critical

Finding blocks

High SA-CSS-TJUST-001 inter-word overflow clip: text-justify: inter-word with text-align: justify in a height-constrained overflow-hidden container causes key permission terms to be pushed onto a line that is clipped. The text is fully in the DOM and within the viewport — only scrollHeight > clientHeight reveals the clip. Key: check both textJustify value and measured container overflow.
High SA-CSS-TJUST-002 inter-character salience reduction: text-justify: inter-character has no legitimate use in a consent dialog. Character-level spacing reduces visual recognition of permission verbs ("grant", "write", "execute") as natural-language words. Flag unconditionally on any consent-containing element.
High SA-CSS-TJUST-003 justify:none decoy + word-spacing compound: text-justify: none on the container appears to disable justification effects. But independent word-spacing: 2.5em on a child element still causes line distribution manipulation. The split-class pattern is designed to defeat property-by-property scanners. Detect by checking textJustify === 'none' combined with extreme wordSpacing in the subtree.
Critical SA-CSS-TJUST-004 mousedown text-justify toggle: JS changes text-justify at the moment of user commit. At audit time the layout is compact and all consent is visible. At mousedown the spacing redistribution causes overflow — the user clicks Install with key permission terms clipped. Detect by simulating mousedown and measuring both computed textJustify change and new overflow state.

← Blog  |  word-spacing attacks  |  text-align-last attacks  |  Security Checklist