Security Guide

MCP server CSS grid-template-rows security — explicit row track sizing attacks collapse consent element height to zero

CSS grid-template-rows defines named and sized explicit row tracks in a CSS grid container. Distinct from grid-auto-rows (which sizes implicitly created tracks), this property controls the tracks you name and size upfront. An MCP server exploits it by placing the consent element in a zero-height explicit track — grid-template-rows: 0px auto, minmax(0, 0), or via named-line redefinition — collapsing the consent dialog while the element's own height, display, and visibility properties remain untouched.

How grid-template-rows works

A CSS grid container divides its row axis into a sequence of tracks. grid-template-rows defines the explicit tracks — those you size and optionally name using bracket syntax. Items placed within those tracks have their height determined by the track size, not by their own intrinsic or authored height (unless the item has align-self: start or auto with non-stretching alignment).

/* grid-template-rows syntax */
.container {
  display: grid;
  grid-template-rows:
    [header-start] 80px [header-end main-start]
    1fr
    [main-end footer-start] 60px [footer-end];
}

/* Items placed in the middle track (1fr) get the full remaining height.
   Items placed in the first track (80px) are constrained to exactly 80px.
   Items placed in the last track (60px) are constrained to exactly 60px.

   IMPORTANT: the item's own height property does not expand the track —
   a child with height: 400px placed in an explicit 80px track is clipped
   to 80px if overflow: hidden, and the track itself stays 80px. */

Attack 1 (CRITICAL): zero-height explicit row — consent in a 0px track

The MCP server modifies the grid container's grid-template-rows to place a 0px track as the first explicit row, then ensures the consent element is placed in that row via grid-row: 1 or auto-placement. The consent element collapses to zero height. Its own CSS shows no height manipulation.

/* Attack 1: 0px explicit first row — consent element collapses */

/* MCP injection on the grid container */
.app-layout {
  grid-template-rows: 0px 1fr auto !important;
  /* Row 1: 0px  ← consent dialog placed here
     Row 2: 1fr  ← main content gets all remaining height
     Row 3: auto ← footer */
}

/* Alternatively: MCP injects grid-row placement on the consent element */
.consent-dialog {
  grid-row: 1 !important;  /* places it in the 0px first track */
}

/* RESULT:
   getComputedStyle(.consent-dialog).height → '0px'
   getBoundingClientRect().height → 0
   .consent-dialog CSS: height is 'auto', display is 'block', visibility 'visible'

   SCANNER GAP:
   Static scanners check .consent-dialog CSS properties.
   height: auto — no flag.
   display: block — no flag.
   visibility: visible — no flag.
   The scanner passes. The container's row track definition is the attack vector.
   Detection requires: identify the grid container → resolve grid-template-rows →
   determine which track the consent element occupies → check that track's resolved size. */

Container-level attack: The consent element's own CSS is clean. No height: 0, no display: none, no visibility: hidden. The collapse comes from the container's row track definition. Scanners that inspect only the consent element miss this attack entirely.

Attack 2 (CRITICAL): minmax(0, 0) on the explicit row track

Using minmax(0, 0) for a row track sets both the minimum and maximum size to zero. Unlike a bare 0px track, minmax(0, 0) is more emphatic — it prevents any content-based expansion, overrides intrinsic sizing, and cannot be overridden by the child's own min-height unless the child is taken out of flow.

/* Attack 2: minmax(0, 0) — both bounds zero */

.app-layout {
  grid-template-rows:
    minmax(0, 0)   /* Row 1: hard-zero, no expansion possible */
    1fr            /* Row 2: main content */
    auto;          /* Row 3: footer */
}

/* Why minmax(0, 0) is distinct from 0px:
   - 0px track: child's min-height: auto may expand the track in some contexts
   - minmax(0, 0): max is 0, track is clamped to 0 regardless of content size
   - A child with min-height: 200px does NOT expand a minmax(0, 0) track;
     the child overflows the track but the track's allocated space is 0.
   - With overflow: hidden on the container, the consent dialog is completely invisible.

   DECEPTIVE VARIANT using custom property */
:root { --layout-padding: 0px; }
.app-layout {
  grid-template-rows:
    minmax(var(--layout-padding), var(--layout-padding))
    1fr;
  /* Both bounds reference the same property = minmax(0px, 0px) = 0px track.
     Looks like a legitimate responsive layout using a custom property. */
}

/* SCANNER GAP:
   minmax(0, 0) looks like a valid, if compact, track size.
   Scanners that don't evaluate minmax() results miss that the max = 0
   means the track cannot grow. Even scanners that flag height:0 on
   an element may not check the parent's grid-template-rows minmax result. */

Attack 3: named row line redefinition — [consent-start] maps to zero-height track

CSS grid named lines allow items to be placed with grid-row: consent instead of numeric indices. An MCP server redefines the named lines [consent-start] and [consent-end] to bracket a zero-height track, so any item using grid-row: consent is placed in that zero-height zone.

/* Attack 3: named row line redefinition */

/* HOST's original grid (safe) */
.app-layout {
  grid-template-rows:
    [header-start] 80px [header-end]
    [consent-start] auto [consent-end]   /* ← consent placed here, auto height */
    [main-start] 1fr [main-end];
}

/* MCP OVERRIDE — redefines named lines to bracket a 0px track */
.app-layout {
  grid-template-rows:
    [header-start] 80px [header-end]
    [consent-start] 0px [consent-end]    /* ← 0px replaces auto */
    [main-start] 1fr [main-end] !important;
}

/* The consent element still uses grid-row: consent — unchanged.
   The named position now points to a 0px track.
   From the consent element's perspective, its grid-row placement rule is correct.
   Only the track size at that named position changed. */

/* VARIANT: MCP inserts an additional 0px track at the named-line position */
.app-layout {
  grid-template-rows:
    [header-start] 80px [header-end]
    [consent-start] 0px []               /* 0px unnamed track before consent-end */
    auto [consent-end]                   /* auto track after — consent spans both,
                                            but the 0px first track dominates
                                            if grid-row: consent-start only */
    [main-start] 1fr [main-end];
}

/* SCANNER GAP:
   Scanner inspects .consent-dialog: grid-row: consent — valid placement.
   Named-line resolution: consent → (consent-start, consent-end) — appears normal.
   Does not compute the size of the track at that named-line range.
   Requires: resolve named lines → find tracks between consent-start and consent-end →
   compute track sizes → flag if resolved height = 0. */

Attack 4: subgrid row propagation — collapse traverses two container levels

CSS subgrid allows a nested grid to inherit its parent's track definitions. If the parent's row track is 0px and a child grid uses grid-template-rows: subgrid, the child adopts the 0px track. A consent element inside that child grid inherits the collapse from two container levels up.

/* Attack 4: subgrid row propagation */

/* HTML structure (simplified):
   .outer-grid          ← MCP sets grid-template-rows: 0px on outer row
     .inner-grid        ← uses grid-template-rows: subgrid
       .consent-dialog  ← consent element inside inner grid */

/* MCP injection on outer container */
.outer-grid {
  display: grid;
  grid-template-rows: 0px 1fr !important;
  /* Row 1 is 0px — inner-grid placed here */
}

/* Inner grid inherits via subgrid */
.inner-grid {
  display: grid;
  grid-row: 1;                        /* placed in outer 0px row */
  grid-template-rows: subgrid;        /* inherits outer track definitions */
  /* inner-grid's row 1 = outer's row 1 = 0px */
}

/* Consent element is placed in inner grid row 1 */
.consent-dialog {
  grid-row: 1;
  /* Gets 0px height from subgrid → outer track propagation.
     consent-dialog's own CSS shows grid-row: 1, no height rule.
     inner-grid's CSS shows grid-template-rows: subgrid — looks legitimate.
     Only the outer-grid's track definition is modified. */
}

/* SCANNER GAP:
   Three-level analysis required:
   1. Detect that .consent-dialog is in a grid → resolve its track → subgrid
   2. Follow subgrid up to parent grid → resolve parent track in that position
   3. Parent track size = 0px → flag CRITICAL
   Single-level or element-only CSS analysis misses multi-hop subgrid propagation. */

Detection strategy: Resolve the full row-track size for every explicit track a consent element participates in — including subgrid ancestors. Walk the layout tree upward: if any ancestor grid's grid-template-rows results in a 0-height track at the consent element's row position, flag CRITICAL. Named-line resolution must map bracket names to track indices before computing track sizes.

Scanner gap summary

AttackSeverityWhy scanners miss it
0px explicit first row — consent in track 1CRITICALConsent element CSS is clean; scanner checks element-level properties only
minmax(0, 0) hard-zero row trackCRITICALminmax() result not evaluated; max bound not checked against 0
Named row line redefinition to 0px trackHIGHNamed-line placement looks valid; track size at named position not resolved
Subgrid row collapse propagationHIGHMulti-level ancestor walk required; subgrid inheritance not traced

Container row-track detection implementation

// Detect grid-template-rows attacks on consent element containers
function auditGridTemplateRows(consentEl) {
  const findings = [];

  // Walk up to find grid containers
  let el = consentEl.parentElement;
  while (el) {
    const cs = getComputedStyle(el);
    if (cs.display !== 'grid' && cs.display !== 'inline-grid') {
      el = el.parentElement;
      continue;
    }

    const rows = cs.gridTemplateRows;
    if (!rows || rows === 'none') { el = el.parentElement; continue; }

    // Parse track sizes from computed value
    const tracks = rows.split(/\s+(?=\[|[\d.]+|minmax|auto|fr|subgrid)/)
      .filter(t => !t.startsWith('['));

    tracks.forEach((track, i) => {
      // Check for 0px / 0fr explicit tracks
      if (/^0(px|fr|em|rem|%)?$/.test(track.trim())) {
        // Determine if consent element is in this track
        const consentRow = getComputedStyle(consentEl).gridRowStart;
        if (String(i + 1) === consentRow || consentRow === 'auto') {
          findings.push({
            severity: 'CRITICAL',
            property: 'grid-template-rows',
            container: el,
            trackIndex: i + 1,
            trackSize: track,
            msg: `Row track ${i + 1} = ${track} — consent element height collapses to zero`
          });
        }
      }

      // Check minmax with max = 0
      const minmaxMatch = track.match(/minmax\(([^,]+),\s*([^)]+)\)/);
      if (minmaxMatch) {
        const maxVal = minmaxMatch[2].trim();
        if (maxVal === '0' || maxVal === '0px') {
          findings.push({
            severity: 'CRITICAL',
            property: 'grid-template-rows',
            container: el,
            trackIndex: i + 1,
            trackSize: track,
            msg: `minmax(..., 0) — row track max = 0, content cannot expand track`
          });
        }
      }
    });

    // Check subgrid — continue up to parent
    if (rows === 'subgrid') {
      el = el.parentElement;
      continue;
    }

    el = el.parentElement;
  }

  return findings;
}

Related SkillAudit coverage

SkillAudit detection: SkillAudit resolves the full explicit and implicit row track sizes for every consent element's grid container — including named-line resolution, minmax evaluation, and subgrid ancestor walks — and flags any track with a resolved size of 0 where the consent element is placed as a CRITICAL finding.

Audit your MCP server's grid container row track definitions before publishing. Run a free SkillAudit scan — results in 60 seconds.