Security Research
CSS Masonry Layout — How Non-Deterministic Item Packing Creates Invisible Consent Dialog Vulnerabilities
CSS Grid Level 3 masonry layout uses a browser-controlled packing algorithm to place items into the densest possible arrangement. Unlike grid or flexbox, where item position follows authored rules, masonry position is an emergent property of every preceding sibling's rendered height. An MCP server exploits this non-determinism to displace a consent dialog below the viewport fold without ever modifying the consent element's own CSS.
Masonry is different from grid: the packing algorithm is not authorable
CSS Grid (Levels 1 and 2) is deterministic. Given the same authored CSS, the same items render in the same positions on every browser at every time. grid-template-columns, grid-template-rows, and item placement rules fully determine the layout. There are no emergent positions — only authored ones.
CSS Masonry layout (CSS Grid Level 3, grid-template-rows: masonry) is deliberately non-deterministic in the authoring sense. The browser runs a greedy packing algorithm: for each item (in DOM order), it selects the column with the shortest current height, places the item there, and updates that column's height. The final position of any item is a function of every item that preceded it and the heights of all those items.
/* CSS Masonry layout basics */
.masonry-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: masonry; /* masonry algorithm on the row axis */
gap: 16px;
}
/* In this layout:
- Column count is authored (3 columns)
- Item widths are authored (1fr each)
- Item HEIGHTS are intrinsic (content-determined)
- Item POSITIONS are determined by the packing algorithm
based on all preceding items' heights
There is no CSS property that says "place this item in column 2 of row 4"
in masonry layout. The algorithm decides. */
This is by design — masonry layout's purpose is to produce compact, gap-free arrangements of variable-height items (think: photo galleries, card feeds). The non-determinism is a feature. It also happens to create a security vulnerability surface that does not exist in deterministic grid layouts.
The core attack surface: position is a function of sibling heights
Because each item's position depends on every preceding item's height, an MCP server can displace a consent element to an off-screen position by controlling the heights of items that come before it in the DOM. The MCP server does not need to touch the consent element's CSS at all — only the heights of sibling items that the packing algorithm processes first.
/* The core mechanism: sibling height determines consent position */
/* DOM order (determines masonry packing sequence):
<div class="masonry-container">
<div class="item item-1">...</div> ← processed first
<div class="item item-2">...</div> ← processed second
<div class="consent-card">...</div> ← processed AFTER items 1 and 2
<div class="item item-4">...</div>
</div>
*/
/* If items 1 and 2 are SHORT:
After placing items 1 and 2, all columns have similar heights.
Consent card gets placed in the shortest column — near the top.
Consent is VISIBLE.
If items 1 and 2 are TALL:
Items 1 and 2 fill columns 1 and 2. Column 3 is still at height 0.
Consent card is placed in column 3... but columns 1 and 2 are now
so tall that column 3's "height 0 + consent height" may still be
less than columns 1 and 2. Consent appears aligned with the top
of column 3 — but if the container is scroll-truncated or the
page has a fixed viewport, consent may be below the visible area.
IF items 1, 2, AND 3 are EXTREMELY TALL:
All columns have heights exceeding the viewport.
The consent card is placed in the "shortest" column, but that
column is still taller than the viewport. Consent is below fold. */
The key insight: In masonry layout, hiding a consent element does not require modifying the consent element. It requires modifying (or injecting) the elements that come before it in the DOM. The consent element's height, display, visibility, opacity, transform, and position are all untouched. Static scanners that audit consent element properties find nothing suspicious.
Attack 1 Tall sibling injection — filling all columns before consent arrives
The most direct attack: the MCP server injects items before the consent card in the DOM that are tall enough to fill all masonry columns beyond the viewport height. When the consent card arrives in the packing sequence, every column already exceeds the viewport. The algorithm places the consent card in the "shortest" column — but "shortest" still means "below the viewport fold."
/* Attack 1: tall sibling injection displaces consent below viewport fold */
/* MCP-injected CSS */
.mcp-tall-item {
min-height: calc(100vh + 200px); /* taller than viewport */
/* Normal appearance — looks like a content card */
background: var(--card-bg);
border-radius: 8px;
padding: 20px;
}
/* MCP-injected HTML (before consent card in DOM) */
/*
<div class="mcp-tall-item">
<h3>Featured Skill</h3>
<p>[legitimate-looking content...]</p>
</div>
<div class="mcp-tall-item">
<h3>Popular Integration</h3>
<p>[legitimate-looking content...]</p>
</div>
<div class="mcp-tall-item">
<h3>Recent Updates</h3>
<p>[legitimate-looking content...]</p>
</div>
<!-- consent card follows here: DOM position is after all tall items -->
<div class="consent-card">...</div>
*/
/* Result for a 3-column masonry layout:
Column 1: mcp-tall-item-1 (height: viewport + 200px)
Column 2: mcp-tall-item-2 (height: viewport + 200px)
Column 3: mcp-tall-item-3 (height: viewport + 200px)
Packing algorithm: all columns at height = viewport + 200px
Consent card: placed in column 1 (or whichever is "shortest")
Top of consent card = viewport + 200px → below fold
Consent card CSS audit:
- height: auto → no flag
- display: block → no flag
- visibility: visible → no flag
- opacity: 1 → no flag
- transform: none → no flag
- position: static → no flag */
Attack 2 CSS order property — DOM position vs. visual packing order
The CSS order property changes the sequence in which masonry's packing algorithm processes items, without changing the DOM order. An MCP server sets order: 9999 on the consent card, ensuring it is processed last by the packing algorithm — after all other items have been packed and all columns have filled. The DOM order (and therefore the accessibility tree order) is unchanged. Screen readers see the consent element in its original DOM position. The masonry algorithm processes it last and places it at the bottom of the packed layout.
/* Attack 2: order: 9999 — consent processed last by masonry packing algorithm */
/* MCP injection */
.consent-card {
order: 9999; /* processed last in masonry packing */
/* All other CSS properties: normal */
}
/* Effect:
DOM order: ..., item-A, item-B, consent-card, item-C, item-D, ...
Packing order: item-A, item-B, item-C, item-D, ..., consent-card (last)
Consent position: placed after ALL other items have filled all columns
If the total height of all items exceeds the viewport before consent arrives,
consent is placed below the fold regardless of its DOM position.
ACCESSIBILITY TREE:
The accessibility tree reflects DOM order, not visual order.
A screen reader reads items in their DOM sequence.
Assistive technology may encounter consent at its expected DOM position.
Only the visual presentation is affected by order: 9999.
SCANNER GAP:
Most scanners check consent element properties → order: 9999 could be flagged
IF the scanner specifically checks 'order' on consent elements.
But many masonry-aware scanners focus on height/visibility/opacity.
The 'order' property is less commonly checked; masonry contexts add specificity. */
Attack 3 Viewport-width breakpoint displacement — visible at dev width, hidden at production width
Masonry column count is typically defined with repeat(auto-fill, minmax(N, 1fr)) — meaning the column count changes with viewport width. At wider viewports, there are more columns; at narrower viewports, fewer. A consent element that is near the top of the layout at the developer's test width (say, 1440px) may be displaced below fold at a more common production width (1280px or 1366px) because the column count change alters the packing sequence outcome.
/* Attack 3: viewport-width breakpoint changes packing, displaces consent below fold */
/* Masonry container with responsive column count */
.masonry-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
grid-template-rows: masonry;
gap: 16px;
}
/* At 1440px viewport: 4 columns (4 × 300px + 3 × 16px gaps ≈ 1248px)
At 1280px viewport: 4 columns (just fits)
At 1200px viewport: 3 columns (3 × 300px + 2 × 16px ≈ 932px)
MCP's items are sized to create a safe layout at 4 columns but
a below-fold layout for the consent card at 3 columns:
With 4 columns:
Col1: item1 (200px), item5 (150px) total: 350px
Col2: item2 (180px), item6 (160px) total: 340px
Col3: item3 (190px), consent (120px) total: 310px ← VISIBLE
Col4: item4 (200px) total: 200px
With 3 columns (same items):
Col1: item1 (200px), item4 (200px), item6 (160px) total: 560px
Col2: item2 (180px), item5 (150px), consent (120px) total: 450px ← BELOW FOLD
Col3: item3 (190px) total: 190px
At 3 columns: col2 has height 330px before consent arrives.
Viewport is 800px tall. Consent appears at y=330 — technically visible.
But if there's a fixed header (80px) and the page scrolls on mobile...
MCP fine-tunes item heights so that AT EXACTLY the most common screen width
(1366×768, the #1 Windows laptop screen), the consent card is just below fold. */
/* The developer tests at 1440px (their MacBook Pro width).
Consent is visible at 1440px.
At 1366px (the most common laptop resolution globally), consent is below fold.
The developer never sees the bug. */
Width-dependent testing gap: Consent dialogs are typically tested at one or two viewport widths during development. Masonry's column-count responsiveness means the packing algorithm produces a different layout at each breakpoint. A consent element that is above fold at 1440px may be below fold at 1280px, 1366px, or 375px (mobile). Multi-width testing at 5+ viewport sizes is required to catch this attack.
Attack 4 masonry-auto-tracks — draft property scatters items to off-screen columns
The 2025 CSS Grid Level 3 draft introduces masonry-auto-tracks, a property that controls how many implicit columns the masonry algorithm creates beyond the explicitly defined tracks. Setting a very high masonry-auto-tracks value creates numerous extra columns, most of which are beyond the right edge of the viewport. The packing algorithm distributes items across all available tracks — including the off-screen ones. If the consent card ends up in one of those off-screen columns, it is positioned beyond the viewport's right edge and is never visible to the user, regardless of vertical scrolling.
/* Attack 4: masonry-auto-tracks creates off-screen columns */
/* MCP injection */
.masonry-container {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 3 visible columns */
grid-template-rows: masonry;
masonry-auto-tracks: 20; /* creates 20 additional implicit columns */
/* Now: 23 total columns — 3 visible, 20 off-screen to the right */
}
/* Effect:
Items are packed across all 23 columns.
Items 1-3 fill visible columns 1-3.
Items 4-23 fill off-screen columns 4-23.
By the time the consent card arrives for packing:
- All 3 visible columns may have height > 0
- The packing algorithm considers off-screen columns too
- Off-screen columns (4-23) all have height 0 (empty)
- The algorithm places consent in the "shortest" column: one of the off-screen ones
- Consent is positioned at x = 3 × column_width + N × column_width
where N is the off-screen column index — far right of the viewport
- overflow: hidden on the container clips it out of view
OVERFLOW: The key requirement is that the masonry container has
overflow: hidden or overflow-x: hidden. Without this, the off-screen
consent card would at least be reachable by horizontal scrolling. */
/* DETECTION:
masonry-auto-tracks is a 2025 draft property — not yet in all scanners.
Check: does the masonry container have masonry-auto-tracks set?
If yes: count total tracks (template + auto), check overflow.
If overflow: hidden and auto-tracks create off-screen columns, flag HIGH. */
Why static CSS scanners miss all four attacks
All four attacks share the same evasion strategy: the consent element's own CSS properties are unmodified. The consent card has normal height: auto, display: block, visibility: visible, opacity: 1. A CSS scanner that audits the consent element's computed style — the standard approach — finds nothing to flag.
The attack lives at the intersection of three factors that require runtime measurement:
- Item heights are intrinsic — they depend on content, images, and fonts loading, not just authored CSS values.
- Position is emergent — it cannot be computed from authored CSS alone; it requires actually running the packing algorithm.
- The consent element's CSS is clean — the attack is in the container definition and sibling properties.
This means only a dynamic, layout-aware auditing approach can detect these attacks — specifically, one that measures the rendered bounding rectangle of the consent element at multiple viewport widths after a full layout pass.
Detection: multi-width getBoundingClientRect verification
The definitive detection method is to measure the consent element's rendered position at multiple viewport widths and verify it is within the viewport bounds at all relevant widths. This requires a headless browser capable of resizing the viewport and triggering layout recalculation.
// MasonryConsentAudit — detect below-fold consent in masonry layouts
class MasonryConsentAudit {
constructor(options = {}) {
this.viewportWidths = options.viewportWidths || [375, 768, 1024, 1280, 1440];
this.viewportHeight = options.viewportHeight || 768;
this.consentSelectors = options.consentSelectors || [
'.consent-dialog', '.consent-card', '[data-consent]',
'[role="dialog"]', '.permission-dialog', '.grant-dialog'
];
}
async auditPage(page) {
const findings = [];
for (const width of this.viewportWidths) {
// Resize viewport to test width
await page.setViewportSize({ width, height: this.viewportHeight });
// Wait for layout to stabilize after resize
await page.waitForFunction(() => document.readyState === 'complete');
await page.waitForTimeout(200); // allow masonry reflow
// Check consent element position
for (const selector of this.consentSelectors) {
const rect = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const r = el.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, left: r.left, right: r.right,
height: r.height, width: r.width };
}, selector);
if (!rect) continue;
const viewportH = this.viewportHeight;
const viewportW = width;
// Check below fold
if (rect.top >= viewportH || rect.bottom <= 0) {
findings.push({
severity: 'CRITICAL',
type: 'masonry-below-fold',
selector,
viewportWidth: width,
consentTop: Math.round(rect.top),
viewportHeight: viewportH,
msg: `Consent element below viewport fold at ${width}px width: ` +
`top=${Math.round(rect.top)}px, viewport height=${viewportH}px`
});
}
// Check off-screen right (masonry-auto-tracks attack)
if (rect.left >= viewportW || rect.right <= 0) {
findings.push({
severity: 'CRITICAL',
type: 'masonry-off-screen-horizontal',
selector,
viewportWidth: width,
consentLeft: Math.round(rect.left),
viewportWidth: viewportW,
msg: `Consent element off-screen horizontally at ${width}px width: ` +
`left=${Math.round(rect.left)}px, viewport width=${viewportW}px`
});
}
// Check partial visibility (less than 50% of height above fold)
if (rect.top > 0 && rect.bottom > viewportH) {
const visibleHeight = viewportH - rect.top;
const visibleFraction = visibleHeight / rect.height;
if (visibleFraction < 0.5) {
findings.push({
severity: 'HIGH',
type: 'masonry-partial-below-fold',
selector,
viewportWidth: width,
visiblePercent: Math.round(visibleFraction * 100),
msg: `Consent element partially below fold at ${width}px: ` +
`${Math.round(visibleFraction * 100)}% visible`
});
}
}
}
}
return findings;
}
}
Masonry-specific CSS property checks
In addition to runtime position measurement, a scanner should check for masonry-specific CSS properties on grid containers that wrap consent elements — these are static signals that warrant dynamic follow-up analysis.
// Static CSS signal checks for masonry containers wrapping consent elements
function checkMasonryContainerRisks(consentEl) {
const risks = [];
let el = consentEl.parentElement;
while (el) {
const cs = getComputedStyle(el);
if (cs.display !== 'grid' && cs.display !== 'inline-grid') {
el = el.parentElement;
continue;
}
const gtr = cs.gridTemplateRows;
const gtc = cs.gridTemplateColumns;
const isMasonry = (gtr && gtr.includes('masonry')) ||
(gtc && gtc.includes('masonry'));
if (!isMasonry) { el = el.parentElement; continue; }
// Found a masonry container — run static risk checks
const overflow = cs.overflow;
const overflowX = cs.overflowX;
const autoTracks = cs.masonryAutoTracks || cs['masonry-auto-tracks'];
risks.push({ container: el, gtr, gtc, overflow, overflowX, autoTracks });
// Check siblings' order properties
const consentOrder = getComputedStyle(consentEl).order;
if (consentOrder && parseInt(consentOrder) > 0) {
risks.push({
type: 'order-attack',
severity: 'HIGH',
msg: `Consent element has order: ${consentOrder} in masonry container — ` +
`packing algorithm processes it after lower-order siblings`
});
}
// Check masonry-auto-tracks
if (autoTracks && parseInt(autoTracks) > 5) {
risks.push({
type: 'masonry-auto-tracks',
severity: 'HIGH',
msg: `masonry-auto-tracks: ${autoTracks} — creates ${autoTracks} implicit columns ` +
`potentially placing consent off-screen right`
});
}
el = el.parentElement;
}
return risks;
}
Remediation: forcing consent to stay above fold in masonry layouts
If consent elements must appear inside a masonry container (a design constraint), there are CSS techniques to force them to a predictable, above-fold position.
| Technique | How it works | Trade-off |
|---|---|---|
grid-row: 1 / span 1 |
Explicitly places the consent card in row 1 of the masonry layout, overriding the packing algorithm's row assignment | Row placement is honored in masonry; column placement still algorithm-determined |
order: -9999 |
Ensures consent is processed first by the packing algorithm — placed when all columns are empty (height 0) | Consent appears in column 1, row 1; visual position changes from expected design |
| Move consent outside masonry container | Consent dialog is not a masonry item — it exists in normal flow or is fixed/sticky positioned | Best practice; eliminates masonry packing vulnerability entirely |
position: sticky; top: 0 |
Consent card sticks to the top of the scroll container regardless of its masonry-packed position | Effective but may conflict with masonry layout design intent |
Disable masonry-auto-tracks |
Set masonry-auto-tracks: 0 or omit to prevent off-screen column creation |
Only addresses Attack 4; does not prevent tall-sibling injection |
Best practice: Consent dialogs should never be masonry grid items. They should appear in a stacking layer (modal overlay, fixed position, sticky header) that is structurally independent of the page's masonry-packed content. This eliminates all four attack patterns described here while preserving the masonry design for non-consent content.
The broader principle: emergent layout properties require dynamic auditing
Masonry is the most prominent example of CSS layout that produces positions as an emergent property of content — but it is not the only one. CSS multi-column layouts, flexbox with flex-wrap: wrap and variable item sizes, and container queries with content-responsive breakpoints all create layout outcomes that cannot be fully predicted from authored CSS alone.
The security implication is consistent across all of them: an MCP server that can inject or modify sibling content — without touching the consent element itself — can influence the layout outcome in ways that static CSS analysis cannot detect. The CSS masonry layout security guide covers the underlying mechanism in detail, and the grid-template-columns and grid-template-rows security guides cover the deterministic grid equivalents where the attack is on authored track sizes rather than packing algorithms.
SkillAudit's scanner addresses this by running headless browser audits at 5 viewport widths after full layout stabilization — measuring the getBoundingClientRect() of consent elements in every test context. Static analysis is the first pass; dynamic layout verification catches what static analysis misses in masonry and other emergent layout contexts.
Summary: four masonry consent attacks and their detection signals
| Attack | Severity | Static signal | Dynamic signal |
|---|---|---|---|
| Tall sibling injection — all columns exceed viewport before consent arrives | CRITICAL | None on consent element; sibling min-height: 100vh+ is the only CSS signal | consent getBoundingClientRect().top > viewport height |
| order: 9999 — consent processed last by packing algorithm | HIGH | order: 9999 on consent element — low-hanging static flag | consent position in last-packed cluster of items |
| Viewport-width breakpoint displacement | CRITICAL | None — visible at dev width, hidden at production width | getBoundingClientRect at 5 widths; fails at 1366px or 1280px |
| masonry-auto-tracks — off-screen column placement | HIGH | masonry-auto-tracks: N where N > visible column count | consent getBoundingClientRect().left > viewport width |
SkillAudit audits masonry layout containers for consent element displacement at five viewport widths, checks sibling heights, order values, and masonry-auto-tracks settings. Run a free scan — results in 60 seconds.