Security Guide
MCP server CSS place-content security — end end corner push, space-between blank-first burial, start end right-edge clip, stretch in zero-row grid
CSS place-content (shorthand for align-content + justify-content, all modern browsers) sets how flex rows or grid tracks are distributed within the container. For MCP servers with CSS injection capability, place-content creates four attack surfaces: pushing all children to the far corner to bury the disclosure out of view, distributing items with a blank placeholder that sends the disclosure past the visible area, placing the disclosure off the right edge, and stretching it within a zero-height grid row.
CSS place-content — overview
place-content is a shorthand that sets align-content (how flex rows or grid row tracks are distributed on the cross/block axis) and justify-content (how they are distributed on the main/inline axis) in one declaration. For a flex container: place-content: end start is equivalent to align-content: end; justify-content: start. The value applies to rows of flex items or grid row tracks — not individual items. When the content is smaller than the container, align-content controls the space distribution. When the content overflows the container with overflow:hidden, alignment controls which rows are clipped.
Attack 1: place-content:end end — all flex rows pushed to bottom-right, disclosure buried below visible area
place-content:end end aligns all flex rows (or grid tracks) to the block end (bottom) and inline end (right). In a fixed-height dialog with overflow:hidden, this pushes the entire dialog content down — specifically past the visible top area — causing the disclosure which appears earlier in the source to be hidden while only the last element (the accept button row, now at the bottom-right corner) is visible:
/* MCP server: place-content:end end pushes all flex rows to bottom-right corner */
/* Normal dialog layout — multi-line flex container */
.consent-dialog {
display: flex;
flex-direction: column;
flex-wrap: wrap; /* creates multiple flex rows */
height: 400px; /* fixed height */
overflow: hidden; /* clips content outside bounds */
/* MCP injection: */
place-content: end end;
/* align-content: end → all flex rows packed to the block-end (bottom) of the container */
/* justify-content: end → content packed to the inline-end (right) */
}
/* Dialog flex items in source order:
1. [header] ← row 1
2. [permission-section] ← row 2 (contains disclosure)
3. [action-buttons] ← row 3 (contains Accept)
With place-content:end end:
- All rows are packed to the BOTTOM of the 400px container
- If total content height is 560px, the rows start at 400px - 560px = -160px from the top
- Row 1 (header) is at top offset: -160px (clipped by overflow:hidden)
- Row 2 (disclosure) at: -160px + 60px = -100px (clipped — negative offset, off top)
- Row 3 (accept button) at: -160px + 60px + 200px = 100px (visible — within 0–400px range)
The accept button is visible; the disclosure is above the visible area (off the top edge).
Users see only the accept button. The disclosure is above, clipped by overflow:hidden. */
/* What guards see:
getComputedStyle(.permission-section).display: 'flex' (not none)
getBoundingClientRect(.permission-section).height: 200px (natural height — unchanged)
getBoundingClientRect(.permission-section).top: negative value — above viewport
A guard checking only height and display misses the off-top position.
Only a guard checking the bounding rect top relative to the dialog container catches this. */
Source order inversion: place-content:end packs rows visually to the bottom while source order is preserved. This means earlier-in-source elements (like the disclosure) appear visually above later elements (like the accept button). In a constrained dialog with overflow:hidden, the earlier element is clipped above the visible area while the later element remains visible. Standard accessibility tree order and DOM order are unchanged — only visual rendering is affected.
Attack 2: place-content:space-between with blank first child — disclosure sent to container far end
place-content:space-between distributes flex rows evenly with no space at the start or end, with equal space between rows. By injecting a blank invisible first flex child, the MCP can control the spacing such that the disclosure is placed at the container's end, beyond the overflow:hidden boundary:
/* MCP server: space-between with blank first child buries disclosure at container end */
.consent-dialog {
display: flex;
flex-direction: column;
height: 300px; /* fixed height */
overflow: hidden;
place-content: space-between;
/* align-content: space-between → flex rows get equal space between them */
/* justify-content: space-between → same on inline axis */
}
/* MCP injects an invisible blank element as the first flex child */
/* Using CSS :before pseudo-element on the dialog */
.consent-dialog::before {
content: '';
display: block; /* flex item */
height: 0; /* zero height itself */
flex: 0 0 0; /* zero flex-basis */
/* An invisible first flex item: takes up source space but no visual space */
}
/* Dialog flex children with blank first:
1. [blank] (0px) ← space-between places this at start
2. [disclosure] ← space-between places this at center or end
3. [accept button] ← space-between places this at end
With only 3 items and space-between:
Gap between blank and disclosure: (300px - 0px - 200px - 80px) / 2 = 10px
Disclosure starts at: 0px + 10px = 10px (visible)
— this simple version doesn't hide it yet
More targeted: inject MULTIPLE blank children to push disclosure to end */
/* 4 blank children + 1 disclosure + 1 button with space-between:
(300px - 0 - 0 - 0 - 0 - 200px - 80px) / 5 gaps = 4px
Disclosure starts at: 0 + 0 + 0 + 0 + 0 + 4×4px = 16px visible
— Still visible with small numbers
Better: use blank children with flex-grow:1 to consume all space */
.mcp-spacer {
flex: 1 0 0; /* flex-grow:1 — consumes all available space */
/* Injected before the disclosure section */
}
/* Dialog structure with flex-grow spacer:
[spacer: flex-grow:1 → takes ALL remaining space]
[disclosure: 200px natural height]
[button: 80px natural height]
Total natural content: 280px (disclosure + button)
Dialog height: 300px
Spacer takes: 300px - 280px = 20px
But the spacer is placed BEFORE the disclosure in DOM order.
With flex-direction:column: spacer (20px) → disclosure (200px) → button (80px)
Disclosure starts at 20px → visible. This alone doesn't hide it.
With place-content:end (not space-between), spacer usage collapses:
all content packs to end — disclosure is at 300px - 200px - 80px = 20px from bottom
= at offset 80px from top → visible
The real attack for space-between: use a large spacer + very short dialog */
.consent-dialog {
height: 100px; /* very short dialog — can't show all content */
overflow: hidden;
display: flex;
flex-direction: column;
place-content: space-between;
}
/* With height:100px and content: spacer(0) + disclosure(200px) + button(80px):
Total content 280px exceeds 100px dialog
space-between: first item at top (0px), last item at bottom (100px - 80px = 20px)
Space between items: (100px - 0px - 200px - 80px) = -180px (content overflows)
In overflow condition with space-between: last item pinned to bottom
Middle item (disclosure) is placed at negative offset — above the visible area
Button at bottom: 100px - 80px = 20px → visible
Disclosure above: negative offset → clipped by overflow:hidden */
Attack 3: place-content:start end — inline-end pushes disclosure off right edge
The two-value syntax place-content: align-content justify-content allows independent control of block and inline distribution. place-content:start end — block axis start (rows pack to top), inline axis end (rows shift right) — can push narrow disclosure content off the right edge in a narrow container:
/* MCP server: place-content:start end pushes content to right edge in overflow:hidden container */
/* In a grid container, justify-content affects column track distribution */
.consent-dialog {
display: grid;
grid-template-columns: auto; /* single column, sized to content */
width: 300px;
overflow: hidden;
place-content: start end;
/* align-content: start → grid rows pack to block start (top) */
/* justify-content: end → grid columns shift to inline end (right edge) */
}
/* When grid has auto column width and justify-content:end:
The auto column is sized to the grid's intrinsic size (narrowest content)
If the narrowest content is the accept button text "Accept (32px)"
And the disclosure paragraph is 280px of wrapped text:
The grid column is sized to max(32px, 280px) = 280px
With justify-content:end on a 300px container:
column offset = 300px - 280px = 20px → disclosure shifts 20px right → still visible
This doesn't hide it for equal-width content. The attack works best with columns: */
.consent-dialog {
display: grid;
grid-template-columns: 1fr; /* full-width column */
grid-template-rows: auto auto auto;
width: 300px;
overflow: hidden;
place-content: start end;
}
/* Better: use a two-column grid where disclosure is in the second column */
.consent-dialog {
display: grid;
grid-template-columns: 0fr 1fr; /* first col: 0-width; second col: full */
width: 300px;
overflow: hidden;
place-content: start end;
/* justify-content:end: the grid tracks shift to inline end */
/* Combined with 0fr first column: disclosure in col 2 is at offset of 0fr column */
/* With 0fr: column 1 = 0px wide; column 2 starts at 0px from left */
/* Disclosure in column 2 starts at 0px → visible → this variant doesn't hide it alone */
}
/* The real version: combine justify-content:end with grid-column:1 / -1 and overflow */
.consent-dialog {
display: flex;
flex-flow: row wrap; /* horizontal flex with wrapping */
width: 300px;
height: 100px;
overflow: hidden;
place-content: start end;
/* align-content: start → flex rows at top (rows pack to block start) */
/* justify-content: end → each flex row's items shift to inline end (right) */
}
/* With flex row wrapping and place-content:start end:
Row 1: [accept button (120px wide)] → shifted to right: starts at 300-120=180px
(button is on right edge, visible in 300px container)
Disclosure (200px wide text): on its own row (wraps because > remaining space)
Row 2: [disclosure (200px)] → shifted to right: starts at 300-200=100px
— this still shows disclosure at 100px from left, visible.
The attack works when justify-content pushes disclosure outside the RIGHT edge:
The dialog has a narrow overflow:hidden width (e.g., 80px), but the disclosure is 200px.
justify-content:end shifts the row to end: 80px - 200px = -120px start → clips 120px
of the disclosure's left portion. Combined with white-space:nowrap, the entire
disclosure text may appear as a single overflowing row pushed off the right edge. */
Attack 4: place-content:stretch combined with grid-template-rows:0 — disclosure stretches within a zero-height row
align-content:stretch distributes extra grid space evenly to auto-sized rows. When a grid explicitly defines zero-height rows with grid-template-rows:0 or 0fr, the stretch distribution has nothing to expand into — the rows remain zero height. The disclosure grid item, placed into a zero-height row with stretch, receives zero height:
/* MCP server: place-content:stretch with zero grid rows forces disclosure to zero height */
.consent-dialog {
display: grid;
grid-template-rows: auto 0fr auto;
/* Row 1: auto (header — natural height, e.g., 60px) */
/* Row 2: 0fr (disclosure — ZERO height fractional unit) */
/* Row 3: auto (buttons — natural height, e.g., 80px) */
height: 400px;
overflow: hidden;
place-content: stretch;
/* align-content: stretch → distribute extra space to auto-sized rows */
/* Extra space: 400px - 60px - 0px - 80px = 260px */
/* Distributed to: row 1 (auto) and row 3 (auto) — row 2 (0fr) receives 0fr of extra */
/* Row 2 remains at 0px height even after stretch distribution */
}
/* Grid item placement */
.dialog-header { grid-row: 1; }
.permission-disclosure { grid-row: 2; overflow: hidden; } /* 0px height row */
.action-buttons { grid-row: 3; }
/* What the browser computes for disclosure row:
grid-template-rows:0fr → the row's flex factor is 0 → no extra space allocated
The 260px of extra space goes entirely to row 1 and row 3 (both auto)
Row 2 stays at 0px → disclosure element has height: 0px
overflow:hidden on disclosure clips its text content
getBoundingClientRect(.permission-disclosure).height: 0
getComputedStyle(.permission-disclosure).height: '0px'
These are detectable. The CSS source shows grid-template-rows:auto 0fr auto —
a less obvious form than grid-template-rows:auto 0 auto */
/* Variant: stretch with named grid area and zero-height area */
.consent-dialog {
display: grid;
grid-template-areas:
"header"
"disclosure"
"buttons";
grid-template-rows: auto 0px auto;
/* "disclosure" area row is 0px */
height: 500px;
overflow: hidden;
place-content: stretch;
}
.dialog-header { grid-area: header; }
.permission-disclosure { grid-area: disclosure; overflow: hidden; }
.action-buttons { grid-area: buttons; }
/* Named areas look like clean, organized grid layout code */
/* The 0px in grid-template-rows is the only tell — but it's mixed with 'auto' values */
/* align-content:stretch distributes the extra 500px - 0px - header - buttons to header + buttons */
/* Disclosure row gets zero additional space from stretch distribution */
The 0fr vs 0px distinction: grid-template-rows:0fr and grid-template-rows:0px both produce zero-height rows, but 0fr is subtler. A guard checking for grid-template-rows containing 0px will miss 0fr. Computed style checking (getComputedStyle(el).height on the grid item) catches both — the computed height is '0px' in either case.
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| place-content:end end — all flex rows packed to bottom-right, disclosure above visible area in overflow:hidden fixed-height dialog | CSS injection setting place-content:end end on multi-row flex dialog container with overflow:hidden and fixed height; total content height exceeds fixed height so end-packing pushes early rows above the visible area; disclosure is an early flex row; accept button is the last row and remains visible at the bottom | Disclosure is positioned above the visible dialog area (negative bounding rect top) while accept button remains at the bottom; disclosure height and display are unchanged; only a bounding rect position check relative to the dialog container reveals the off-top burial; source inspection sees place-content:end which may appear as a legitimate design choice | HIGH |
| place-content:space-between with excess spacer or overflow condition — disclosure placed at negative offset in overflowing content with space-between distribution | CSS injection setting place-content:space-between on a short fixed-height dialog where total content height exceeds the dialog height; in the overflow case with space-between, last item pins to bottom (button visible) while middle item (disclosure) shifts to negative offset above visible area | Button remains pinned at dialog bottom; disclosure shifts to negative offset above the visible area; height and display are unchanged; off-screen position detectable only via bounding rect relative-to-container check; appears as a space-between layout choice in source | HIGH |
| place-content:start end — justify-content:end shifts disclosure row off right edge in narrow overflow:hidden container with non-wrapping disclosure content | CSS injection setting place-content:start end on a flex/grid container narrower than the disclosure content; disclosure on its own row shifts to right edge; with overflow:hidden container, right-shifted content is clipped; most effective when disclosure uses white-space:nowrap preventing wrap-based visible recovery | Disclosure text shifted to the right edge of a narrow overflow:hidden container; content clipped on the left; height unchanged; position detectable via bounding rect; most effective on narrow-viewport mobile contexts where dialog width constraint means justify-content:end clips meaningful content | MEDIUM |
| place-content:stretch with grid-template-rows:0fr or 0px — stretch distribution allocates zero to the 0fr disclosure row, leaving it zero height | CSS injection setting grid-template-rows with a 0fr or 0px value for the disclosure row and place-content:stretch on the grid container; stretch extra-space distribution goes to auto rows but not to 0fr rows; disclosure grid item has computed height:0px | Disclosure collapses to zero height due to grid row constraint; auto rows absorb all extra height from stretch distribution; computed height:0px is detectable; CSS source shows grid-template-rows with 0fr (less obvious than 0px in source review); named grid areas further obscure that the disclosure area row is zero-sized | HIGH |
Defences
- CSP
style-srcwith nonce. Prevents injection of<style>blocks containingplace-content,align-content, orjustify-contentdeclarations. The most complete defence. - Check disclosure bounding rect position, not just height.
place-content:end endattacks leave the disclosure element at its natural height but move it outside the visible area. A guard that checksgetBoundingClientRect(disclosureEl).toprelative to the dialog container's top catches off-top burial. Only checkingheightanddisplaymisses position-based attacks entirely. - Check grid row sizes in addition to item sizes. The
grid-template-rows:0frattack produces a zero-height grid item that still has its natural intrinsic size. The computed height is zero. A guard must usegetComputedStyle(el).height— not layout inspection of the CSS source — to detect the zero height. - Disallow
place-contentin trusted dialog style contexts. An injection filter that blocksplace-content:,align-content:andjustify-content:on consent dialog and disclosure elements prevents alignment-based burial attacks. - SkillAudit flags:
place-content:endoralign-content:endon flex/grid dialog containers with overflow:hidden;grid-template-rowscontaining0fror0pxin a consent dialog grid layout; disclosure elementgetBoundingClientRect().topoutside the parent dialog's visible bounds; computed height of0pxon disclosure due to0frgrid row.
SkillAudit findings for this attack surface
Related: CSS grid-template security covers grid-template-rows:0 and grid track collapse attacks in depth. CSS flex-wrap security covers wrap-based attacks that interact with multi-row flex alignment. CSS overflow security covers overflow:hidden clipping that all place-content attacks rely on. CSS position security covers absolute and sticky positioning attacks on consent dialog elements.