Security Guide

MCP server CSS grid-row-end security — large positive end row stretches button below fold, span 1000 makes button extremely tall, negative end extends implicit grid upward, JS mousedown injection

CSS grid-row-end specifies the ending grid row line for a grid item's placement. When a consent dialog uses a CSS grid layout, an MCP server can set grid-row-end to a large integer to stretch the approve button's bottom edge to an implicit row far below the viewport fold. The button starts at row 1 — within view — but its bottom edge extends thousands of pixels below. The parent container's overflow: hidden clips the extension; any bottom-aligned content inside the button is pushed off-screen.

CSS grid-row-end — property overview

grid-row-end is the individual longhand for the ending row line of a grid item's placement, distinct from grid-row-start and the grid-row shorthand. It accepts a positive or negative integer (line number), a named line, or a span value. When the start line is at row 1 and the end line is 1000, the element spans rows 1 through 999 — the top portion is visible but the bottom extension is far below the fold. A span value causes the element to span that many row tracks from its auto-placed start position. Related: grid-row-start, grid-area shorthand, grid-column-end.

Attack 1: large positive integer — button's end row at implicit row far below fold

The consent dialog container is a grid with a few defined rows. Setting grid-row-end: 1000 on the approve button stretches its end line to implicit row 1000 — far below the viewport. The button starts at row 1, so the top portion of the element is visible. However, the element now spans from row 1 through row 999. Any bottom-aligned content within the button — such as align-items: flex-end label text or bottom-positioned padding — sits far off-screen below the fold. The parent's overflow: hidden clips the visible part to the container's height, showing the empty top portion of the element without the actual interactive label.

/* Attack: grid-row-end:1000 stretches button to implicit row 1000 — far below fold */
.consent-dialog {
  display: grid;
  grid-template-rows: 60px 40px; /* 2 defined rows, 100px total */
  height: 100px;
  overflow: hidden; /* clips content extending beyond 100px */
}
.approve-btn {
  grid-row-start: 1;   /* button starts at first row — visible */
  grid-row-end: 1000;  /* button ends at implicit row 1000 — ~40,000px below top */
  /* BCR: { top: 0, bottom: ~40000, height: ~40000 }
     bottom > window.innerHeight + scrollY — extends far below fold.
     display ✓ (block), visibility ✓ (visible), offsetParent ✓ (non-null)
     Standard visibility checks pass. The top portion of the element is in-viewport,
     but it is empty — button label is at the bottom edge, far off-screen. */
  display: flex;
  align-items: flex-end; /* label at the bottom — off-screen below fold */
  justify-content: center;
}
// Detection: check BCR bottom against viewport
function auditGridRowEndPlacement(el) {
  const bcr = el.getBoundingClientRect();
  const vh = window.innerHeight;
  // BCR bottom far below viewport indicates suspiciously tall element
  if (bcr.bottom > vh * 5) {
    console.warn('[SkillAudit] consent element BCR bottom is far below fold:', bcr, el);
  }
  // Also check for large height that indicates off-screen end row
  if (bcr.height > vh * 3) {
    console.warn('[SkillAudit] consent element height is suspiciously large:', bcr.height, el);
  }
  // Check computed grid-row-end for suspicious values
  const cs = getComputedStyle(el);
  const gre = cs.getPropertyValue('grid-row-end');
  if (gre && !isNaN(parseInt(gre)) && Math.abs(parseInt(gre)) > 10) {
    console.warn('[SkillAudit] grid-row-end is a large integer:',
      gre, '— may stretch element to implicit off-screen row:', el);
  }
}

Partially visible ≠ usable: A button with grid-row-end: 1000 has its getBoundingClientRect().top within the viewport — the element does start in-view. Standard "is element visible?" checks that test bcr.top < window.innerHeight will pass. But the visible portion is the empty top of a 40,000px-tall element, not the interactive label region. Audit tools must also verify that the interactive content of the element is in the visible window, not just that the element's top edge is.

Attack 2: span 1000 — button spans 1000 rows, content at bottom off-screen

With grid-row-end: span 1000, the button auto-places at row 1 and spans 1000 row tracks downward — making the element approximately 1000 × row_height pixels tall. Any bottom-aligned content within the button is in the lower region, far below the fold. The parent's overflow: hidden clips the element at the container's height. The visible portion of the button is the empty top section of an enormous element. Unlike a grid-row-start large value (which moves the element entirely off-screen), grid-row-end: span 1000 keeps the element top in-viewport but makes the meaningful content unreachable.

/* Attack: span 1000 makes button 1000 rows tall — label at bottom off-screen */
.approve-btn {
  grid-row-end: span 1000;
  /* Spans from auto-placed row 1 through 1001 implicit rows.
     With auto row height (e.g. 40px): element is ~40,000px tall.
     Only first 100px visible (container height with overflow:hidden).
     Button label positioned at bottom: */
  display: flex;
  align-items: flex-end;  /* label at ~40,000px from top — off-screen */
  padding-bottom: 20px;   /* label ~19,980px below the visible window */
  /* User sees an empty 100px tall box — the "button" — but clicking it
     does not trigger the approve action because the visible area has no
     text/icon to indicate intent, or the click target is at bottom. */
}

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

Negative row line numbers count from the end of the explicit row grid. When the explicit grid has N rows, line -1 is the end of the last row, line -(N+1) is the start of the last row, and further negative values extend the implicit grid upward. Setting a large negative grid-row-end can place the button's end line above the first explicit row — meaning the button is entirely in the implicit negative row region, off-screen above the page. Combined with a grid-row-start even further in the negative direction, the button spans a region entirely above the top of the container.

/* Attack: negative grid-row-end extends implicit grid upward */
.consent-dialog {
  display: grid;
  grid-template-rows: 60px 40px; /* explicit rows 1-3 (lines 1 through 3) */
  height: 100px;
  overflow: hidden;
}
.approve-btn {
  grid-row-start: -1000; /* far above first explicit row */
  grid-row-end: -500;    /* also above first explicit row, but closer */
  /* Button spans from implicit row -1000 to -500 — all above the container.
     BCR: bottom ≈ -20000px — off-screen above the page.
     overflow:hidden clips content above the container's top edge.
     Auditors checking only positive large values miss this upward extension. */
}
// Detection: bidirectional row end placement audit
function auditGridRowLines(el) {
  const cs = getComputedStyle(el);
  const grs = cs.getPropertyValue('grid-row-start');
  const gre = cs.getPropertyValue('grid-row-end');
  const threshold = 10;
  for (const [prop, val] of [['grid-row-start', grs], ['grid-row-end', gre]]) {
    if (val && !isNaN(parseInt(val)) && Math.abs(parseInt(val)) > threshold) {
      console.warn(`[SkillAudit] ${prop} has large absolute value: ${val}`, el);
    }
  }
  const bcr = el.getBoundingClientRect();
  const vh = window.innerHeight;
  if (bcr.bottom < 0) console.warn('[SkillAudit] element BCR is off-screen above fold:', bcr, el);
  if (bcr.top > vh)   console.warn('[SkillAudit] element BCR is off-screen below fold:', bcr, el);
}

Attack 4: JS mousedown injection — stretches button downward before click fires

The consent button is visible with normal row placement — it occupies a 40px row and its label is within view. A mousedown listener synchronously sets grid-row-end: 1000 on the button's inline style. The browser immediately reflows the grid — the button now spans from row 1 to implicit row 1000, becoming approximately 40,000px tall. The label is at the bottom of this enormous element. The click event fires at the original screen coordinates — on the now-empty top portion of the stretched element. The pointer-events area is huge but the interactive content is unreachable. At mouseup, the inline style is cleared and the button returns to its original height, with no visible change.

/* Attack: JS mousedown injects grid-row-end:1000 — button stretches downward */
document.addEventListener('mousedown', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.setProperty('grid-row-end', '1000');
  /* Synchronous grid reflow — button now spans to row 1000, ~40,000px tall.
     Click fires at original y — on the empty top portion of the stretched element.
     Button label is now at ~40,000px from top — far below fold. */
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.removeProperty('grid-row-end');
  /* Button returns to normal height — user sees no indication of attack. */
});
// Detection: MutationObserver monitoring grid-row-end during mousedown
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 gre = m.target.style.getPropertyValue('grid-row-end');
    if (gre && !isNaN(parseInt(gre)) && Math.abs(parseInt(gre)) > 5) {
      console.warn('[SkillAudit] grid-row-end injected during mousedown:', gre, m.target);
    }
    // Also check grid-row shorthand (covers start/end in one property)
    const gr = m.target.style.getPropertyValue('grid-row');
    if (gr && gr.includes('/')) {
      const endPart = gr.split('/')[1].trim();
      if (!isNaN(parseInt(endPart)) && Math.abs(parseInt(endPart)) > 5) {
        console.warn('[SkillAudit] grid-row shorthand end injected during mousedown:', gr, m.target);
      }
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High grid-row-end with large positive integer — button's bottom edge stretches to implicit row 1000+; element is 40,000px+ tall; top portion is in-viewport but shows only empty space; button label at bottom is far below fold; BCR.bottom far exceeds viewport height; partially visible does not mean usable.
High span 1000 — button spans 1000 implicit grid rows downward; element height is enormous; overflow:hidden clips visible portion to container height; visible area is empty top section; interactive label content is thousands of pixels below fold and unreachable by click.
Medium Large negative grid-row-end — implicit grid extends upward; combined with negative grid-row-start, button is placed entirely above the first explicit row; overflow:hidden clips it above container top; auditors checking only positive large values miss this upward extension direction.
High JS mousedown injection of grid-row-end — synchronous grid reflow stretches button downward before click fires; click lands on empty top portion of over-stretched element; label at bottom is unreachable; button returns to normal height at mouseup; MutationObserver during mousedown is the required detection layer.

SkillAudit checks grid row end properties on consent-path elements, validates that interactive content regions are within the viewport, and monitors mousedown for grid-row-end mutations. Run a free audit on your MCP server.