MCP server CSS text-wrap-mode security: nowrap single-line overflow clip, flex-shrink container compress, RTL direction horizontal clip, and JS mousedown text-wrap-mode injection
Published 2026-08-07 — SkillAudit Research
The CSS text-wrap-mode property (CSS Text Module Level 4) is a sub-property of the text-wrap shorthand that controls whether text wraps to new lines: wrap (default — text wraps) or nowrap (text does not wrap and overflows the line box). It is distinct from the older white-space: nowrap declaration and from the text-wrap shorthand itself (text-wrap: nowrap is the shorthand; text-wrap-mode: nowrap is the sub-property). This distinction creates scanner evasion opportunities: a tool that checks getComputedStyle(el).whiteSpace for the value 'nowrap' will receive 'normal' when only text-wrap-mode is set, because white-space is a separate property in a separate CSS module.
When text-wrap-mode: nowrap is applied to a consent element inside a narrow container with overflow: hidden, the consent text extends as a single horizontal line past the container boundary. Only the first characters of the consent are visible; the critical "I agree to grant permissions" clause is off-screen. The element's offsetHeight is a single line height, offsetWidth matches the container, and all display/visibility checks pass — but scrollWidth is much larger than offsetWidth. See also CSS white-space attacks, CSS text-wrap:nowrap attacks, and CSS overflow-x attacks.
text-wrap-mode vs white-space vs text-wrap shorthand: white-space: nowrap is the legacy property (CSS 2.1). text-wrap: nowrap is a CSS Text Level 4 shorthand. text-wrap-mode: nowrap is the CSS Text Level 4 long-hand sub-property, settable independently. In Blink/WebKit: setting text-wrap-mode: nowrap alone leaves white-space at 'normal'. A scanner checking getComputedStyle(el).whiteSpace === 'nowrap' will miss the text-wrap-mode attack. The correct check is getComputedStyle(el).textWrapMode === 'nowrap' or el.scrollWidth > el.offsetWidth as a geometry fallback.
Attack 1: text-wrap-mode:nowrap in overflow:hidden container — consent single-line extends off right edge (SA-CSS-TWMD-001)
The consent element sets text-wrap-mode: nowrap. Its parent container has width: 180px; overflow: hidden. The consent text — "By clicking Install you agree to grant this MCP server full filesystem access and permission to execute arbitrary commands." — would normally wrap to 5-6 lines in a 180px container. With text-wrap-mode: nowrap, the text extends as a single line of approximately 750px. The container clips everything past 180px. Visible text: "By clicking Install you" (approximately). Hidden: the critical "agree to grant... execute arbitrary commands" clause. The element's offsetHeight is 20px (single line); its scrollWidth is 750px while offsetWidth is 180px. Scanners checking white-space see 'normal' — the attack evades the check.
/* MCP attack: */
.consent-container { width: 180px; overflow: hidden; }
.consent-disclosure {
text-wrap-mode: nowrap; /* CSS Text Level 4 sub-property */
/* getComputedStyle(el).whiteSpace → 'normal' ← scanner check evaded */
/* getComputedStyle(el).textWrapMode → 'nowrap' ← correct check */
/* el.scrollWidth → 750px; el.offsetWidth → 180px */
/* Visible: first ~22 characters; hidden: remainder including permissions clause */
}
// Detection:
function detectTextWrapModeNowrap(el) {
const cs = window.getComputedStyle(el);
// Primary check: text-wrap-mode sub-property (CSS Text Level 4)
const textWrapMode = cs.textWrapMode || cs.getPropertyValue('text-wrap-mode');
if (textWrapMode === 'nowrap') {
const parent = el.parentElement;
const parentCS = parent ? window.getComputedStyle(parent) : null;
const hasOverflowClip = parentCS && ['hidden', 'clip'].includes(parentCS.overflow);
if (hasOverflowClip && el.scrollWidth > el.offsetWidth * 1.2) {
console.error('SA-CSS-TWMD-001: text-wrap-mode:nowrap + overflow:hidden clips consent text', {
el,
textWrapMode,
whiteSpace: cs.whiteSpace, // 'normal' — scanner evasion evidence
scrollWidth: el.scrollWidth,
offsetWidth: el.offsetWidth
});
}
}
// Fallback: geometry check catches all no-wrap variants
if (el.scrollWidth > el.offsetWidth * 1.5) {
const parent = el.parentElement;
const parentCS = parent ? window.getComputedStyle(parent) : null;
if (parentCS && ['hidden', 'clip'].includes(parentCS.overflow)) {
console.warn('SA-CSS-TWMD-001 (geometry): consent scrollWidth >> offsetWidth with overflow:hidden', {
el, scrollWidth: el.scrollWidth, offsetWidth: el.offsetWidth
});
}
}
}
Attack 2: text-wrap-mode:nowrap in flex child with flex-shrink — container compressed to minimum width (SA-CSS-TWMD-002)
The consent element sets text-wrap-mode: nowrap and is a flex child with flex-shrink: 1; min-width: 0. Its sibling install button has flex-shrink: 0. The flex container has a fixed width of 220px with overflow: hidden. Because the nowrap content creates a very wide minimum content size (the single-line text length), but min-width: 0 allows the flex child to shrink below its content size, the flex algorithm compresses the consent child to fill the remaining space after the fixed-width install button. The compressed consent container is then 100px wide; the nowrap text extends past the container. The install button is never compressed. Key detection: min-width: 0 on a nowrap flex child.
/* MCP attack: */
.install-dialog {
display: flex;
width: 220px;
overflow: hidden;
}
.install-btn {
flex-shrink: 0; /* never compressed */
width: 80px;
}
.consent-disclosure {
flex-shrink: 1;
min-width: 0; /* allows shrink below content size */
text-wrap-mode: nowrap; /* creates single long line */
overflow: hidden; /* clips the nowrap content within shrunken flex child */
/* flex child compressed to 140px; nowrap text extends 700px; 560px clipped */
}
// Detection — flex child with text-wrap-mode:nowrap + min-width:0:
function detectFlexNowrapShrink(el) {
const cs = window.getComputedStyle(el);
const textWrapMode = cs.textWrapMode || cs.getPropertyValue('text-wrap-mode');
if (textWrapMode !== 'nowrap') return;
const parent = el.parentElement;
if (!parent) return;
const parentCS = window.getComputedStyle(parent);
if (!['flex', 'inline-flex'].includes(parentCS.display)) return;
const minWidth = parseFloat(cs.minWidth) || 0;
const flexShrink = parseFloat(cs.flexShrink) || 0;
if (flexShrink > 0 && minWidth === 0 && el.scrollWidth > el.offsetWidth * 1.2) {
console.error('SA-CSS-TWMD-002: text-wrap-mode:nowrap + flex-shrink + min-width:0 — consent compressed and clipped', {
el, textWrapMode, flexShrink, minWidth,
scrollWidth: el.scrollWidth, offsetWidth: el.offsetWidth
});
}
}
Attack 3: text-wrap-mode:nowrap + direction:rtl + overflow-x:hidden — consent extends off left edge (SA-CSS-TWMD-003)
The consent element sets text-wrap-mode: nowrap and direction: rtl. In RTL layout, text flows from right to left — the first character of the consent is at the right edge, and the text extends leftward. With the parent's overflow-x: hidden, content extending past the left edge of the container is clipped. The visible portion of the consent is the rightward segment — typically the end of the consent text ("...and execute arbitrary commands."), which appears as boilerplate. The beginning of the consent ("By clicking Install, you agree to...") extends leftward past the container boundary and is clipped. This is the RTL variant of the text-indent horizontal clip: the nowrap text extends in the opposite direction, and the critical clause is clipped on the other side.
/* MCP attack: */
.consent-container {
width: 180px;
overflow-x: hidden; /* clips left overflow in RTL layout */
}
.consent-disclosure {
text-wrap-mode: nowrap;
direction: rtl; /* text flows right-to-left; start is at right edge */
/* Visible: rightmost ~22 chars = "...execute commands."
Hidden (left of container): "By clicking Install, you agree to grant..." */
}
// Detection:
function detectRTLNowrapLeftClip(el) {
const cs = window.getComputedStyle(el);
const textWrapMode = cs.textWrapMode || cs.getPropertyValue('text-wrap-mode');
const direction = cs.direction;
if (textWrapMode === 'nowrap' && direction === 'rtl') {
const parent = el.parentElement;
const parentCS = parent ? window.getComputedStyle(parent) : null;
if (!parentCS) return;
const overflowX = parentCS.overflowX;
if (!['hidden', 'clip'].includes(overflowX)) return;
// In RTL, left edge clipping hides the START of the text
const consentBCR = el.getBoundingClientRect();
const parentBCR = parent.getBoundingClientRect();
if (el.scrollWidth > el.offsetWidth) {
console.error('SA-CSS-TWMD-003: text-wrap-mode:nowrap + direction:rtl + overflow-x:hidden — consent start clipped on left', {
el, textWrapMode, direction, overflowX,
scrollWidth: el.scrollWidth, offsetWidth: el.offsetWidth
});
}
}
}
Attack 4: JS mousedown sets text-wrap-mode:nowrap — consent collapses to single line at install click (SA-CSS-TWMD-004)
At page load, the consent text wraps normally across 4-5 lines in its container. The consent is fully readable. When the user presses the install button (mousedown), JS sets el.style.setProperty('text-wrap-mode', 'nowrap') on the consent element. The text instantly collapses from 4-5 lines to a single line that extends past the parent's overflow boundary. The height drops from ~80px to ~20px, making the consent appear to have "confirmed" (visually collapsed). The browser registers the click and confirms the install. MutationObserver on the consent element detects the style attribute change; the geometry check (scrollWidth vs offsetWidth increase) confirms text is now being clipped. Because text-wrap-mode is set via setProperty, scanners watching for whiteSpace changes will not fire.
// MCP JS — fires at mousedown:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const consent = document.querySelector('.consent-disclosure');
if (consent) {
// Using setProperty for text-wrap-mode (sub-property)
consent.style.setProperty('text-wrap-mode', 'nowrap');
/* Consent instantly collapses from 4 lines to 1 line
Height: 80px → 20px (looks like "step confirmed" collapse)
Text extends ~700px to the right; parent overflow:hidden clips it
getComputedStyle(..).whiteSpace still returns 'normal' */
}
}, { capture: true });
// Detection:
function detectDynamicTextWrapMode(consentEl) {
const parent = consentEl.parentElement;
new MutationObserver(() => {
const cs = window.getComputedStyle(consentEl);
const textWrapMode = cs.textWrapMode || cs.getPropertyValue('text-wrap-mode');
if (textWrapMode === 'nowrap') {
requestAnimationFrame(() => {
if (consentEl.scrollWidth > consentEl.offsetWidth * 1.2) {
const parentCS = parent ? window.getComputedStyle(parent) : null;
console.error('SA-CSS-TWMD-004: JS injected text-wrap-mode:nowrap at install click — consent clipped', {
consentEl,
textWrapMode,
whiteSpace: cs.whiteSpace, // 'normal' — no alert from whitespace scanner
scrollWidth: consentEl.scrollWidth,
offsetWidth: consentEl.offsetWidth,
parentOverflow: parentCS?.overflow
});
}
});
}
}).observe(consentEl, { attributes: true, attributeFilter: ['style'] });
document.querySelector('#install-btn, [data-action="install"]')
?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
}
Root detection method: Check getComputedStyle(el).textWrapMode (the CSS Text Level 4 sub-property) independently from getComputedStyle(el).whiteSpace. These are separate properties. If textWrapMode is not supported in the scanning environment, fall back to geometry: el.scrollWidth > el.offsetWidth * 1.2 with parent overflow: hidden flags any no-wrap attack regardless of which property caused it. Check direction on RTL elements for the left-side clip variant. SkillAudit checks all three no-wrap properties — whiteSpace, textWrap, and textWrapMode — plus the scrollWidth geometry fallback on every consent element.
Attack summary
| ID | Technique | white-space check | textWrapMode check | scrollWidth check | Severity |
|---|---|---|---|---|---|
| SA-CSS-TWMD-001 | text-wrap-mode:nowrap + overflow:hidden — right-edge clip | normal (evades) | nowrap (reveals) | required | High |
| SA-CSS-TWMD-002 | text-wrap-mode:nowrap + flex-shrink + min-width:0 | normal (evades) | nowrap (reveals) | required | High |
| SA-CSS-TWMD-003 | text-wrap-mode:nowrap + direction:rtl — left-edge clip | normal (evades) | nowrap (reveals) | required | High |
| SA-CSS-TWMD-004 | JS mousedown sets text-wrap-mode:nowrap — dynamic collapse | normal (evades) | nowrap (reveals) | required (dynamic) | High |
Consolidated findings
See also: CSS white-space:nowrap attacks | CSS text-wrap:nowrap attacks | CSS overflow-x horizontal clip attacks | CSS direction:rtl attacks | SkillAudit — free MCP server audit