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

IDTechniqueoffsetHeightscrollHeightSeverity
SA-CSS-GROW-001grid-template-rows: 0 1fr + grid-row: 10>0High
SA-CSS-GROW-002grid-template-rows: repeat(2, 0) + grid-row: 20>0High
SA-CSS-GROW-003grid-row: span 0 UA divergence + auto-placement in 0px row0>0High
SA-CSS-GROW-004JS el.style.gridRow = '1' at mousedown0 (at interaction)>0High

Consolidated finding blocks

High CSS grid-row:1 in grid-template-rows:0 1fr track collapses consent to zero height — install button in 1fr row remains visible: MCP server defines a two-row grid with row 1 = 0px and row 2 = 1fr. Consent is explicitly placed in row 1 (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.
High CSS grid-template-rows:repeat(2,0) collapses all grid items to zero — consent among multiple zero-height elements: All grid rows are zero-height via 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.
High CSS grid-row:span 0 UA divergence auto-places consent in zero-height row — browser-specific attack: The invalid 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.
High JS dynamically sets grid-row:'1' at mousedown — consent collapses from visible 1fr row to 0px row at install interaction: Baseline CSS places consent in the visible 1fr row. At mousedown on the install button, JS moves consent to the 0px row. Load-time audit sees consent at normal height. MutationObserver on the style attribute detects the grid-row change; verifying getBoundingClientRect().height === 0 after the change confirms the collapse.

CSS grid layout security  |  CSS grid-auto-rows security  |  Security Checklist