MCP server CSS text-align-last security: text-align-last:right with direction:rtl clips consent at left edge, justify in narrow container, end alignment with bidi content, and JS mousedown RTL toggle in overflow:hidden

Published 2026-08-07 — SkillAudit Research

The CSS text-align-last property specifies how the last line (or only line) of a block is aligned when text-align is set to justify, or how any single-line text block is aligned when a specific value is set. It is a distinct property from text-align: while text-align governs all lines of wrapped text, text-align-last applies specifically to the final — or sole — line. For single-line consent text in a fixed-width container, text-align-last effectively controls the text's horizontal position independently of text-align. The security relevance arises when text-align-last: right is combined with direction: rtl: in an RTL context, "right" is the logical start of the line (where the first characters appear in RTL script), which becomes the physical left edge of the container. The consent text is pushed to the left edge and, in a container with overflow: hidden, may be clipped against the container's left padding or border.

This attack is distinct from text-indent attacks (which push text off-screen via large indent values) and unicode-bidi attacks (which reverse text rendering order). The text-align-last property is supported in Chrome 47+, Firefox 69+, Safari 16+. See also text-overflow attacks for adjacent overflow clipping patterns.

Detection gap: A scanner checking getComputedStyle(el).textAlign will not detect text-align-last attacks — they are separate properties. The correct check is getComputedStyle(el).textAlignLast, cross-referenced against direction, overflow, and container width to determine whether the alignment pushes text to a clipped position.

Attack 1: text-align-last:right + direction:rtl + overflow:hidden — consent pushed to physical left edge and clipped (SA-CSS-TALST-001)

The consent element has direction: rtl, text-align-last: right, and the parent container has overflow: hidden with a width narrower than the consent text's natural width. In an RTL layout, logical "right" corresponds to the physical left side of the container — this is where RTL text begins. With text-align-last: right, the single-line consent text is aligned to the physical left edge of the container. Because the container is narrower than the text, the text begins at the left edge and extends to the right, with the right portion clipped by overflow: hidden. The start of the text (the first words of the consent) is visible in the narrow container, but in RTL the visual "start" means the right-to-left reading start — the actual first word is at the physical right edge, which is the clipped end. The result: the visible portion is the end of the consent text, not the beginning of the consent statement.

/* MCP attack: */
.mcp-dialog {
  width: 120px;        /* narrower than consent text */
  overflow: hidden;
}

.consent-disclosure {
  direction: rtl;
  text-align-last: right;
  /* In RTL context: logical-right = physical-left
     Text aligned to physical left edge
     Consent text reads right-to-left; first words are at physical right (clipped)
     Only tail of consent visible in the 120px window
     getComputedStyle(el).textAlign:     'start' (default)  ← scanner may miss
     getComputedStyle(el).textAlignLast: 'right'            ← reveals intent */
}

// Detection:
function detectTextAlignLastRTL(el) {
  const cs = window.getComputedStyle(el);
  const tal = cs.textAlignLast;    // 'right', 'left', 'end', 'start', 'justify', 'auto'
  const dir = cs.direction;         // 'rtl' or 'ltr'
  const ovf = cs.overflow;
  const parent = el.parentElement;
  const parentWidth = parent ? parent.offsetWidth : Infinity;

  if (tal !== 'auto' && tal !== 'start' && dir === 'rtl') {
    if (ovf === 'hidden' || window.getComputedStyle(parent)?.overflow === 'hidden') {
      if (parentWidth < el.scrollWidth) {
        console.error('SA-CSS-TALST-001: text-align-last + RTL direction clips consent in overflow:hidden', {
          el, textAlignLast: tal, direction: dir, containerWidth: parentWidth, scrollWidth: el.scrollWidth
        });
      }
    }
  }
}

Attack 2: text-align-last:justify in very narrow container — extreme inter-word spacing pushes characters off-screen (SA-CSS-TALST-002)

text-align-last: justify on a single-line element in a container set to width: 8px causes the browser to distribute the inter-word and inter-character space across the available line width (8px) for the consent text. Each word of the consent text is separated by enormous justified spacing — on a short consent line, this means the first word anchors at the left edge (0px), the last word anchors at the right edge (8px), and all intermediate words are spread across the 8px width with proportional spacing. In practice all words collapse into the same 8px band, each word visually occupying the same 0–8px strip. The result is unreadable layered text. The element's offsetWidth is 8px; textContent is the full consent string; scrollWidth may or may not be larger depending on browser behavior with extreme justify. A scanner checking offsetWidth against scrollWidth may not flag this because the browser reports scrollWidth = offsetWidth = 8px with justify containing all text within bounds.

/* MCP attack: */
.consent-disclosure {
  width: 8px;
  text-align-last: justify;
  white-space: nowrap;          /* prevents line wrapping */
  overflow: hidden;
  /* All consent words forced into 8px with justified spacing
     Result: all words overlap at same x position — unreadable stacked text
     offsetWidth: 8px; scrollWidth: 8px (justified, not overflowing)
     textContent: full consent string */
}

/* Variant: text-align:justify + text-align-last:justify in 1px container */
.consent-disclosure {
  width: 1px;
  text-align: justify;
  text-align-last: justify;
  /* Even more extreme — single-pixel justified column */
}

// Detection:
function detectNarrowJustifyAttack(el) {
  const cs = window.getComputedStyle(el);
  if (cs.textAlignLast === 'justify') {
    const w = el.offsetWidth;
    if (w < 20 && el.textContent.trim().length > 0) {
      console.error('SA-CSS-TALST-002: text-align-last:justify in extremely narrow container', {
        el, width: w, textAlignLast: cs.textAlignLast, textContent: el.textContent.slice(0, 40)
      });
    }
  }
}

Attack 3: text-align-last:end with direction:rtl and mixed bidi content — only trailing punctuation visible (SA-CSS-TALST-003)

text-align-last: end aligns the last line to the logical end of the line direction. In an RTL context (direction: rtl), the logical "end" is the physical left side — so the consent text is pushed toward the physical left edge. With overflow: hidden on a narrower container, only the physical-left portion is visible, which in an RTL line corresponds to the end of the sentence — trailing punctuation, closing parentheses, or the final word. The semantically important beginning of the consent ("By clicking Install, you grant...") is at the physical right side (the RTL line start), which is clipped. Additionally, if the consent text contains a mix of LTR characters (like URLs or product names) and RTL script, the Unicode Bidirectional Algorithm re-orders portions of the line. The combination of text-align-last: end + direction: rtl + mixed bidi content creates unpredictable visual positioning of LTR embedded segments.

/* MCP attack: */
.consent-disclosure {
  direction: rtl;
  text-align-last: end;
  overflow: hidden;
  width: 200px;  /* narrower than full consent line */
  /* In RTL: end = physical left = sentence tail
     Only the grammatical end of the sentence is visible (punctuation, final word)
     The consent's main clause ("you grant access to...") is at physical right — clipped */
}

/* With embedded LTR URL: */
.consent-disclosure {
  direction: rtl;
  text-align-last: end;
  /* Consent: "Install to grant access to https://example.com files"
     RTL rendering: URL (LTR island) re-ordered by bidi algorithm
     With end-alignment, the visible portion is unpredictable */
}

// Detection:
function detectEndAlignRTL(el) {
  const cs = window.getComputedStyle(el);
  if (cs.textAlignLast === 'end' && cs.direction === 'rtl') {
    const parentOverflow = window.getComputedStyle(el.parentElement).overflow;
    if (parentOverflow === 'hidden' || cs.overflow === 'hidden') {
      console.error('SA-CSS-TALST-003: text-align-last:end + direction:rtl + overflow:hidden clips consent', {
        el, textAlignLast: cs.textAlignLast, direction: cs.direction
      });
    }
  }
}

Attack 4: JS mousedown toggles text-align-last:right + direction:rtl — consent shifts to clipped edge at install click (SA-CSS-TALST-004)

At page load, the consent element has default alignment (text-align-last: auto, direction: ltr) and renders normally within its container — audit passes. At mousedown on the install button, JS sets el.style.textAlignLast = 'right' and el.style.direction = 'rtl' simultaneously. The consent text immediately shifts to the physical left edge (RTL right = physical left). In a container with overflow: hidden, the text is now clipped. Because both property changes happen in the same JS task (before the browser repaints), the user sees only the result: the consent text appears to briefly flash and then the install proceeds. MutationObserver on the element's style attribute catches both property changes at once.

/* Baseline CSS: default alignment — visible at load time */
.consent-disclosure {
  /* No text-align-last or direction set */
  /* Loads with default: text-align-last:auto, direction:ltr */
}

.mcp-dialog {
  width: 180px;
  overflow: hidden;    /* set up for clip attack */
}

// MCP JS — triggers at install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  const el = document.querySelector('.consent-disclosure');
  if (el) {
    el.style.textAlignLast = 'right';
    el.style.direction = 'rtl';
    /* Both applied in same task before repaint
       Consent text shifts to physical left, gets clipped in overflow:hidden
       Visible at audit time, clipped at install time */
  }
}, { capture: true });

// Detection:
function detectDynamicTextAlignLast() {
  document.querySelectorAll('.consent-disclosure, [data-consent]').forEach(el => {
    const observer = new MutationObserver(() => {
      const cs = window.getComputedStyle(el);
      const tal = cs.textAlignLast;
      const dir = cs.direction;
      if (tal !== 'auto' && dir === 'rtl') {
        console.error('SA-CSS-TALST-004: JS changed text-align-last + direction at interaction', {
          el, textAlignLast: tal, direction: dir
        });
        requestAnimationFrame(() => {
          const rect = el.getBoundingClientRect();
          const parentRect = el.parentElement?.getBoundingClientRect();
          if (parentRect && rect.left < parentRect.left) {
            console.error('SA-CSS-TALST-004: consent text starts left of container after toggle', { el });
          }
        });
      }
    });
    observer.observe(el, { attributes: true, attributeFilter: ['style'] });
    document.querySelector('#install-btn, [data-action="install"]')
      ?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  });
}

Root detection method for all text-align-last attacks: Always check getComputedStyle(el).textAlignLast — it is a distinct property from textAlign and not revealed by checking textAlign alone. Cross-reference against direction, container overflow, and container width vs. text natural width. Any combination of non-default textAlignLast + direction: rtl + overflow: hidden warrants a geometric BCR check to confirm consent text is actually visible within the container bounds. SkillAudit checks textAlignLast independently on every consent element.

Attack summary

IDCSS / JS techniquetextAligntextAlignLastdirectionSeverity
SA-CSS-TALST-001text-align-last:right + direction:rtl + narrow overflow:hidden'start''right''rtl'High
SA-CSS-TALST-002text-align-last:justify in 8px container — stacked unreadable textany'justify''ltr'High
SA-CSS-TALST-003text-align-last:end + direction:rtl — only tail visible'start''end''rtl'High
SA-CSS-TALST-004JS sets textAlignLast='right' + direction='rtl' at mousedowndefault'right' (after)'rtl' (after)High

Consolidated finding blocks

High CSS text-align-last:right + direction:rtl clips consent at physical left edge in overflow:hidden container: In RTL, logical-right is physical-left. text-align-last: right aligns consent text to the physical left edge. In a container narrower than the text with overflow: hidden, the visible portion is the RTL sentence tail (grammatically unimportant), while the consent's main clause is clipped at the physical right side. getComputedStyle().textAlign may show default — only textAlignLast reveals the attack.
High CSS text-align-last:justify in 8px container — all consent words overlap in unreadable stacked column: Extreme justification forces all words into a container too narrow to display any of them distinctly. Words overlap at the same x position, creating visually unreadable stacked text. offsetWidth = 8px; scrollWidth = 8px (text does not overflow). Standard width-check detectors miss it — only small-container + justify combination reveals it.
High CSS text-align-last:end + direction:rtl makes only trailing punctuation visible in clipped container: Logical "end" in RTL context is the physical left. Combined with overflow: hidden and a narrow container, only the grammatical end of the consent sentence (trailing words, punctuation) is visible. The semantically critical consent clause is at the RTL line start (physical right), outside the visible area.
High JS text-align-last + direction swap at mousedown — consent shifts to clipped edge at install click, normal at audit time: Both properties applied in the same JS task before repaint. Consent visible at load-time audit. At mousedown, instantaneous shift clips consent behind overflow boundary. MutationObserver on style attribute with textAlignLast + direction cross-check detects the dynamic manipulation.

CSS text-indent security  |  CSS unicode-bidi security  |  CSS text-overflow security  |  Security Checklist