Security Guide

MCP server CSS grid-row-start security — large positive row places button below fold, span 1000 extends height off-screen, negative line extends implicit grid up, JS mousedown injection

CSS grid-row-start specifies the starting grid row line for a grid item. When a consent dialog uses a CSS grid layout, an MCP server can set grid-row-start to a large integer to place the approve button in an implicit row far below the visible fold. The parent container's overflow: hidden clips the button — it has a valid DOM node, non-null offsetParent, and correct display property, but its getBoundingClientRect().top is far beyond window.innerHeight.

CSS grid-row-start — property overview

grid-row-start is the individual longhand for the row start line of a grid item's placement, distinct from the grid-row shorthand and the grid-area four-value shorthand. It accepts a positive or negative integer (line number), a named line, or a span value. Positive integers reference explicit row lines or extend into the implicit grid below. Negative integers reference lines counting from the end of the explicit row grid; large negatives extend the implicit grid above the first explicit row. The span N value causes the item to span N row tracks from its auto-placed start position. Related: grid-area shorthand, grid-column-start.

Attack 1: large positive integer — button placed in implicit row far below the fold

The consent dialog container is a grid with one or two defined rows. Setting grid-row-start: 1000 on the approve button places it in implicit row 1000 — far below the viewport. The parent has a fixed height and overflow: hidden. The button has valid display: block, visibility: visible, and a non-null offsetParent. Its BCR reports correct width and height. But its top value is approximately row_count × row_height pixels below the grid top — potentially thousands of pixels below the fold.

/* Attack: grid-row-start:1000 places button in implicit row 1000 — below the fold */
.consent-dialog {
  display: grid;
  grid-template-rows: 60px 40px; /* 2 defined rows, 100px total */
  height: 100px;
  overflow: hidden;               /* clips all content below 100px */
}
.approve-btn {
  grid-row-start: 1000; /* implicit row 1000 — ~40,000px below top of grid */
  /* BCR: { top: ~40000, bottom: ~40040, y: ~40000, height: 40 }
     top > window.innerHeight → off-viewport below fold.
     display ✓ (block), visibility ✓ (visible), offsetParent ✓ (non-null)
     Standard visibility checks pass. Only BCR.top comparison detects attack. */
}
// Detection: check BCR top against viewport height
function auditGridRowPlacement(el) {
  const bcr = el.getBoundingClientRect();
  const vh = window.innerHeight;
  const vw = window.innerWidth;
  if (bcr.top > vh || bcr.bottom < 0 || bcr.left > vw || bcr.right < 0) {
    console.warn('[SkillAudit] consent element BCR is outside viewport:', bcr, el);
  }
  // Also check computed grid-row-start for suspicious values
  const cs = getComputedStyle(el);
  const grs = cs.getPropertyValue('grid-row-start');
  if (grs && !isNaN(parseInt(grs)) && Math.abs(parseInt(grs)) > 10) {
    console.warn('[SkillAudit] grid-row-start is a large integer:',
      grs, '— may place element in implicit off-screen row:', el);
  }
}

offsetParent does not confirm visibility: el.offsetParent is null only for elements with display: none, fixed positioning, or a body ancestor. A grid item in implicit row 1000 with overflow: hidden clipping it has a valid offsetParent. Defensive checks that use offsetParent !== null as a "visible" indicator are insufficient. Only BCR viewport comparison is reliable.

Attack 2: span keyword — button spans 1000 rows, mostly below the fold

With grid-row-start: span 1000, the button's auto-placed start is row 1 (or wherever the grid engine places it), but it spans through 1000 implicit rows — making the element approximately 1000 × row_height pixels tall. Most of this height extends beyond the fold. The parent's overflow: hidden clips the element to the container's height. Any button content or interactive area positioned at padding-top: 5000px is far below the visible window. The element has valid dimensions — its height is enormous — but the portion within the visible window is an empty space with no interactive content.

/* Attack: span 1000 makes element extremely tall, extending below fold */
.approve-btn {
  grid-row-start: span 1000;
  /* Spans rows 1 through 1001 (auto-placed at row 1).
     Element height = sum of 1000 implicit row heights (auto = content height).
     In a dialog with 100px height and overflow:hidden, only the first ~100px
     of the element is visible — the rest is clipped below the fold.
     Button label positioned with padding-top:500px is never in the clipped window. */
  display: flex;
  align-items: flex-end; /* button content at the bottom — beyond visible window */
  padding-bottom: 100px; /* pull content further toward the off-screen area */
}

Attack 3: negative line number — implicit grid extends upward, button above fold

Negative row line numbers count from the end of the explicit row grid. Values beyond the explicit grid extend the implicit grid upward (for rows) — above the first explicit track. Setting grid-row-start: -1000 places the button in an implicit row far above the grid's starting position. With overflow: hidden on the parent, the button is clipped above the container's top edge. Auditors who only check for large positive values miss this direction. The BCR reports a large negative top value — off-screen above the page.

/* Attack: negative grid-row-start extends implicit grid upward */
.consent-dialog {
  display: grid;
  grid-template-rows: 60px 40px;
  height: 100px;
  overflow: hidden;
}
.approve-btn {
  grid-row-start: -1000;
  /* Line -1000 is far above the explicit grid start.
     The button is placed in an implicit row ~998 tracks above row 1.
     BCR: top ≈ -40000px — above the viewport.
     overflow:hidden clips the content above the dialog's top edge.
     Auditors checking only for positive-large values miss this mirrored direction. */
}

Bidirectional implicit grid: The CSS grid implicit grid extends in both directions — positive values extend downward (below the last explicit row), and negative values extend upward (above the first explicit row). An audit checking for parseInt(grs) > 10 catches the downward case but misses parseInt(grs) < -10. Detection must use Math.abs(parseInt(grs)) > threshold.

Attack 4: JS mousedown injection — moves button to row 1000 before click fires

The consent button is visible in row 1. A mousedown listener synchronously sets grid-row-start: 1000 on the button's inline style. The browser immediately reflows the grid — the button moves to implicit row 1000, far below the fold. Click fires at the original screen coordinates — now an empty area. At mouseup, the inline style is cleared and the button moves back to row 1. The entire sequence is invisible to the user: the button is visible before mousedown and returns to visibility at mouseup with no perceptible movement during the click event window.

/* Attack: JS mousedown injects grid-row-start:1000 — button moves below fold */
document.addEventListener('mousedown', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.setProperty('grid-row-start', '1000');
  /* Synchronous grid reflow — button is now at row 1000, ~40,000px below fold.
     Click fires at original coordinates — empty space. */
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.removeProperty('grid-row-start');
  /* Button returns to row 1 — user sees no indication of attack. */
});
// Detection: MutationObserver during mousedown window
const inMousedown = { v: false };
document.addEventListener('mousedown', () => { inMousedown.v = true; }, true);
document.addEventListener('mouseup',   () => { inMousedown.v = false; }, true);

new MutationObserver(mutations => {
  if (!inMousedown.v) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    const grs = m.target.style.getPropertyValue('grid-row-start');
    if (grs && !isNaN(parseInt(grs)) && Math.abs(parseInt(grs)) > 5) {
      console.warn('[SkillAudit] grid-row-start injected during mousedown:',
        grs, m.target);
    }
    // Also check grid-row shorthand and grid-area
    const gr = m.target.style.getPropertyValue('grid-row');
    if (gr && gr.split('/')[0] && Math.abs(parseInt(gr)) > 5) {
      console.warn('[SkillAudit] grid-row shorthand injected during mousedown:', gr, m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High grid-row-start with large positive integer — button placed in implicit row far below fold; overflow:hidden clips it; BCR top exceeds window.innerHeight; display, visibility, offsetParent checks pass; only BCR viewport comparison detects attack.
High span keyword — button spans 1000+ implicit grid rows; element height is enormous; visible window shows only empty top portion; button content is in the off-screen lower region; BCR height is large but no interactive content is in viewport.
Medium Large negative grid-row-start — implicit grid extends upward; button placed above first explicit track; overflow:hidden clips it above container top edge; auditors checking only positive large values miss this mirrored direction; use Math.abs for threshold check.
High JS mousedown injection of grid-row-start — synchronous grid reflow moves button below fold before click fires; click targets empty viewport coordinates; button returns to original row at mouseup; MutationObserver during mousedown is the required detection layer.

SkillAudit checks grid row placement properties on consent-path elements, validates BCR top against window.innerHeight, and monitors mousedown for grid-row-start mutations. Run a free audit on your MCP server.