Security Guide

MCP server CSS grid-column-start security — large positive column line places button off-screen, span keyword extends beyond viewport, negative line extends implicit grid left, JS mousedown injection

CSS grid-column-start specifies the starting grid column line for a grid item. When a consent dialog uses a CSS grid layout, an MCP server can set grid-column-start to a large integer to place the approve button in an implicit off-screen column. With overflow: hidden on the parent, the button is clipped and invisible — but its display is not none, its visibility is not hidden, and its offsetParent is not null. Standard visibility checks pass.

CSS grid-column-start — property overview

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

Attack 1: large positive integer — button placed in implicit off-screen column

The consent dialog container is a grid with a small number of defined columns (e.g., 2). The MCP server sets grid-column-start: 100 on the approve button. The browser creates implicit columns to accommodate the placement — the button lands in column 100, far to the right of the visible area. The parent container has overflow: hidden, which clips the button at its right edge. The button's BCR reports valid, non-zero dimensions, but its left coordinate is far beyond the viewport width. Standard checks for display: none, visibility: hidden, and offsetParent === null all pass.

/* Attack: grid-column-start:100 places button in implicit column 100 */
.consent-dialog {
  display: grid;
  grid-template-columns: 200px 200px; /* 2 defined columns */
  overflow: hidden;                   /* clips button at right edge */
}

.approve-btn {
  grid-column-start: 100; /* implicit column 100 — far off-screen to the right */
  /* BCR: { x: ~20000, y: 100, width: 100, height: 40, right: ~20100, left: ~20000 }
     left > window.innerWidth — off-viewport.
     display ✓ (block), visibility ✓ (visible), offsetParent ✓ (non-null)
     Standard checks pass. Only BCR.left comparison reveals attack. */
}
// Detection: check BCR for off-viewport placement
function auditGridColumnPlacement(el) {
  const bcr = el.getBoundingClientRect();
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  if (bcr.right < 0 || bcr.left > vw || bcr.bottom < 0 || bcr.top > vh) {
    console.warn('[SkillAudit] consent element BCR is off-viewport:', bcr, el);
  }
  // Also check computed grid-column-start for suspicious values
  const cs = getComputedStyle(el);
  const gcs = cs.getPropertyValue('grid-column-start');
  if (gcs && !isNaN(parseInt(gcs)) && Math.abs(parseInt(gcs)) > 10) {
    console.warn('[SkillAudit] grid-column-start is a large integer:',
      gcs, '— may place element in implicit off-screen column:', el);
  }
}

BCR is accurate but off-screen: getBoundingClientRect() correctly reports the button's position — in column 100. The reported left value is approximately column_count × column_width pixels from the left edge — potentially 20,000+ pixels off-screen. An audit that checks bcr.width > 0 && bcr.height > 0 without also checking bcr.left < window.innerWidth misses this attack.

Attack 2: span keyword — button spans 100 columns beyond viewport

The span N value for grid-column-start makes the element start at its auto-placed position and span N tracks to the right. With grid-column-start: span 100, if the auto-placed position is column 1, the button occupies columns 1 through 100. The element is extremely wide — spanning 100 columns of implicit grid tracks. Most of this width extends beyond the right edge of the viewport. With overflow: hidden on the parent, only the portion within the defined grid columns is visible — and if the button's starting content begins off to the right (e.g., text-align: right with padding), the visible area may show nothing.

/* Attack: span 100 makes element extremely wide, extending off-screen */
.approve-btn {
  grid-column-start: span 100;
  /* Auto-placed at column 1; spans through column 101 (100 implicit tracks).
     Element width = sum of 100 implicit column widths.
     Parent overflow:hidden — only left edge of element is "visible".
     Button text, border, and interactive area may all be on the right half
     (off-screen) while the left portion shows as an empty white space. */
  text-align: right;   /* text content off to the right edge of the element */
  padding-right: 5000px; /* push any visible content further right */
}

Attack 3: negative line number — implicit grid extends left into clipped overflow

Negative line numbers in CSS grid count from the end of the explicit grid. The value -1 is the end line of the last explicit column; -2 is the line before that; and so on. Values beyond the explicit grid — e.g., grid-column-start: -20 when only 2 columns are defined — extend the implicit grid to the left of the first explicit track. The browser places the element in an implicit column to the left of the grid's starting edge. The parent's overflow: hidden clips this leftward extension. Auditors expecting that only large positive values move elements off-screen miss the mirrored direction created by large negative values.

/* Attack: negative grid-column-start extends implicit grid leftward */
.consent-dialog {
  display: grid;
  grid-template-columns: 200px 200px;
  overflow: hidden;
}
.approve-btn {
  grid-column-start: -20;
  /* Line -20 is far to the left of the explicit grid start.
     The button is placed in an implicit column ~18 tracks left of column 1.
     With column widths of 200px, button is at x ≈ -3600px relative to the grid.
     overflow:hidden clips all content left of the grid's left edge.
     BCR: left ≈ -3600px — off-viewport to the left.
     Auditors checking only for large positive values miss this direction. */
}

Attack 4: JS mousedown injection — moves button to column 100 before click

The consent button is visible in column 1. A mousedown listener on the document synchronously sets grid-column-start: 100 on the button's inline style. The browser immediately reflows the grid — the button moves to column 100, far off-screen. Click fires on the now-empty column 1 area where the button was. At mouseup, the inline style is cleared and the button moves back to its original position. The grid reflow is synchronous and completes before the click event. The button was visible immediately before mousedown and returns to visibility at mouseup — giving the user no indication of the attack.

/* Attack: JS mousedown injects grid-column-start:100 — button moves off-screen */
document.addEventListener('mousedown', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.setProperty('grid-column-start', '100');
  /* Grid reflows synchronously — button is now at column 100, off-screen.
     Click fires at the original screen coordinates — empty area. */
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.removeProperty('grid-column-start');
  /* Button returns to column 1 — user sees nothing unusual. */
});
// Detection: MutationObserver 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 gcs = m.target.style.getPropertyValue('grid-column-start');
    if (gcs && !isNaN(parseInt(gcs)) && Math.abs(parseInt(gcs)) > 5) {
      console.warn('[SkillAudit] grid-column-start injected during mousedown:',
        gcs, m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High grid-column-start with large positive integer — button placed in implicit off-screen column; overflow:hidden clips it; BCR reports off-viewport coordinates; display, visibility, offsetParent checks pass; only BCR viewport comparison detects attack.
High span keyword — button spans 100+ implicit grid columns; extends far beyond right edge of viewport; visible area is empty while button content is off-screen; text-align and padding push content further right; BCR width is huge but element is effectively invisible.
Medium Large negative grid-column-start — implicit grid extends leftward; button placed to the left of first explicit track; overflow:hidden clips it; auditors checking only large positive values miss the mirrored direction.
High JS mousedown injection of grid-column-start — synchronous grid reflow moves button off-screen before click fires; click targets empty area; button returns to original position at mouseup; MutationObserver during mousedown is the required detection layer.

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