MCP server CSS overflow-block and overflow-inline security: logical overflow axis swap, writing-mode hidden clipping, directional scroll trap, and combined logical overflow attacks
Published 2026-07-24 — SkillAudit Research
CSS Logical Properties (CSS Logical Properties and Values Level 1) introduced flow-relative equivalents for physical CSS properties. Instead of overflow-x (horizontal clipping) and overflow-y (vertical clipping), authors can write overflow-inline and overflow-block — which map to physical axes based on the element's writing mode.
In the default horizontal left-to-right writing mode (writing-mode: horizontal-tb), the mapping is straightforward: overflow-block = overflow-y (block axis = vertical), overflow-inline = overflow-x (inline axis = horizontal). But under writing-mode: vertical-lr or writing-mode: vertical-rl, the axes swap: overflow-block becomes the horizontal axis and overflow-inline becomes the vertical axis.
MCP server consent-disclosure attacks exploit this mapping ambiguity. Scanners that look for overflow-y: hidden or overflow-x: hidden do not match overflow-block: hidden or overflow-inline: hidden — even though the computed effect is identical. This article documents four attacks using these logical properties.
Browser support: overflow-block and overflow-inline are supported in Chrome 87+, Firefox 69+, and Safari 15+. On older browsers, these declarations are ignored (the overflow falls back to the browser default, usually visible — which is also an attack path if the fallback is the only visible form of the disclosure).
Attack 1: overflow-block: hidden — vertical clipping bypass in horizontal writing mode
In the default writing-mode: horizontal-tb, overflow-block: hidden is exactly equivalent to overflow-y: hidden. An MCP server can use the logical property to clip tall consent disclosures while bypassing scanners that only check for overflow-y: hidden:
/* Attack 1: overflow-block: hidden on a height-constrained consent disclosure */
/* Direct (detected by scanners checking overflow-y): */
.consent-container {
height: 20px;
overflow-y: hidden; /* scanner matches this */
}
/* Logical property bypass: */
.consent-container {
height: 20px;
overflow-block: hidden; /* same computed effect — clips vertical overflow */
/* overflow-block in horizontal-tb = overflow-y.
A scanner matching /overflow-y\s*:\s*hidden/ does NOT match this.
A scanner matching /overflow:\s*hidden/ does NOT match this
(shorthand 'overflow' expands to overflow-x and overflow-y, not the logical
properties — the two property namespaces are independent in the CSSOM). */
}
/* Combined with a height that seems to allow content: */
.consent-container {
/* Appears to allow one line of 16px text: */
height: 18px;
line-height: 16px;
overflow-block: hidden;
/* The disclosure text contains 4 paragraphs. Only the first ~18px is visible.
The rest of the content is clipped by overflow-block: hidden.
getComputedStyle(el).overflowY returns 'hidden' (the browser resolves the
logical property to its physical equivalent in computed style).
BUT: CSS scanners reading the stylesheet source text do not see 'overflow-y'. */
}
// Detection: check computed overflow values (physical axes)
function detectLogicalOverflowClipping(el) {
const cs = window.getComputedStyle(el);
// getComputedStyle always returns the physical properties — logical properties
// are always resolved to their physical equivalents in computed style.
const overflowY = cs.overflowY;
const overflowX = cs.overflowX;
// Check for height-constrained hidden overflow clipping
const height = parseFloat(cs.height);
const scrollHeight = el.scrollHeight;
if ((overflowY === 'hidden' || overflowY === 'clip') && scrollHeight > height * 1.5) {
console.error('SECURITY: consent disclosure is clipped by overflow-block/overflow-y: hidden', {
element: el,
computedOverflowY: overflowY,
visibleHeight: height,
totalScrollHeight: scrollHeight,
hiddenFraction: ((scrollHeight - height) / scrollHeight).toFixed(2)
});
return true;
}
return false;
}
Attack 2: writing-mode: vertical-lr + overflow-block: hidden — axis swap clipping
Under a vertical writing mode, the block axis is horizontal. This means overflow-block: hidden clips horizontal overflow while appearing to be a vertical-axis property to a scanner that reads the name "block" as "vertical." MCP servers exploit this to clip wide consent disclosures in horizontally-scrollable contexts:
/* Attack 2: writing-mode swap — overflow-block clips horizontal axis */
/* Under writing-mode: vertical-lr:
- block axis = horizontal (left-to-right direction of the block flow)
- inline axis = vertical (direction of text lines within a block)
Logical property mapping flips:
- overflow-block (block axis) → clips HORIZONTAL overflow
- overflow-inline (inline axis) → clips VERTICAL overflow */
.consent-wrapper {
writing-mode: vertical-lr; /* established on the ancestor */
}
.consent-disclosure {
/* Under vertical-lr writing mode: */
overflow-block: hidden; /* clips HORIZONTAL (block direction in vertical-lr) */
overflow-inline: scroll; /* allows VERTICAL scroll (inline direction) */
/* The consent disclosure text, which is in English horizontal text,
appears as a single rotated column in vertical writing mode.
The user can scroll vertically (overflow-inline: scroll) but cannot
see content that extends horizontally (overflow-block: hidden).
English text is designed to be read left-to-right in horizontal lines —
in vertical-lr mode, the text is rotated 90°, making it extremely difficult
to read even when visible. The disclosure is technically present but
practically illegible. */
}
/* Even subtler: reset writing mode on the disclosure but inherit overflow */
.consent-wrapper {
writing-mode: vertical-lr;
overflow-block: hidden; /* clips horizontal = the layout's natural block direction */
}
.consent-disclosure {
writing-mode: horizontal-tb; /* text reads normally left-to-right */
/* But overflow-block: hidden inherited from parent clips the block axis
of the parent (horizontal), constraining the horizontal space of this disclosure. */
}
/* Detection must account for the writing-mode context: */
function detectAxisSwapOverflowClipping(el) {
const cs = window.getComputedStyle(el);
const writingMode = cs.writingMode;
const overflowY = cs.overflowY;
const overflowX = cs.overflowX;
if ((writingMode === 'vertical-lr' || writingMode === 'vertical-rl') &&
(overflowX === 'hidden' || overflowX === 'clip')) {
// In vertical writing mode, overflow-block corresponds to overflow-x
if (el.scrollWidth > parseFloat(cs.width) * 1.2) {
console.error('SECURITY: vertical writing-mode with overflow-block: hidden clips disclosure width', {
element: el,
writingMode,
computedOverflowX: overflowX,
scrollWidth: el.scrollWidth,
clientWidth: parseFloat(cs.width)
});
return true;
}
}
return false;
}
Attack 3: overflow-inline: hidden on a width-constrained container — horizontal scroll trap
In horizontal writing mode, overflow-inline: hidden clips horizontal overflow, identical to overflow-x: hidden. When combined with a narrow container width, it creates a single-line viewport that clips a multi-line disclosure to one line of text:
/* Attack 3: overflow-inline: hidden + narrow container = single-line viewport */
/* In horizontal-tb writing mode:
overflow-inline = overflow-x (inline axis = horizontal direction of text) */
.consent-container {
/* Constrain to single-line width: */
width: 40px; /* very narrow — only a few characters visible */
white-space: nowrap; /* prevent text from wrapping to next line */
overflow-inline: hidden; /* clips horizontal overflow = overflow-x: hidden */
/* Effect: the disclosure text ('By clicking OK you authorize...') renders on a
single line 40px wide, then clips. Only the first ~6 characters are visible.
The user sees 'By cli...' — not the full disclosure.
Scanner looking for overflow-x: hidden → no match.
Scanner looking for width: 40px → matches but width alone isn't flagged. */
}
/* Variant: make the constraint responsive so it's harder to detect at fixed viewport: */
.consent-container {
max-width: 10%; /* 10% of parent = very narrow in most UI contexts */
overflow-inline: hidden;
white-space: nowrap;
/* At 400px container: max-width = 40px — same attack as above.
At 1200px container: max-width = 120px — wider but still clips.
The % value bypasses scanners checking for literal small px values. */
}
/* Combined with text-overflow: ellipsis to make truncation look "normal": */
.consent-container {
width: 60px;
overflow-inline: hidden;
white-space: nowrap;
text-overflow: ellipsis;
/* The user sees 'By cl...' — the ellipsis makes it look like a UI truncation
pattern, not an attack. This is a social-engineering amplifier. */
}
// Detection: check for narrow containers with overflow-inline (computed overflow-x) hidden
function detectInlineOverflowNarrowContainer(el) {
const cs = window.getComputedStyle(el);
const overflowX = cs.overflowX; // always resolves logical to physical
const width = parseFloat(cs.width);
if ((overflowX === 'hidden' || overflowX === 'clip') &&
width < 100 && el.textContent.trim().length > 20) {
console.error('SECURITY: consent disclosure clipped by narrow container + overflow-inline: hidden', {
element: el,
computedOverflowX: overflowX,
containerWidth: width,
textContentLength: el.textContent.trim().length,
// First 30 chars are visible; rest is clipped
visibleText: el.textContent.trim().substring(0, Math.floor(width / 8))
});
return true;
}
return false;
}
Attack 4: overflow-block: scroll + overflow-inline: hidden — directional scroll trap
When an MCP server sets overflow-block: scroll (vertical scroll) and overflow-inline: hidden (horizontal clip) on a height-constrained container, it creates a deceptive scrollable region. The user can scroll vertically, but the container is short enough that the disclosure text appears to fit without scrolling — in reality the remaining content is below the visible fold in the short container:
/* Attack 4: overflow-block: scroll + overflow-inline: hidden = scroll trap */
/* In horizontal writing mode:
overflow-block = overflow-y (block axis = vertical)
overflow-inline = overflow-x (inline axis = horizontal) */
.consent-container {
height: 40px; /* tall enough for ~2 lines — seems reasonable */
overflow-block: scroll; /* vertical scrollbar appears if overflow exists */
overflow-inline: hidden; /* clips horizontal overflow */
/* The disclosure text is 8 paragraphs = ~200px of content at 14px/1.5 line-height.
The container shows 2 lines. The vertical scrollbar appears, suggesting the user
should scroll — but in a rapid UI flow (popup, install dialog), users rarely
scroll consent text in small boxes. 160px of disclosure is hidden below.
Scanner matching 'overflow-y: scroll' + 'height: 40px' → no match.
This combined logical property form bypasses both checks. */
}
/* Paired with max-height to look like a normal scrollable box: */
.consent-container {
max-height: 3em; /* 3 lines — seems reasonable for a "summary" */
overflow-block: scroll; /* "scroll if needed" appears innocent */
overflow-inline: hidden; /* technical bypass */
font-size: 13px;
line-height: 1.5; /* 3em × 13px × 1.5 = 58.5px visible height */
/* Actual content: 500 words of permissions.
User must scroll 8× the visible height to read all content.
Most users do not — the first 3 lines are visible bait-and-switch. */
}
// Detection: scrollable-but-clipped consent container
function detectScrollTrapContainer(el) {
const cs = window.getComputedStyle(el);
const overflowY = cs.overflowY;
const height = parseFloat(cs.height);
const scrollHeight = el.scrollHeight;
// A scrollable disclosure container that requires scrolling more than 2× its height
// to see all content is a scroll trap — consent cannot be obtained this way.
if ((overflowY === 'scroll' || overflowY === 'auto') &&
scrollHeight > height * 2 &&
el.textContent.trim().length > 50) {
console.error('SECURITY: consent disclosure in scroll trap — user must scroll to see full content', {
element: el,
computedOverflowY: overflowY,
visibleHeight: height,
totalScrollHeight: scrollHeight,
scrollRatio: (scrollHeight / height).toFixed(1),
// Check if user has actually scrolled
currentScrollTop: el.scrollTop,
percentSeen: ((el.scrollTop + height) / scrollHeight * 100).toFixed(0) + '%'
});
return true;
}
return false;
}
Why logical overflow properties evade CSS scanners: CSS security scanners that check for disclosure-hiding patterns typically build pattern lists from physical property names: overflow-x: hidden, overflow-y: hidden, overflow: hidden. The logical properties overflow-block and overflow-inline produce identical computed effects but do not match these patterns. Critically, the physical overflow properties in getComputedStyle() always resolve to their computed values — so any runtime detection check using getComputedStyle will correctly identify the physical overflow and catch the attack. Only static CSS source text scanners are blind to the logical-to-physical mapping.
Attack summary
| Attack | CSS property used | Physical equivalent | Effect | Severity |
|---|---|---|---|---|
| Vertical clip via overflow-block | overflow-block: hidden |
overflow-y: hidden |
Clips multi-paragraph disclosure to one line of visible height | High |
| Axis swap via writing-mode + overflow-block | writing-mode: vertical-lr; overflow-block: hidden |
overflow-x: hidden |
Block axis is horizontal in vertical mode — clips horizontal width | High |
| Narrow container via overflow-inline | overflow-inline: hidden; width: 40px; white-space: nowrap |
overflow-x: hidden |
Single-line clipping to a few visible characters | High |
| Scroll trap via overflow-block: scroll | overflow-block: scroll; height: 40px |
overflow-y: scroll |
Scrollable but too short to reveal full consent without explicit user scroll | Medium |
Consolidated finding blocks
overflow-block: hidden on a height-constrained container to clip vertical overflow, bypassing scanners that look only for overflow-y: hidden. Computed effect is identical — multi-paragraph disclosure truncated to visible-height slice. Detected via getComputedStyle(el).overflowY === 'hidden' && el.scrollHeight > el.clientHeight * 1.5.
writing-mode: vertical-lr to flip the block axis to horizontal, then uses overflow-block: hidden to clip horizontal overflow — bypassing scanners that read "block" as vertical. Detected by checking for vertical writing mode when getComputedStyle(el).overflowX === 'hidden'.
overflow-inline: hidden with a narrow width and white-space: nowrap to create a single-character-line visible area, clipping all but the first few characters of the disclosure. Detected by checking container width (<100px) with non-empty text content and getComputedStyle(el).overflowX === 'hidden'.
overflow-block: scroll (vertical scroll) on a very short container (height or max-height <60px) containing hundreds of words of consent text. User must scroll multiple times to see full disclosure. Detected by checking el.scrollHeight > el.clientHeight * 2 with scroll position at top.