Security Guide

MCP server CSS row-gap security — flex/grid below-fold push, 100vh fold guarantee, negative row overlap, JS mousedown injection

CSS row-gap (originally grid-row-gap) controls the spacing between rows in a flex or grid container. In a consent dialog structured as a flex-direction: column or single-column grid, row-gap inserts space between each row of content — the consent title, body text, checkboxes, and approve button. A large row-gap pushes the approve button downward, eventually past the viewport fold. Unlike margin-based attacks on the dialog itself, a row-gap attack requires the consent container's interior layout to be audited — checking only the dialog's BCR misses it entirely.

CSS row-gap — property overview

The row-gap property is an alias of grid-row-gap and the row component of the gap shorthand. It applies to flex containers (spacing between flex lines in multi-line flex, or between items in a flex-direction: column single-column layout) and grid containers (spacing between grid rows). Values may be lengths (px, em, vh, %) or the keyword normal. Negative values are invalid per spec for most browsers but may be accepted with unpredictable behavior in some. Related: gap shorthand, column-gap, scroll-margin-inline.

Attack 1: large row-gap in flex-column consent — approve button pushed below fold

A consent dialog built with display: flex; flex-direction: column stacks consent rows vertically. The title occupies row 1, body text row 2, an optional checkbox row 3, and the approve button a final row. When row-gap is set to a large value, the space between each row expands. Even a dialog that starts well within the viewport can be stretched so that the approve button ends up below the fold. The dialog's own BCR may still show its top within the viewport; only checking the approve button's own BCR reveals the attack.

/* Attack: large row-gap in flex-column consent pushes approve button below fold */
.consent-dialog {
  display: flex !important;
  flex-direction: column !important;
  row-gap: 400px !important; /* 400px between each row → button at 400 + 400 + ... px */
}

/* Layout with 3 rows (title, body, button):
   Row 1 (title):    ~40px
   Gap 1:           400px
   Row 2 (body):     ~80px
   Gap 2:           400px
   Row 3 (button):   ~44px
   Total height = 40 + 400 + 80 + 400 + 44 = 964px
   On a 600px viewport, button top = 40 + 400 + 80 + 400 = 920px → well below fold.

   dialog.getBoundingClientRect().top may be 50px → dialog BCR is in viewport
   dialog.getBoundingClientRect().bottom = 50 + 964 = 1014px → overflows viewport
   approveBtn.getBoundingClientRect().top = 970px → BCR.top >= window.innerHeight → off-fold */

function checkRowGapFoldPush(consentEl) {
  const cs = getComputedStyle(consentEl);
  const rowGap = parseFloat(cs.rowGap) || 0;
  const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
  const btnBCR = approveBtn ? approveBtn.getBoundingClientRect() : null;
  return {
    rowGap,
    displayType:   cs.display,
    flexDirection: cs.flexDirection,
    buttonBelowFold: btnBCR ? (btnBCR.top >= window.innerHeight) : null,
    buttonTop:     btnBCR ? btnBCR.top : null,
  };
}

Row-gap attacks are invisible at the dialog container level: the dialog's BCR top can be well within the viewport while the approve button is hundreds of pixels below the fold. Any audit that checks only the dialog element's BCR — rather than the approve button's own BCR — will report a false clean result.

Attack 2: row-gap: 100vh — viewport-height gap guarantees fold eviction

Setting row-gap to 100vh (or any value ≥ window.innerHeight) guarantees that the space between any two rows in the flex/grid container exceeds the full viewport height. Even if the dialog is positioned at the very top of the viewport, the approve button — separated from the consent title by at least one 100vh gap — is guaranteed to be at least one viewport-height below the fold. This attack scales automatically across any screen size or viewport, making it a robust platform-independent approach.

/* Attack: row-gap: 100vh — guaranteed fold push at any viewport size */
.consent-dialog {
  display: flex !important;
  flex-direction: column !important;
  row-gap: 100vh !important;
}

/* Effect at any viewport height H:
   Gap between each row = H px
   Dialog starts at top: 0
   Row 1 (title) top:  0px
   Gap 1 top:         ~40px
   Row 2 (body) top:  ~40 + H px → already below fold at any H
   Row 3 (button) top: ~40 + H + 80 + H px → far below fold

   Since the gap is always exactly the viewport height,
   no matter the device, the button is always off-fold. */

function checkViewportHeightRowGap(consentEl) {
  const cs = getComputedStyle(consentEl);
  const resolvedGap = parseFloat(cs.rowGap) || 0;
  return {
    rowGap:           resolvedGap,
    viewportHeight:   window.innerHeight,
    exceedsViewport:  resolvedGap >= window.innerHeight,
    ratioToViewport:  resolvedGap / window.innerHeight,
  };
}

Attack 3: negative row-gap — rows overlap, approve button hidden beneath consent text

While negative row-gap is technically invalid per CSS specification, some browsers accept it with vendor-specific behavior, rendering rows with negative spacing — i.e., overlapping. When consent rows overlap, the approve button (a later row) is rendered at a y-position that places it beneath the consent text row in the stacking order. The button's BCR may show it within the viewport, but it is obscured by other content. Clicks land on the element with the highest paint order at that position, which may not be the approve button.

/* Attack: negative row-gap causes rows to overlap */
.consent-dialog {
  display: flex !important;
  flex-direction: column !important;
  row-gap: -60px !important; /* rows overlap by 60px */
}

/* Effect (browser-dependent):
   Row 1 (title)  top: 0px,  height: 40px → bottom: 40px
   Row 2 (body)   top: 40 - 60 = -20px → overlaps title from above
   Row 3 (button) top: body_bottom - 60px → overlaps body

   Overlap stacking: later rows in DOM order paint on top of earlier rows.
   BUT: the button row may be painted below the text layer depending on z-index.
   A click at the button's BCR coordinates lands on the text overlay, not the button.

   Signal: row-gap parsed value is negative; BCR overlap between approve button
   and consent text element (button.BCR.top < text.BCR.bottom). */

function checkNegativeRowGap(consentEl) {
  const cs = getComputedStyle(consentEl);
  const rawGap = cs.rowGap;
  const resolvedGap = parseFloat(rawGap) || 0;
  const approveBtn  = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
  const consentText = consentEl.querySelector('p, .consent-body, [data-role="body"]');
  const btnBCR  = approveBtn  ? approveBtn.getBoundingClientRect()  : null;
  const textBCR = consentText ? consentText.getBoundingClientRect() : null;
  const overlapping = btnBCR && textBCR
    ? (btnBCR.top < textBCR.bottom && btnBCR.bottom > textBCR.top)
    : null;
  return {
    rowGapRaw:   rawGap,
    rowGapPx:    resolvedGap,
    negative:    resolvedGap < 0,
    overlapping,
  };
}

Attack 4: JS mousedown injection — large row-gap during button press

At page load, the consent dialog uses a normal row gap and all rows are visible. A mousedown listener injects a large row-gap on the flex/grid container during the press interval. The approve button is pushed downward below the fold at click time — the pointer, still at the button's original position, fires a click on whatever element is below the approve button's former position. At mouseup, the gap is restored. Because the injection is transient, a static page-load audit finds no anomaly; only monitoring style mutations during pointer events reveals the attack.

/* Mousedown: inject row-gap to push approve button below fold during press */
(function () {
  document.querySelectorAll('.approve-btn, [data-action="allow"]').forEach(btn => {
    const dialog = btn.closest('.consent-dialog, [data-role="consent"]');
    if (!dialog) return;

    btn.addEventListener('mousedown', () => {
      dialog.style.setProperty('row-gap', '100vh', 'important');
    }, { passive: true });

    btn.addEventListener('mouseup',    () => dialog.style.removeProperty('row-gap'), { passive: true });
    btn.addEventListener('mouseleave', () => dialog.style.removeProperty('row-gap'), { passive: true });
  });
})();

Use a MutationObserver on the consent container's style attribute to catch row-gap mousedown injection. When row-gap or gap changes while a pointer event is active, immediately re-check the approve button's BCR. A gap that expands to push the button below fold during mousedown is HIGH severity even if the gap is restored at mouseup.

Detection summary

HIGH Approve button BCR top ≥ window.innerHeight — button is below the viewport fold due to large row-gap expanding the flex/grid container's row spacing.
HIGH row-gap resolved value ≥ window.innerHeight — the gap between any two consent rows exceeds the full viewport height, guaranteeing fold eviction of all rows after the first.
MEDIUM Negative row-gap accepted by browser — rows overlap; the approve button's BCR overlaps the consent text element, making the button inaccessible to pointer events landing at its BCR coordinates.
MEDIUM Mousedown listener on approve button injects row-gap on the consent container — transient below-fold push not detectable at page-load audit time.
MEDIUM row-gap > 100px on a consent dialog in flex-direction: column — excessive inter-row spacing that pushes the approve button significantly below the consent text.
/* Complete row-gap consent audit */
function auditRowGap(consentEl) {
  const cs = getComputedStyle(consentEl);
  const rowGap = parseFloat(cs.rowGap) || 0;
  const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
  const btnBCR = approveBtn ? approveBtn.getBoundingClientRect() : null;
  return {
    rowGap,
    rowGapNegative:    rowGap < 0,
    rowGapExcessive:   rowGap > 100,
    rowGapExceedsVP:   rowGap >= window.innerHeight,
    display:           cs.display,
    flexDirection:     cs.flexDirection,
    buttonTop:         btnBCR ? btnBCR.top : null,
    buttonBelowFold:   btnBCR ? (btnBCR.top >= window.innerHeight) : null,
    buttonInViewport:  btnBCR
      ? (btnBCR.top < window.innerHeight && btnBCR.bottom > 0 &&
         btnBCR.left < window.innerWidth  && btnBCR.right  > 0)
      : null,
  };
}

SkillAudit checks row-gap by reading the resolved gap value and comparing it to window.innerHeight, then verifying the approve button's own BCR independently. Negative gaps and viewport-height gaps are both flagged. Mousedown injection is detected via MutationObserver monitoring during pointer-event phases. Run a free audit →