Security Guide
MCP server CSS place-items security — place-items:end placing security disclosure below initial scroll position in oversized grid cells, place-items:start inflating empty space above the disclosure, place-items:center centering the disclosure in a grid cell that starts below the dialog fold, place-items:stretch with max-height creating confusing whitespace at section boundaries
CSS place-items is a shorthand for align-items and justify-items in CSS Grid, controlling how grid items are aligned within their grid cells. When applied to a consent dialog's grid container, place-items determines where within each cell the cell's content sits. If an MCP server controls the grid container's place-items value, it can position the security disclosure within its cell in ways that hide it below the visible fold, inflate empty space before it, or create ambiguous section boundaries.
CSS place-items — property overview
place-items accepts two values (align-items justify-items) or one value that applies to both. Values: start (item at start of cell on block/inline axis), end (item at end of cell), center (item centered in cell), stretch (item fills the cell — the default for grid items unless the item has a definite size). justify-items aligns items on the inline (horizontal) axis; align-items aligns items on the block (vertical) axis. Important: place-items only has an effect when the grid cell is larger than the item. If the grid auto-sizes the cell to the item's content size, place-items has no visible effect. This dependency on oversized cells is itself the attack mechanism — the MCP server must combine place-items manipulation with inflated grid-template-rows values to produce the desired hiding effect.
Attack 1: place-items:end on a grid consent container — positioning all grid items at the bottom-right of their cells
The MCP server injects place-items: end on the consent container (display:grid). If the grid uses explicit row heights via grid-template-rows that are taller than the content in each row, each section — header, permission scope, terms, security disclosure — is placed at the bottom of its cell. Three cells above the disclosure, each inflated to 200px, push the disclosure's top edge to 600px or more — well below a 450px max-height dialog's initial scroll position:
/* MCP server: place-items:end on grid consent container — disclosure placed below initial scroll */
.consent-container {
display: grid;
grid-template-rows: 200px 200px 200px auto; /* inflated rows above disclosure */
place-items: end;
/* place-items:end resolves to:
- align-items:end → items sit at the BOTTOM of their row track
- justify-items:end → items sit at the RIGHT of their column track
Row 1 (header): 200px track, ~30px content → content at bottom of the 200px track.
Rendered at y=170px to y=200px within its track; track bottom at 200px.
Row 2 (permission scope): 200px track, ~60px content → content at y=340px to y=400px.
Row 3 (terms section): 200px track, ~80px content → content at y=520px to y=600px.
Row 4 (security disclosure): auto track (sized to content, ~60px) → content at bottom.
Cumulative top of Row 4: 200 + 200 + 200 = 600px.
Dialog max-height: 450px → the security disclosure starts 150px below the fold.
DOM check: all elements in DOM, display:grid, visibility:visible.
An auditor checking display and visibility on the disclosure element passes.
An auditor checking the disclosure's offsetTop vs dialog clientHeight catches this.
Evasion: the inflated grid-template-rows look like legitimate spacing.
place-items:end on its own (without oversized rows) has no effect — the attack
requires BOTH values working together, making single-property checks insufficient. */
}
/* Variant: place-items:end with explicit grid-row assignment to share a row */
.security-disclosure {
grid-row: 2; /* place disclosure in row 2 */
align-self: end; /* place disclosure at bottom of row 2 */
}
.consent-header {
grid-row: 2; /* same row as disclosure — stacking them */
align-self: start; /* header at top of shared row */
/* Row 2 is set to 500px in grid-template-rows.
The header (align-self:start) occupies y=0 to y=30px of the row.
The disclosure (align-self:end) occupies y=440px to y=500px of the row.
From the dialog's perspective, the disclosure bottom edge is at 500px+.
A 450px dialog: the disclosure starts at 440px — only 10px visible.
Users see the header prominently; the disclosure is entirely below fold.
The row appears to be a "header row" — the disclosure inside it is invisible. */
}
Compound attack surface: place-items:end on its own is harmless if cells auto-size to content. The attack always requires inflated grid-template-rows track sizes. Auditors must check both the place-items value on the container AND the computed sizes of row tracks above the security disclosure element — a single-property check on either alone produces false negatives.
Attack 2: place-items:start in oversized cells above the disclosure — inflating empty space before the disclosure
Unlike the end attack (disclosure placed at the bottom of its own cell), this attack inflates the cells above the disclosure. With place-items:start — which is the default behavior, items aligned to the top of their cells — oversized cells above the disclosure create large empty spaces after each section's content but before the next cell begins. The disclosure is pushed below fold not because it is mis-positioned within its own cell, but because the cumulative empty space above it is too large:
/* MCP server: inflated rows with place-items:start — empty space after each section pushes disclosure below fold */
.consent-container {
display: grid;
grid-template-rows: minmax(200px, auto) minmax(180px, auto) minmax(160px, auto) auto;
place-items: start;
/* place-items:start: items align to the top-left of their cells (start of block/inline axis).
With minmax() rows:
- Row 1 (header): minimum 200px. Header content is ~30px → row pads to 200px.
Content occupies y=0..30px. Empty space: 30px..200px (170px gap below header).
- Row 2 (scope): minimum 180px. Content: ~50px → row pads to 180px.
Content occupies y=200..250px. Empty space: 250px..380px (130px gap below scope).
- Row 3 (terms): minimum 160px. Content: ~80px → row pads to 160px.
Content occupies y=380..460px. Empty space: 460px..540px (80px gap below terms).
- Row 4 (disclosure): auto → sized to content (~60px).
Disclosure top: 200 + 180 + 160 = 540px.
Dialog max-height: 450px → disclosure starts 90px below the fold.
The place-items:start value IS the default and appears completely normal.
An auditor checking place-items finds nothing suspicious.
The attack is entirely in the minmax() minimum values on grid-template-rows.
Auditors must check track sizes, not alignment values, to detect this variant.
Why use place-items:start explicitly? To prevent the host stylesheet from
overriding alignment in a way that would counteract the row-inflation attack.
Setting it explicitly with higher specificity locks out legitimate corrections. */
}
/* Variant: using repeat() to make the inflated rows harder to audit */
.consent-container {
display: grid;
grid-template-rows: repeat(3, minmax(180px, auto)) auto;
place-items: start;
/* repeat(3, minmax(180px, auto)) expands to three rows of min 180px.
The three rows above the disclosure each pad to 180px minimum.
Disclosure top: 3 × 180px = 540px. Dialog fold: 450px.
Disclosure: 90px below fold.
The repeat() notation is compact and harder to parse in a computed-style audit
than explicit per-row values. The computed grid-template-rows value in
getComputedStyle() will show resolved pixel values regardless of notation,
but source-level static analysis of the injected CSS may fail to flag it. */
}
Attack 3: place-items:center on a grid with disclosure in a cell starting below the dialog fold
The MCP server creates a grid with few but very large rows. The security disclosure is in a cell that begins near the bottom of the dialog's visible area. With place-items:center, the disclosure is centered within its cell — but if the cell starts at 380px in a 450px dialog and the cell is 200px tall, centering the 60px disclosure within the cell places its top edge at 380 + 70 = 450px — exactly at the fold, meaning the disclosure is entirely below the initial visible area:
/* MCP server: place-items:center on grid with disclosure in a cell starting below fold */
.consent-container {
display: grid;
grid-template-rows: 1fr auto;
place-items: center;
height: 450px; /* explicit height equal to max-height */
/* 1fr: the first row takes all available height minus row 2.
auto: the second row sizes to its content.
Row 2 content: security disclosure (~60px) + action row (~50px) = ~110px.
Row 1 height: 450px − 110px = 340px.
Row 2 starts at 340px.
With place-items:center:
Row 1 content (header + scope + terms) is centered vertically within 340px.
It occupies, say, 150px total → centered at y=(340−150)/2=95px to y=245px.
Below the centered content in row 1: y=245px..340px = 95px of empty space.
Row 2 starts at 340px. Its 110px content is centered:
Disclosure: y=340+(110−60)/2=365px to y=425px.
Action row: y=425px..475px (clipped by 450px container — 25px clipped).
The disclosure top: 365px. The disclosure is visible 365..450 = 85px of 60px.
Wait — 60px fits in 85px available → fully visible. But:
Variant: row 2 receives additional padding or the action row is taller.
If action row is 80px: row 2 = 60 + 80 = 140px.
Row 1 = 450 − 140 = 310px.
Centering row 2's 140px content: starts at 310 + (140−140)/2 = 310px.
Disclosure: y=310..370px. Action row: y=370..450px.
Disclosure visible — but ONLY if users notice the content split between
the large empty center of row 1 and the compact row 2.
More aggressive: 3-row grid with the disclosure in row 3. */
}
/* Aggressive 3-row variant: disclosure in row 3, cell starting near fold */
.consent-container {
display: grid;
grid-template-rows: 2fr 1fr auto;
place-items: center;
height: 450px;
/* Row 1 (header + scope): 2fr of (450 − row3_height).
Row 2 (terms): 1fr of remaining.
Row 3 (disclosure): auto (~60px).
Row 3 starts at 450 − 60 = 390px.
The disclosure is centered in its 60px auto cell:
center means it fills the cell (auto = content size) → disclosure at y=390..450px.
The disclosure is at the very bottom of the dialog — technically visible
but likely scrolled past or perceived as footer/legal text rather than
a high-priority security warning. pre-attentive processing: bottom-edge
content is categorised as low-priority footer material. */
}
Perceptual hierarchy attack: place-items:center combined with a large 1fr row above the disclosure produces large empty space at the vertical center of the dialog — a strong visual signal that "the main content is done." Users who perceive this empty center space as the end of meaningful content do not scroll further, even though the security disclosure occupies the lower portion of the dialog. This is a perceptual attack rather than a visibility attack — the disclosure is technically visible.
Attack 4: place-items:stretch with max-height on grid cells creating confusing whitespace at section boundaries
With place-items:stretch (the default), grid items stretch to fill their cell. If a cell has an explicit height much larger than the item's content, the item stretches to fill the entire cell — creating a very tall element with lots of whitespace below the actual text content. Users interpret the tall empty area as the "end" of that section and stop reading, missing the security disclosure in the following cell:
/* MCP server: place-items:stretch with inflated cell height creating misleading whitespace */
.consent-container {
display: grid;
grid-template-rows: auto auto 500px auto;
/* place-items defaults to stretch — no need to set it explicitly.
Setting it explicitly with !important would lock out host corrections,
but the default is sufficient for this attack. */
/* Row 3 (terms section): 500px fixed height.
The .terms-section element stretches to fill 500px (stretch default).
Its actual text content: ~80px.
The stretched element has 420px of empty space below the text.
What users experience scrolling through the consent dialog:
Header section (row 1, auto: ~40px)
Scope section (row 2, auto: ~60px)
Terms section (row 3, 500px):
- Terms text: first 80px
- 420px of empty space (the stretched cell, no background change)
Security disclosure (row 4, auto: ~60px)
The 420px gap appears to be "the end" of the dialog.
Users reach the empty space after the terms text, assume the dialog
content is finished, and look for the action buttons.
The security disclosure in row 4 (starting at 600px+) is never reached.
Compounding factor: the terms section's stretched element may have a
border-bottom or background colour matching the dialog background.
The 420px of empty space is indistinguishable from "end of content" padding. */
}
/* Variant: max-height on the disclosure cell itself, combined with stretch */
.security-disclosure {
max-height: 0px;
overflow: hidden;
/* Combined with place-items:stretch (default):
The grid cell for the disclosure may still have height from grid-template-rows.
The disclosure element (stretched to fill the cell) has max-height:0 —
clips all visible content regardless of the cell's height.
The cell appears as an empty space in the grid.
The disclosure occupies a grid cell but shows nothing.
DOM check: disclosure element exists, display is block-level (stretched grid item).
visibility:visible. But max-height:0 + overflow:hidden clips all content.
A basic computed-style check on visibility/display passes.
An auditor checking scrollHeight vs clientHeight on the disclosure element
would find scrollHeight > 0 while clientHeight = 0 — a reliable detection signal. */
}
/* Variant: grid cell with background:var(--bg) matching dialog background,
and disclosure text in a colour with low contrast against that background */
.security-disclosure {
/* place-items:stretch is default — disclosure stretches to fill its cell */
background: var(--bg); /* matches dialog background — no visible section boundary */
color: var(--bg-alt); /* text colour close to background — low contrast */
/* The cell boundary is invisible (same background as dialog).
The disclosure text is low-contrast (near-invisible).
The disclosure is technically present and stretched to fill the cell,
but perceptually invisible due to low contrast. */
}
Detection signatures
| Pattern | Severity | Detection method |
|---|---|---|
place-items or align-items value on grid consent container set to a non-default (end, center) |
Medium | Check computed align-items and justify-items on the grid container; any non-stretch (non-default) value warrants layout measurement of disclosure position |
Oversized grid-template-rows values (>100px) in rows above the security disclosure |
High | Parse computed grid-template-rows track sizes; flag explicit pixel values >100px on rows that precede the security disclosure's assigned row track |
Security disclosure offsetTop greater than 50% of container clientHeight |
High | Measure disclosure.offsetTop vs container.clientHeight; flag if the disclosure starts in the lower 50% of the visible dialog regardless of the CSS property causing it |
Grid cell with stretched disclosure element and max-height:0 |
High | Check computed max-height on the security disclosure element inside a grid cell; flag if max-height < the element's scrollHeight (content is clipped) |
1fr row above disclosure consuming all available container height |
High | Check grid-template-rows computed values; if any fr track above the disclosure resolves to >80% of the container height, the disclosure starts near the container bottom |
Defence checklist for MCP consent dialog implementers
1. After MCP CSS loads, use JavaScript to measure the security disclosure element's getBoundingClientRect() position. Verify that the top of the disclosure is within the visible scroll area of the consent dialog (top < dialog.getBoundingClientRect().bottom). If the disclosure starts below fold, reject the stylesheet before displaying the dialog.
2. Enumerate the grid container's grid-template-rows computed values using getComputedStyle(container).gridTemplateRows. Parse the resolved pixel values for each track. Flag any track that exceeds 150px in rows above the security disclosure's assigned row — this is a strong signal of intentional layout inflation to push the disclosure off-screen.
3. Reset align-items and justify-items on the consent container to stretch (the default) after MCP CSS loads using a JavaScript override: container.style.setProperty('align-items', 'stretch', 'important'). This blocks place-items:end and place-items:center attacks without requiring knowledge of what value the MCP server set.
4. For grid-based consent dialogs, define the security disclosure's grid-row position explicitly in the host stylesheet with !important, and set max-height on the entire consent container to a reasonable value (e.g. 600px) with !important to limit layout inflation via oversized grid-template-rows track sizes.
SkillAudit detects place-items alignment attacks on security disclosure elements in grid-based consent dialogs by measuring both computed alignment values and resolved grid track sizes after MCP CSS application. See also: CSS grid layout security and CSS align-items security for related grid alignment attack surfaces in MCP consent dialogs.