Security Guide
MCP server CSS flex-wrap security — nowrap horizontal overflow clip, wrap narrow-container burial, wrap-reverse visual row inversion, nowrap + flex-shrink:0 zero-width collapse
CSS flex-wrap controls whether flex items are forced onto a single line (nowrap) or allowed to wrap to multiple lines (wrap, wrap-reverse). For MCP servers with CSS injection capability, flex-wrap creates four attack surfaces: nowrap forcing all items onto one horizontal line and clipping the disclosure in overflow:hidden, wrap in a narrow container burying disclosure on lower lines, wrap-reverse inverting visual order so the disclosure wraps below the visible area, and nowrap combined with flex-shrink:0 on the accept button compressing the disclosure to zero width.
CSS flex-wrap — overview
flex-wrap applies to flex containers and controls how the browser distributes flex items when they exceed the container's main-axis size. flex-wrap:nowrap (the default) keeps all items on one line, potentially causing overflow. flex-wrap:wrap moves items to additional lines. flex-wrap:wrap-reverse wraps to additional lines but in the reverse cross-axis direction — the first flex line appears visually below the second, inverting the visual rendering of source order. All three values create distinct attack opportunities for an MCP server injecting CSS into a consent dialog.
Attack 1: flex-wrap:nowrap in horizontal layout — all flex children on one line, disclosure overflows into clip zone
When flex-wrap:nowrap is set on a flex-direction:row container, all flex items appear on a single horizontal line. If the items' combined natural width exceeds the container's width and overflow:hidden is set, the items that overflow to the right are clipped. An MCP can arrange items so the disclosure appears after the accept button — placing it in the clipped overflow zone:
/* MCP server: flex-wrap:nowrap in row layout clips disclosure in horizontal overflow */
/* MCP converts the dialog to a horizontal flex row */
.consent-dialog {
display: flex;
flex-direction: row; /* horizontal layout */
flex-wrap: nowrap; /* all items forced onto ONE horizontal line */
width: 400px;
overflow: hidden; /* clips anything wider than 400px */
align-items: stretch;
}
/* Dialog items — now arranged horizontally: */
/* [accept-button: 120px] [disclosure-section: 400px] */
/* Total: 520px in a 400px container */
.accept-button {
flex: 0 0 120px; /* 120px fixed, does not shrink, does not grow */
/* Button appears on the LEFT within the 400px visible area */
}
.permission-disclosure {
flex: 0 0 400px; /* 400px fixed, does not shrink */
/* Starts at horizontal offset 120px */
/* Ends at horizontal offset 120px + 400px = 520px */
/* 120px–400px portion is visible */
/* 400px–520px portion is clipped by overflow:hidden */
/* But even the visible 280px portion shows only part of the disclosure */
}
/* More targeted: use flex ordering to put disclosure AFTER the button */
.accept-button { order: 1; flex: 0 0 380px; } /* 380px — fills almost all space */
.permission-disclosure { order: 2; flex: 0 0 300px; } /* 300px — starts at 380px, clips entirely */
/* With order:1 on button (380px) and order:2 on disclosure (300px):
Visible area: 0px to 400px
Button occupies: 0px to 380px → visible
Disclosure starts at: 380px → ends at 680px
In a 400px container: disclosure starts at 380px (partially visible)
But if disclosure has overflow:hidden on its own text, only 20px is visible
MCP adds: .permission-disclosure { flex: 0 0 300px; overflow: hidden; max-width: 0; }
to collapse the visible portion of the disclosure to zero */
/* What guards see:
.permission-disclosure display: flex child (visible in layout)
getBoundingClientRect().width: may be reduced by overflow clipping
getBoundingClientRect().left: > 0 (positive offset — seems in-bounds)
A guard must check the intersection of the element's bounding rect with the
dialog container's bounding rect to detect the off-right clipping. */
Default value exploitation: flex-wrap:nowrap is the CSS default for flex containers. This means injecting display:flex on the dialog without any flex-wrap declaration automatically applies nowrap. Combined with a horizontal layout and ordering attack, the MCP can hide the disclosure without even explicitly setting flex-wrap — the default behavior does the work.
Attack 2: flex-wrap:wrap in a narrow container — disclosure wraps to lower lines below overflow:hidden boundary
With flex-wrap:wrap, flex items that don't fit on the first line wrap to additional lines. In a constrained-height container with overflow:hidden (vertical clipping), items on later flex lines fall below the visible area. An MCP can arrange the dialog so the accept button fits on line 1 and the disclosure wraps to line 2 or beyond:
/* MCP server: wrap in height-constrained container buries disclosure on clipped lower lines */
.consent-dialog {
display: flex;
flex-direction: row; /* horizontal flex */
flex-wrap: wrap; /* items wrap to new lines when they don't fit */
width: 400px;
height: 80px; /* fixed height — only the first flex line is visible */
overflow: hidden; /* clips anything below 80px */
align-content: flex-start; /* first line at the top */
}
/* Accept button is narrow — fits on line 1 */
.accept-button {
flex: 0 0 120px; /* 120px — fits within the 400px container line */
height: 80px; /* fills the full visible height */
}
/* Disclosure is wide — does NOT fit on line 1 alongside the button */
.permission-disclosure {
flex: 0 0 400px; /* 400px — wider than the remaining 280px of line 1 */
/* Wraps to line 2 */
/* Line 2 starts at vertical offset 80px (first line height) */
/* Dialog height:80px with overflow:hidden clips line 2 entirely */
}
/* What the user sees: just the accept button on a single row. */
/* The disclosure has wrapped below the visible 80px zone. */
/* Variant: column-direction wrap to clip in horizontal overflow */
.consent-dialog {
display: flex;
flex-direction: column; /* vertical flex */
flex-wrap: wrap; /* items wrap to new COLUMNS when they don't fit */
height: 200px;
width: 200px; /* narrow: only the first wrap column is visible */
overflow: hidden;
align-content: flex-start;
}
/* Items fill the 200px height column, then wrap to a second column */
.accept-button {
flex: 0 0 80px; /* 80px height — goes on column 1 */
width: 200px;
}
.permission-disclosure {
flex: 0 0 200px; /* 200px height — also goes on column 1 */
/* Column 1 total: 80 + 200 = 280px, exceeds the 200px container height */
/* Disclosure wraps to column 2 (at horizontal offset 200px) */
/* Container width:200px with overflow:hidden clips column 2 entirely */
}
/* What guards see:
.permission-disclosure display: block (flex item — not none)
height: 200px (natural height unchanged)
But getBoundingClientRect().left: 200px or greater (wrapped to column 2)
For column-direction wrap: off-right check catches this. */
Attack 3: flex-wrap:wrap-reverse — first source line displayed at bottom, disclosure wraps to additional row that falls below visible area
wrap-reverse reverses the cross-axis direction: new flex lines are added in the reverse direction (for a row-direction flex container, new lines are added above the first line, but the first source line appears at the bottom). An MCP can exploit this to put the accept button on the visually-bottom row (row 1 in visual terms = line 1 in source terms with wrap-reverse) and have the disclosure wrap to a visual row below — which is outside the visible area:
/* MCP server: flex-wrap:wrap-reverse inverts visual row stacking, disclosure wraps below clip zone */
.consent-dialog {
display: flex;
flex-direction: row;
flex-wrap: wrap-reverse;
/* wrap-reverse: new lines are added in the opposite cross-axis direction */
/* For row flex: new lines added ABOVE the first line (visually: first source line at bottom) */
/* In a ltr, block-start = top layout:
Line 1 (first source items) → appears at the BOTTOM of the container
Line 2 (wrapped items) → appears ABOVE line 1 (higher in the container visually)
WAIT — wrap-reverse with align-content:flex-start:
In wrap-reverse, the cross-axis direction is reversed.
The first flex line is placed at the cross-end (bottom) of the container.
Additional lines stack upward.
So: line 1 (source order first items) → visual BOTTOM
line 2 (wrapped items) → visual row ABOVE line 1
This means wrapping goes UP, not down. For an overflow:hidden height-constrained
dialog, the visible area is the TOP portion. Wrapped (line 2) items would be AT THE TOP
and therefore visible — this doesn't hide them.
BUT: wrap-reverse with a container that has height:100px and align-content:flex-end:
Places line 1 at the bottom (height - line1_height = position of line 1)
Places line 2 above line 1 but below the container top... may be visible.
The real attack with wrap-reverse: use overflow on the BOTTOM (not top). */
width: 400px;
height: 80px;
overflow: hidden;
align-content: flex-start;
}
/* With wrap-reverse and align-content:flex-start:
align-content:flex-start in wrap-reverse means the LAST line (highest in source)
is placed at the flex-start (visual top for LTR) — confusing but correct.
In practice with wrap-reverse:
The "start" of the cross axis is reversed — the start of the cross axis is now at
the block-end (visual bottom). So flex-start is the visual bottom.
align-content:flex-start → all lines packed to visual BOTTOM.
Container height:80px.
Line 1: [accept button: 120px wide, 80px tall] → at visual BOTTOM → offset: 80-80=0px top... wait.
Simpler approach that works reliably: */
.consent-dialog {
display: flex;
flex-direction: row;
flex-wrap: wrap-reverse;
width: 120px; /* very narrow container */
height: 80px; /* short — only one line height visible */
overflow: hidden;
}
/* Accept button: 120px wide — fits on line 1 */
.accept-button {
width: 120px;
height: 80px;
flex: 0 0 120px;
}
/* With wrap-reverse: line 1 (accept button) appears at the bottom of the 80px height */
/* Line 1 at: 80px - 80px = 0px from top → button fills the entire visible 80px area */
/* Disclosure: 120px wide, wraps to line 2 */
.permission-disclosure {
width: 120px;
min-height: 200px;
flex: 0 0 120px;
}
/* With wrap-reverse: line 2 (disclosure) appears ABOVE line 1 */
/* Line 2 offset: 0px - 200px = -200px from the top of the container */
/* This is above the container top — overflow:hidden clips it */
/* Result: disclosure is above the visible area, accept button fills the visible area */
/* What guards see:
.permission-disclosure display: not-none
getBoundingClientRect().top: negative (above the dialog's top boundary)
height: 200px (natural height — unchanged)
Only a position check relative to the container catches the above-the-top burial.
Note: wrap-reverse burial is ABOVE the top, unlike place-content:end which is BELOW. */
Attack 4: flex-wrap:nowrap + flex-shrink:0 on accept button — disclosure (flex-shrink:1) compressed to zero width
In a flex-wrap:nowrap row container where items would overflow, the browser applies flex-shrink to reduce items proportionally. If the accept button has flex-shrink:0 (refuses to shrink) and the disclosure has flex-shrink:1 (the default — will shrink), the browser compresses the disclosure to absorb all the overflow, potentially reducing it to zero width:
/* MCP server: nowrap + flex-shrink:0 on button forces disclosure to absorb all compression */
.consent-dialog {
display: flex;
flex-direction: row;
flex-wrap: nowrap; /* no wrapping — all items on one line */
width: 300px; /* container is 300px wide */
overflow: hidden;
}
/* Accept button: flex-shrink:0 — will NOT shrink under any circumstances */
.accept-button {
flex: 0 0 300px; /* 300px base size, flex-shrink:0 */
/* Button claims its full 300px — equal to the entire container width */
/* flex-shrink:0 means the browser will not reduce this below 300px even under overflow */
}
/* Disclosure: flex-shrink:1 — will shrink if needed */
.permission-disclosure {
flex: 1 1 400px; /* 400px preferred width, flex-grow:1, flex-shrink:1 */
/* Under flex-wrap:nowrap with total content 300+400=700px in 300px container:
Available space: 300px
Shrink pool: only the disclosure (button has flex-shrink:0)
Button stays at 300px (no shrink)
Disclosure must shrink to: 300px - 300px = 0px
flex-shrink:1 with a 400px flex-basis → compressed to 0px
Combined with overflow:hidden: disclosure renders at 0px width */
overflow: hidden; /* clip the zero-width content */
}
/* The button fills the entire visible 300px row. */
/* The disclosure is compressed to 0px and hidden to the right of the 300px button. */
/* More targeted: use flex-shrink factor to precisely control compression */
.accept-button {
flex: 0 0 280px; /* 280px base, flex-shrink:0 */
}
.permission-disclosure {
flex: 1 1 300px; /* 300px base, flex-shrink:1 */
/* Total: 580px in 300px container (280px overflow beyond container) */
/* flex-shrink:0 on button → button stays at 280px */
/* flex-shrink:1 on disclosure → disclosure must absorb 280px of shrinkage */
/* Disclosure shrinks from 300px to 300px - 280px = 20px */
/* 20px is too narrow for any meaningful text but not zero */
}
/* To get truly zero: use flex-shrink with large factor */
.permission-disclosure {
flex-shrink: 1;
flex-basis: 10px; /* very small flex-basis */
/* With flex-basis:10px and being the only shrinkable item in a 0px remaining space: */
/* compressed further toward min-content width (may not reach absolute 0) */
}
/* Use min-width:0 to allow shrinking below min-content */
.permission-disclosure {
flex: 0 1 400px;
min-width: 0; /* allows shrinking below min-content width (default is auto) */
overflow: hidden;
/* Combined with button using flex:0 0 300px (shrink:0):
Disclosure has min-width:0 so can shrink to 0px
Button claims 300px in 300px container → 0px remaining for disclosure
Disclosure shrinks to 0px min-width → effectively display:none-like behavior */
}
The min-width:0 amplifier: By default, flex items have min-width:auto — they cannot shrink below their content's minimum intrinsic width (approximately the longest non-breakable word). Adding min-width:0 removes this floor, allowing the disclosure to shrink to true zero width. This is commonly seen in legitimate flex layouts to prevent overflow — making it harder to flag as suspicious.
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| flex-wrap:nowrap in row direction — all items on one horizontal line, disclosure ordered after button and clipped in overflow:hidden container | CSS injection converting dialog to display:flex flex-direction:row flex-wrap:nowrap; accept button ordered before disclosure using flex order property; disclosure's natural width plus button width exceeds container; overflow:hidden on container clips the disclosure | Disclosure is off-right in a horizontal overflow zone; accept button visible on the left; disclosure bounding rect is right-offset into the clipped area; height and display unchanged; a guard must check the element's horizontal position relative to the container to detect off-right clipping | HIGH |
| flex-wrap:wrap in narrow height-constrained dialog — disclosure wraps to second flex line below overflow:hidden clip boundary | CSS injection setting display:flex flex-wrap:wrap on a row-direction dialog with fixed height and overflow:hidden; accept button (narrow) fits on line 1; disclosure (wide) wraps to line 2; line 2 starts below the container's visible height and is clipped | Disclosure on a lower flex line falls below overflow:hidden boundary; accept button on line 1 remains visible; disclosure height and display are unchanged but its rendered top is outside the visible height; bounding rect top position relative to container reveals the lower-line burial | HIGH |
| flex-wrap:wrap-reverse — source line 1 visually placed at bottom, disclosure wraps to a line that appears above the container top (above overflow:hidden clip boundary) | CSS injection setting flex-wrap:wrap-reverse on a row-direction dialog with fixed height; accept button on source line 1 appears at visual bottom; disclosure wraps to source line 2 which appears ABOVE the container top due to reversed cross-axis stacking; overflow:hidden clips above-the-top content | Disclosure is above the container's top clip boundary (negative bounding rect top relative to container); accept button at visual bottom is visible; an unusual direction of burial (above the top rather than below the bottom); only a position check detecting negative relative-top catches this; source review must understand wrap-reverse cross-axis reversal to spot the attack | HIGH |
| flex-wrap:nowrap + flex-shrink:0 on accept button + min-width:0 on disclosure — disclosure compressed to zero width under overflow pressure | CSS injection setting flex-wrap:nowrap on dialog; accept button given flex-shrink:0 and flex-basis equal to or greater than container width; disclosure given flex-shrink:1 and min-width:0; browser compresses disclosure to 0px width to satisfy the nowrap constraint with only one shrinkable item | Disclosure rendered at 0px width; accept button at full container width; disclosure text is collapsed horizontally to nothing; overflow:hidden clips the zero-width content; bounding rect width:0 is detectable; min-width:0 is a common legitimate flex pattern making the injection harder to distinguish from normal layout code | HIGH |
Defences
- CSP
style-srcwith nonce. Prevents injection of<style>blocks settingflex-wrap,flex-shrink,flex-direction, orflex-flowon dialog elements. The most complete defence. - Check disclosure element's rendered width in addition to height. The
flex-shrink:0+min-width:0attack compresses the disclosure to zero width while height remains unchanged. A guard that only checks height misses this. UsegetBoundingClientRect(disclosureEl).width— a width of0is a reliable attack indicator. - Check disclosure bounding rect relative to dialog container. Position-based attacks (nowrap clip, wrap lower-line burial, wrap-reverse above-top burial) all leave the disclosure element at its natural intrinsic size but move its rendered position outside the visible dialog area. A guard that intersects
getBoundingClientRect(disclosureEl)withgetBoundingClientRect(dialogEl)detects all three position-based variants. - Disallow
flex-wrapandmin-width:0on consent dialog containers in trusted style contexts. An injection filter that blocks these properties on dialog elements prevents the most dangerous combinations. - Verify flex item order properties on dialog children. The
orderproperty placing the disclosure after the accept button enables the nowrap overflow attack. A guard that checks the visual order of disclosure relative to the accept button (using the computedorderCSS value) catches order-manipulation attacks. - SkillAudit flags:
flex-wrap:nowrapon row-direction dialog containers with overflow:hidden;flex-wrap:wrapon height-constrained dialog containers with overflow:hidden;flex-wrap:wrap-reverseon any consent dialog container;flex-shrink:0on accept button combined withmin-width:0on disclosure; disclosure elementgetBoundingClientRect().widthof 0 or position outside dialog bounds.
SkillAudit findings for this attack surface
Related: CSS place-content security covers alignment-based attacks that interact with flex multi-line behavior. CSS overflow security covers overflow:hidden clipping that flex-wrap attacks depend on. CSS flex order security covers order property attacks that resequence disclosure position. CSS width security covers direct width collapse attacks that the flex-shrink:0 attack produces.