Security reference · CSS injection · Grid layout attacks · Explicit placement

MCP server CSS grid-column security

CSS Grid's explicit placement properties — grid-column, grid-row, and grid-area — let any grid item be placed at any position in the grid, including positions that overflow the visible container, collapse to zero size, or land in named grid holes. MCP servers exploit explicit grid placement to move consent disclosures out of the user's view without using display:none, visibility:hidden, or opacity:0. The consent grid item exists in the DOM, has a normal computed display value, and passes most static accessibility checks — but is rendered outside the viewport or in a zero-area cell.

CSS Grid explicit placement attack surface

Placement propertyAttack patternEffect on consent
grid-row: NLarge explicit row number (e.g. 100) places consent in an implicit row far below the visible gridConsent rendered many viewport heights below install form; container with overflow:hidden clips it
grid-column: -1Negative column line references place consent in the last implicit column; with RTL or constrained width, this overflows off-screenConsent placed at the right edge of or beyond the explicit grid; clipped by container overflow
grid-area: 1 / 2 / 2 / 2Row-start/column-start/row-end/column-end where column-start equals column-end creates a zero-width grid areaConsent grid item collapses to zero width — element exists but has no rendered width; text invisible
grid-area: hole + grid-template-areasNamed grid area defined as . (empty/implicit) region; consent placed in undefined named areaIn some browsers, item placed in area name that matches no region defaults to auto-placement in unexpected row

Grid placement attacks evade conventional consent scanners: Tools that check computed display, computed visibility, and opacity will report consent grid items as fully visible. The consent element is rendered — just in a grid cell that is outside the viewport or has collapsed dimensions. Detection requires checking the element's getBoundingClientRect() against viewport bounds, or inspecting grid-row / grid-column computed values for large explicit positions.

Attack 1: grid-row: 100 — implicit row displacement off-screen

CSS Grid generates implicit rows automatically when grid items are placed beyond the defined explicit grid. Setting grid-row: 100 on a consent element forces the browser to generate 99 implicit rows above it — each with a default auto row height (typically 0 or based on content). In a constrained grid container with overflow: hidden, the consent lands far below the visible area. The install form uses grid-row: 1, remaining at the top:

/* Malicious CSS — SA-CSS-GRIDCOL-001 */
/* Install grid: two children — form (row 1) and consent (row 100) */
.mcp-install-grid {
  display: grid;
  grid-template-columns: 1fr;
  /* No explicit grid-template-rows — implicit rows are auto-generated */
  overflow: hidden;
  max-height: 300px; /* shows form rows but consent at row 100 is off-screen */
}

.mcp-install-form {
  grid-row: 1;   /* First row — visible */
}

.mcp-consent-disclosure {
  grid-row: 100; /* Row 100 — browser generates 99 implicit rows above it */
                 /* Default auto row height: 0px per empty implicit row     */
                 /* Consent lands at y ≈ 0 * 98 = 0px below form... wait    */
                 /* In practice: auto rows between items have 0 height,      */
                 /* but the items in those rows accumulate.                  */
                 /* If grid-auto-rows: 1px, then 99 rows * 1px = 99px below  */
                 /* With grid-auto-rows: 100px, consent is at y = 9900px     */
}

.mcp-install-grid {
  display: grid;
  grid-auto-rows: 100px; /* each implicit row is 100px tall */
  overflow: hidden;
  height: 250px;
}

/* With grid-auto-rows:100px and overflow:hidden height:250px:
   - grid-row:1 form: visible at y=0..200px (within 250px)
   - grid-row:100 consent: at y = 99 * 100px = 9900px — far below visible area
   - overflow:hidden clips everything after 250px
   - Consent in DOM, computed display:block, but entirely off-screen */

/* Detection: check for large explicit grid-row values on consent elements */
function detectGridRowDisplacementAttack() {
  const findings = [];
  const candidates = document.querySelectorAll('[class*="consent"], [class*="disclosure"], [class*="terms"]');
  for (const el of candidates) {
    if (!/consent|disclosure|terms|privacy|by installing|by clicking/i.test(el.textContent || '')) continue;
    const style = getComputedStyle(el);
    /* gridRowStart is the computed value of grid-row start line */
    const rowStart = parseInt(style.gridRowStart, 10);
    if (!isNaN(rowStart) && rowStart > 5) {
      findings.push({ id: 'SA-CSS-GRIDCOL-001', severity: 'high',
        message: `Consent element has grid-row-start: ${rowStart} — placed in implicit row far below visible grid. Check parent overflow for clipping.` });
    }
    /* Also check via getBoundingClientRect */
    const rect = el.getBoundingClientRect();
    if (rect.top > window.innerHeight * 3 || rect.bottom < -window.innerHeight) {
      findings.push({ id: 'SA-CSS-GRIDCOL-001', severity: 'critical',
        message: `Consent element bounding rect (top: ${Math.round(rect.top)}px) is far outside viewport — grid row displacement attack` });
    }
  }
  return findings;
}

Empty implicit rows have zero height by default: If grid-auto-rows is not set, implicit rows between the form and consent item have auto height. If those rows have no content, their height is 0, and the consent item ends up immediately below the form — visible. The attack only succeeds when combined with either (a) explicit grid-auto-rows setting, (b) other items filling intermediate rows, or (c) a large gap / row-gap. Auditors must check both the grid placement value AND the resulting rendered position.

Attack 2: grid-column: span 0 / zero-width column collapse

Setting a grid item's column span so that its start and end line are the same — grid-column: 2 / 2 — creates a zero-width placement. The grid item exists in the grid and in the DOM, but it occupies no column tracks and therefore has zero rendered width. Its text is present in the DOM but invisible because the layout box has width: 0. With overflow: hidden (default on grid items in some browsers) or simply because text cannot render in a zero-width box without explicit overflow settings, the consent text disappears:

/* Malicious CSS — SA-CSS-GRIDCOL-002 */
/* grid-area shorthand: row-start / column-start / row-end / column-end */
/* When column-start === column-end, the item spans zero column tracks  */
.mcp-consent-disclosure {
  grid-area: 1 / 2 / 2 / 2;
  /* row 1, column 2 to column 2 → zero-width column span */
  /* Item is placed in the grid but spans no horizontal space */
  overflow: hidden; /* text cannot overflow the zero-width box */
}

/* Alternative using explicit grid-column property: */
.mcp-consent-disclosure {
  grid-column: 2 / 2; /* column-start: 2, column-end: 2 — zero span */
  grid-row: 1;
}

/* What happens in the browser:
   - The grid item's inline size (width in row writing mode) = 0
   - Text content is technically in the layout box but has no space to render
   - overflow:hidden (or default overflow) clips the text
   - getBoundingClientRect().width === 0
   - The element is NOT display:none — its display is grid-item (block-level)
   - Accessibility tree still contains the text
   - Screen readers MAY read the text depending on the implementation
   - Visual users see nothing

/* Note: Most browsers treat span < 1 as span 1 for grid-span shorthand.
   The attack uses the explicit line number form (start-line / end-line)
   where both lines are the same number — this is not the span keyword form.

/* Detection: check for zero-width or zero-height grid items containing consent */
function detectGridZeroSpanAttack() {
  const findings = [];
  const candidates = document.querySelectorAll('[class*="consent"], [class*="disclosure"], [class*="terms"]');
  for (const el of candidates) {
    if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
    const rect = el.getBoundingClientRect();
    const style = getComputedStyle(el);
    /* Zero-width or zero-height grid item with consent text */
    if ((rect.width === 0 || rect.height === 0) && style.display !== 'none') {
      findings.push({ id: 'SA-CSS-GRIDCOL-002', severity: 'critical',
        message: `Consent element has zero rendered ${rect.width === 0 ? 'width' : 'height'} (${Math.round(rect.width)}×${Math.round(rect.height)}px) but is not display:none. CSS: grid-column: ${style.gridColumnStart}/${style.gridColumnEnd}, grid-row: ${style.gridRowStart}/${style.gridRowEnd}` });
    }
  }
  return findings;
}

Attack 3: negative grid-column line — implicit column overflow

CSS Grid negative line numbers count from the end of the explicit grid backward. grid-column: -1 refers to the last explicit column line. However, when the explicit grid has only one column, grid-column: -1 places the item starting at the far-right edge of that single column — effectively taking up zero columns (line -1 is the same as the far end of the only track). Combined with a direction change or an overflow-constrained container, this can push the consent element to the rightmost edge of the container or beyond:

/* Malicious CSS — SA-CSS-GRIDCOL-003 */
/* grid-template-columns defines 2 columns: main content + sidebar */
.mcp-install-grid {
  display: grid;
  grid-template-columns: 300px 1fr; /* 2-column explicit grid */
  overflow: hidden;
  width: 300px; /* container only as wide as the first column */
}

.mcp-install-form {
  grid-column: 1; /* placed in first column — visible */
}

.mcp-consent-disclosure {
  grid-column: -1; /* last explicit column line = start of column 2 */
                   /* Item placed starting at column line 2 = second column */
                   /* Second column (1fr) starts at 300px = beyond container width */
                   /* Container overflow:hidden clips the second column entirely */
                   /* Consent is in the DOM but entirely outside the 300px container */
}

/* Why this appears legitimate:
   - grid-column: -1 is valid CSS used to make an item span to the end
   - The explicit grid having 2 columns is common for sidebar layouts
   - overflow:hidden on the container looks like a standard layout constraint
   - No display:none / visibility:hidden anywhere
   - The consent element's display is block (grid item — block-level)

/* Variant: RTL direction exploitation */
.mcp-install-grid {
  display: grid;
  grid-template-columns: 1fr;
  direction: rtl; /* reverses column direction */
  /* With rtl: column 1 is on the RIGHT; column -1 is on the LEFT edge */
  /* For an LTR user, the RTL grid pushes items in unexpected directions */
  overflow: hidden;
}

.mcp-consent-disclosure {
  /* auto-placement in RTL puts consent on the right (visually left for LTR) */
  /* depending on container constraints, consent may overflow off-screen left */
}

/* Detection: check for consent elements rendered outside the viewport horizontally */
function detectGridColumnOverflowAttack() {
  const findings = [];
  const candidates = document.querySelectorAll('[class*="consent"], [class*="disclosure"], [class*="terms"]');
  for (const el of candidates) {
    if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
    const rect = el.getBoundingClientRect();
    const vw = window.innerWidth;
    if (rect.left >= vw || rect.right <= 0) {
      const style = getComputedStyle(el);
      findings.push({ id: 'SA-CSS-GRIDCOL-003', severity: 'high',
        message: `Consent element is horizontally off-screen (left:${Math.round(rect.left)}px, viewport width:${vw}px). grid-column: ${style.gridColumnStart}/${style.gridColumnEnd} — negative column line or overflow placement attack.` });
    }
  }
  return findings;
}

Attack 4: grid-template-areas dot-hole — consent in undefined grid region

The grid-template-areas property uses dot (.) to define empty, unnamed cells in a named-area grid. A grid item assigned to a named area that does not appear in grid-template-areas is placed via the auto-placement algorithm — typically after the explicitly named items, in an implicit row below the visible grid. MCP defines a clean grid layout in grid-template-areas for the install form and uses a different area name for consent that maps to no defined area:

/* Malicious CSS — SA-CSS-GRIDCOL-004 */
/* grid-template-areas defines named regions for the install form */
.mcp-install-grid {
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: auto auto auto;
  grid-template-areas:
    "title"
    "form"
    "button";
  /* NOTE: "consent" is NOT defined in grid-template-areas */
  overflow: hidden;
  max-height: 400px;
}

.mcp-install-title    { grid-area: title; }
.mcp-install-form     { grid-area: form; }
.mcp-install-button   { grid-area: button; }

.mcp-consent-disclosure {
  grid-area: consent; /* "consent" area is not defined in grid-template-areas */
                      /* Browser auto-places this item after the explicit grid */
                      /* It lands in an implicit row below the 3 explicit rows */
                      /* With overflow:hidden max-height:400px, it is clipped  */
}

/* Why this is difficult to detect statically:
   - The grid-template-areas looks reasonable (title/form/button)
   - grid-area: consent looks like a deliberate design choice
   - Nothing about "consent" as a named area is suspicious in isolation
   - The implicit row that holds the mismatched area is not documented in CSS
   - Only checking the RENDERED position reveals the displacement

/* Dot-hole variant: consent placed in an explicit dot cell */
.mcp-install-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-areas:
    "form   form"
    "button button"
    ".      .";     /* Row 3 is a named empty row using dot notation */
}

.mcp-consent-disclosure {
  grid-column: 1 / 3; /* spans both columns of row 3 */
  grid-row: 3;        /* explicitly placed in the empty dot row */
}

/* Container constrains to show rows 1-2 only; row 3 (consent) is clipped */

/* Detection: check for grid items with area names not in grid-template-areas */
function detectGridAreaMismatchAttack() {
  const findings = [];
  const grids = document.querySelectorAll('*');
  for (const grid of grids) {
    const style = getComputedStyle(grid);
    if (style.display !== 'grid' && style.display !== 'inline-grid') continue;
    const templateAreas = style.gridTemplateAreas;
    if (!templateAreas || templateAreas === 'none') continue;
    for (const child of grid.children) {
      if (!/consent|disclosure|terms|privacy/i.test(child.textContent || '')) continue;
      const childStyle = getComputedStyle(child);
      const areaName = childStyle.gridArea;
      /* If child has a named grid-area, check it appears in parent's template */
      if (areaName && areaName !== 'auto' && !areaName.includes('/')) {
        if (!templateAreas.includes(`"${areaName}"`)) {
          findings.push({ id: 'SA-CSS-GRIDCOL-004', severity: 'high',
            message: `Consent child has grid-area:"${areaName}" but parent grid-template-areas does not define this region — auto-placement may push consent outside visible grid` });
        }
      }
      /* Also check rendered position */
      const rect = child.getBoundingClientRect();
      const gridRect = grid.getBoundingClientRect();
      if (rect.top > gridRect.bottom || rect.bottom < gridRect.top) {
        findings.push({ id: 'SA-CSS-GRIDCOL-004', severity: 'critical',
          message: `Consent element is positioned outside its parent grid container bounds — grid-template-areas displacement or implicit row overflow attack` });
      }
    }
  }
  return findings;
}

Grid explicit placement is invisible to source-order scanners: Static HTML analysis that checks DOM source order will find consent elements appearing after install buttons in the markup — which looks correct. The grid placement properties reorder elements visually, not structurally. An auditor reading the HTML source may see the correct order; only checking rendered grid placement (via DevTools grid overlay or getBoundingClientRect()) reveals the displacement.

SkillAudit findings for CSS grid-column consent attacks

HighSA-CSS-GRIDCOL-001 — grid-row: 100 (or any large explicit row) places consent in an implicit row far below the visible grid. Combined with overflow: hidden and a fixed height on the grid container, consent is clipped. Computed display is block; computed visibility is visible. Detection requires checking getBoundingClientRect() against viewport bounds or inspecting grid-row-start computed value.
CriticalSA-CSS-GRIDCOL-002 — grid-column: 2 / 2 or grid-area: 1/2/2/2 places consent in a zero-width grid area. Text is in the DOM but rendered in a box with width zero. Passes display:none and visibility checks. Detected by checking getBoundingClientRect().width === 0 on elements with consent-related text.
HighSA-CSS-GRIDCOL-003 — grid-column: -1 with a multi-column explicit grid and constrained container width. Negative column line references the far end of the explicit grid; combined with overflow clipping, consent overflows horizontally off-screen. Detection: getBoundingClientRect().left >= viewport.width.
HighSA-CSS-GRIDCOL-004 — grid-area: consent assigned to consent element when grid-template-areas does not define a "consent" region. Auto-placement pushes item into implicit rows below the explicit grid. With overflow clipping, consent is cut off. Detection: verify every grid-area name in child elements appears in parent's grid-template-areas.

Related MCP consent attack research

Audit your MCP server for grid-column consent displacement attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-GRIDCOL findings.