MCP server CSS grid-row security: grid-row:1 in zero-height grid-template-rows track, repeat(2,0) all-zero grid, span-0 UA divergence, and JS grid-row collapse at mousedown
Published 2026-08-07 — SkillAudit Research
The CSS grid-row shorthand property (grid-row-start / grid-row-end) places a grid item in a specific row track within a CSS grid container. When the container's grid-template-rows defines an explicit zero-height track and the consent element is placed in that track, the consent is clipped to zero height while the overflow: hidden container prevents scrolling to it. This attack class is distinct from grid-layout-security (which covers general grid attack patterns) and from grid-auto-rows-security (which covers implicitly placed rows in auto-row grids).
The critical detection gap: el.offsetHeight and getBoundingClientRect().height return 0 for an element in a zero-height grid track, but el.scrollHeight returns the content height (non-zero). Standard visibility checks like display !== none, visibility !== hidden, opacity > 0, and el.offsetWidth > 0 all pass — the element is display:block, visible, opaque, and has a non-zero width. Only getBoundingClientRect().height === 0 on a non-empty element reveals the collapse.
Detection gap: el.offsetHeight === 0 with el.textContent.trim() !== '' is the canonical detection signal. Additionally, for grid-placed items, verify getComputedStyle(container).gridTemplateRows does not contain 0px tracks that the consent element is assigned to.
Attack 1: grid-template-rows:0 1fr — consent in row 1 (0px), install button in row 2 (1fr) (SA-CSS-GROW-001)
The install dialog grid container defines two rows: row 1 with height 0, row 2 with height 1fr (fills remaining space). The consent disclosure is placed in row 1 via grid-row: 1. The install button is in row 2 via grid-row: 2. The container has overflow: hidden. Result: the consent element is clipped to 0px height and is invisible, while the install button occupies the full available height. All standard checks pass: consent is display:block, visibility:visible, opacity:1, offsetWidth > 0, scrollHeight > 0, textContent contains the consent text. Only getBoundingClientRect().height === 0 reveals the collapse.
/* MCP attack: */
.install-dialog {
display: grid;
grid-template-rows: 0 1fr; /* row 1 = 0px, row 2 = fill */
overflow: hidden;
height: 400px;
}
.consent-disclosure {
grid-row: 1; /* placed in zero-height row */
/* display: block ✓ visibility: visible ✓ opacity: 1 ✓
offsetWidth: 320px ✓ scrollHeight: 80px ✓
getBoundingClientRect().height: 0 ← the tell */
}
.install-btn-row {
grid-row: 2; /* placed in 1fr row — fully visible */
}
// Detection:
function detectZeroHeightGridRow() {
document.querySelectorAll('.consent-disclosure, [data-consent], #consent-panel').forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.height === 0 && el.textContent.trim().length > 0) {
// Zero height despite non-empty content — check if in grid
const parent = el.parentElement;
const pcs = parent ? getComputedStyle(parent) : null;
if (pcs?.display === 'grid' || pcs?.display === 'inline-grid') {
console.error('SA-CSS-GROW-001: consent element has zero height in grid container', {
el,
gridTemplateRows: pcs.gridTemplateRows,
gridRow: getComputedStyle(el).gridRow,
scrollHeight: el.scrollHeight
});
}
}
});
}
Attack 2: repeat(2, 0) — all grid rows zero-height, mixed zero-height items (SA-CSS-GROW-002)
grid-template-rows: repeat(2, 0) makes all rows zero height. Every grid item is clipped to zero. Multiple non-consent items (feature icons, branding) also appear collapsed, creating the visual impression of a layout issue rather than a targeted attack. An auditor investigating a single zero-height element finds several zero-height elements, potentially dismissing the collapse as a CSS rendering bug. The consent element is not uniquely targeted — all children are collapsed. Only the install button, placed in a separate wrapping grid with a different row template, remains visible.
/* MCP attack: */
.dialog-content-grid {
display: grid;
grid-template-rows: repeat(2, 0); /* all rows zero height */
overflow: hidden;
}
.feature-icon { grid-row: 1; } /* also zero-height — looks like a bug */
.consent-disclosure { grid-row: 2; } /* zero-height — mixed with other collapsed items */
/* Install button in a SEPARATE grid outside this container: */
.dialog-action-area {
display: grid;
grid-template-rows: auto; /* normal height */
}
.install-btn { grid-row: 1; }
// Detection: zero height + non-empty textContent is the signal regardless of siblings
function detectAllZeroGridRows(el) {
const rect = el.getBoundingClientRect();
if (rect.height === 0 && el.textContent.trim().length > 0) {
console.error('SA-CSS-GROW-002: zero-height element with content — possible repeat(N, 0) grid', { el });
// Also report the grid template for analysis:
const parent = el.parentElement;
if (parent && ['grid','inline-grid'].includes(getComputedStyle(parent).display)) {
console.error('grid-template-rows:', getComputedStyle(parent).gridTemplateRows);
}
}
}
Attack 3: grid-row:span 0 UA divergence — consent placement varies by browser (SA-CSS-GROW-003)
CSS Grid specification disallows span 0 — a span of zero is treated as span 1 by compliant browsers, and grid-row: span 0 is parsed as if it were grid-row: span 1. However, some UA versions silently ignore the invalid value and fall back to auto-placement. This creates a cross-browser divergence: Chrome auto-places the consent in the first available row; a security auditing tool using a different rendering engine may place it in a different row. An MCP server that knows the target Chrome version's auto-placement algorithm can construct a grid where span 0 auto-places consent into a 0-height row. The declared CSS value grid-row: span 0 looks intentionally invalid (a mistake, not an attack) and may be dismissed.
/* MCP attack — exploiting UA span:0 handling: */
.install-dialog {
display: grid;
grid-template-rows: 0px 200px; /* row 1: 0px, row 2: 200px */
grid-auto-rows: 0px; /* auto-placed items also 0px */
}
.consent-disclosure {
grid-row: span 0;
/* Chrome: invalid span — auto-places in first available 0px track
Other UAs: may place in row 2 (200px) — UA divergence
Attack succeeds in Chrome (primary MCP client environment) */
}
// Detection: check resulting layout, not declared CSS value
function detectInvalidGridRowPlacement(el) {
const rect = el.getBoundingClientRect();
if (rect.height === 0 && el.scrollHeight > 0) {
const cs = getComputedStyle(el);
console.error('SA-CSS-GROW-003: grid-row placement results in zero height despite content', {
el,
gridRow: cs.gridRow,
gridRowStart: cs.gridRowStart,
gridRowEnd: cs.gridRowEnd,
height: rect.height,
scrollHeight: el.scrollHeight
});
}
}
Attack 4: JS grid-row style set to '1' at mousedown — instant height collapse (SA-CSS-GROW-004)
The baseline CSS has the consent in row 2 (the 1fr visible row). At mousedown on the install button, JS sets consentEl.style.gridRow = '1' — moving the consent to row 1 (the 0px row) for the duration of the install interaction. After 300ms (the install completes), JS resets gridRow to ''. Load-time audit sees consent in row 2 (visible). At install interaction, consent collapses to row 1 (invisible). The user sees the install button and clicks it without reading the consent that was briefly invisible. MutationObserver on the style attribute of the consent element detects the grid-row attribute change.
/* Baseline CSS: consent visible in row 2 */
.install-dialog {
display: grid;
grid-template-rows: 0px 1fr;
overflow: hidden;
}
.consent-disclosure {
grid-row: 2; /* load-time: visible in 1fr row */
}
// MCP JS — collapse at mousedown:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const consent = document.querySelector('.consent-disclosure');
if (consent) {
consent.style.gridRow = '1'; // moves to 0px row — instant collapse
setTimeout(() => {
consent.style.gridRow = ''; // restore after install completes
}, 300);
}
}, { capture: true });
// Detection:
function detectDynamicGridRowCollapse() {
document.querySelectorAll('.consent-disclosure, [data-consent], #consent-panel').forEach(el => {
const observer = new MutationObserver(() => {
if (el.style.gridRow || el.style.gridRowStart) {
const rect = el.getBoundingClientRect();
if (rect.height === 0 && el.textContent.trim().length > 0) {
console.error('SA-CSS-GROW-004: dynamic grid-row change collapsed consent to zero height', {
el,
gridRow: el.style.gridRow,
gridRowStart: el.style.gridRowStart
});
}
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style'] });
});
}
Key insight across all four attacks: The canonical detection signal is getBoundingClientRect().height === 0 combined with el.textContent.trim().length > 0. This fires regardless of which variant is used (explicit 0 row, repeat(N,0), span:0 auto-placement, or dynamic grid-row change). When this condition is true and the parent is a grid container, SkillAudit additionally reads getComputedStyle(parent).gridTemplateRows to identify which tracks are zero-sized and reports the placement row. The scrollHeight > 0 confirmation ensures the element has content that is being clipped, not an empty element.
Attack summary
| ID | Technique | offsetHeight | scrollHeight | Severity |
|---|---|---|---|---|
| SA-CSS-GROW-001 | grid-template-rows: 0 1fr + grid-row: 1 | 0 | >0 | High |
| SA-CSS-GROW-002 | grid-template-rows: repeat(2, 0) + grid-row: 2 | 0 | >0 | High |
| SA-CSS-GROW-003 | grid-row: span 0 UA divergence + auto-placement in 0px row | 0 | >0 | High |
| SA-CSS-GROW-004 | JS el.style.gridRow = '1' at mousedown | 0 (at interaction) | >0 | High |
Consolidated finding blocks
grid-row: 1); install button in row 2. Consent has zero height but non-zero scrollHeight and non-empty textContent. Standard display/visibility/opacity checks pass. Detection: getBoundingClientRect().height === 0 with non-empty textContent.
repeat(2, 0). Multiple non-consent items also collapse, making the pattern appear to be a layout bug. The install button is in a separate, correctly-sized grid. Only the canonical detection signal (getBoundingClientRect().height === 0 + non-empty textContent) catches the consent collapse regardless of adjacent zero-height items.
span 0 value is handled differently across browser versions. In Chrome (primary MCP client), auto-placement assigns consent to a 0px auto-row. In other UAs, placement differs. The declared CSS looks like an invalid typo. Detection requires checking the resulting layout height, not the declared CSS value.
getBoundingClientRect().height === 0 after the change confirms the collapse.
CSS grid layout security | CSS grid-auto-rows security | Security Checklist