Security Guide

MCP server CSS grid-area security — off-grid column shorthand, named hidden zone, negative line numbers, JS mousedown atomically repositions with one property

CSS grid-area is a shorthand that sets all four grid placement values — grid-row-start / grid-column-start / grid-row-end / grid-column-end — in a single declaration. An MCP server can use this atomicity to move a consent button into an implicit grid track far outside the visible container with one CSS rule, evading audits that check individual sub-properties. A single setProperty('grid-area', '1 / 100 / 2 / 101') call in a mousedown handler moves the button off-screen with minimal mutation footprint.

CSS grid-area — property overview

grid-area accepts either a single named grid area (matching a name in grid-template-areas) or up to four slash-separated line numbers in the form row-start / col-start / row-end / col-end. When a line number references a track outside the explicit grid, the browser creates implicit tracks to accommodate it — these implicit tracks extend the grid beyond its defined boundary in any direction. Implicit tracks have default size (auto), which may make them zero or very small if no content forces them to grow, and they are typically off-screen for columns beyond the container's width. Related: grid-column, grid-row, grid-template-areas.

Attack 1: Off-grid column via line number shorthand — button in implicit track beyond visible area

The consent dialog container is a CSS grid with two columns (the label column and the button column). The button is normally placed at grid-column: 2. With grid-area: 1 / 100 / 2 / 101, the button is placed in column 100 — 98 columns beyond the explicit grid. The browser creates 98 implicit columns, each sized auto (collapsing to the minimum size needed). The consent dialog's overflow: hidden clips everything beyond the explicit grid, and the button is painted outside the visible clip region. Its getBoundingClientRect() reports coordinates far off-screen. The element is in the DOM, has non-zero size (the button's own content forces the implicit track to grow), but is not visible and not in the pointer-event-receiving viewport area.

/* Consent dialog grid: 2 explicit columns */
.consent-dialog {
  display: grid;
  grid-template-columns: 1fr auto; /* label | button */
  overflow: hidden;
}

/* Normal: button in column 2 */
.approve-btn {
  grid-column: 2;
}

/* Attack: shorthand places button in column 100 — implicit off-screen track */
.approve-btn {
  grid-area: 1 / 100 / 2 / 101;
  /* Shorthand expands to:
     grid-row-start: 1
     grid-column-start: 100  ← 98 implicit columns created, all off-screen
     grid-row-end: 2
     grid-column-end: 101 */
}

BCR vs. pointer events: The button's getBoundingClientRect() will show an X coordinate far beyond the viewport width (e.g., left: 4800 for a typical page). The element is not within the viewport rectangle, so no pointer events are dispatched to it. Standard "is the button visible?" checks using offsetParent !== null or display !== 'none' will pass — only a BCR viewport intersection check detects the placement.

// Detection: check grid-area sub-properties and BCR placement
function auditGridArea(el) {
  const cs = getComputedStyle(el);
  // Check if element is in an explicit or implicit track
  const colStart = cs.getPropertyValue('grid-column-start');
  const rowStart = cs.getPropertyValue('grid-row-start');
  const colNum = parseInt(colStart);
  const rowNum = parseInt(rowStart);

  if (!isNaN(colNum) && Math.abs(colNum) > 50) {
    console.warn('[SkillAudit] grid-column-start is', colNum, '— likely in off-screen implicit track:', el);
  }
  if (!isNaN(rowNum) && Math.abs(rowNum) > 50) {
    console.warn('[SkillAudit] grid-row-start is', rowNum, '— likely in off-screen implicit track:', el);
  }

  // BCR viewport check
  const rect = el.getBoundingClientRect();
  const inViewport = rect.top < window.innerHeight && rect.bottom > 0 &&
                     rect.left < window.innerWidth && rect.right > 0;
  if (!inViewport) {
    console.warn('[SkillAudit] consent element BCR is outside viewport:', rect, el);
  }
}

Attack 2: Named grid area — button mapped to off-viewport zone

CSS grid-template-areas allows assigning names to regions of the grid. The MCP server defines the grid template with a zone named hidden-zone that is positioned in a row or column outside the visible area of the dialog. Assigning grid-area: hidden-zone to the consent button places it in that zone. Auditors who check the value of grid-area and see a named string may not immediately assess where that named area is located in the template — the attack is two steps removed: from the name to the template to the pixel position.

/* Attack: named area maps to off-viewport position */
.consent-dialog {
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: auto auto 0px; /* zero-height hidden row */
  grid-template-areas:
    "label"
    "dialog-content"
    "hidden-zone"; /* row 3 has 0px height — button in here is invisible */
  overflow: hidden;
}

.approve-btn {
  grid-area: hidden-zone; /* placed in the 0px-height row */
  /* Button has zero layout height — collapses to 0px painted area */
}

Named area audit gap: A check that reads getComputedStyle(btn).gridArea sees only the string "hidden-zone" — not the pixel coordinates of that area. A complete audit must also read grid-template-areas and grid-template-rows on the parent to determine whether the named zone has zero height, is off-screen, or is clipped.

Attack 3: Negative line numbers — button at end of explicit grid pushed off-screen

CSS grid accepts negative line numbers to count from the end of the explicit grid. grid-column-start: -1 is the last column line; grid-column-start: -100 on a 2-column grid refers to a line 98 positions before the start of the grid, which extends the implicit grid in the negative direction (to the left in LTR). The grid-area shorthand with negative values — e.g., grid-area: -1 / -100 / -2 / -99 — places the consent button far to the left of the visible grid area. Auditors expecting only large positive column numbers as attack values will miss negative-number placements that extend the implicit grid in the opposite direction.

/* Attack: negative line numbers extend implicit grid to the left/top */
.consent-dialog {
  display: grid;
  grid-template-columns: 200px 1fr; /* 2-column explicit grid */
  overflow: hidden;
}

.approve-btn {
  /* -100 counted from end of explicit grid (line 3) = line -97 from start
     This creates 97 implicit columns to the LEFT — all off-screen */
  grid-area: -1 / -100 / -2 / -99;
}
// Detection: check for negative line numbers in grid placement
function auditNegativeGridLines(el) {
  const cs = getComputedStyle(el);
  const props = ['grid-column-start', 'grid-column-end', 'grid-row-start', 'grid-row-end'];
  props.forEach(prop => {
    const val = cs.getPropertyValue(prop);
    const num = parseInt(val);
    if (!isNaN(num) && num < -10) {
      console.warn(`[SkillAudit] ${prop}: ${num} — large negative grid line may push element off-screen:`, el);
    }
  });
}

Attack 4: JS mousedown injects grid-area shorthand — atomic repositioning with one mutation

The consent button is in its correct visible grid position. A mousedown listener calls btn.style.setProperty('grid-area', '1 / 100 / 2 / 101'). This single call atomically sets all four grid placement values, moving the button to column 100. The button is off-screen by the time the click event is dispatched. At mouseup, a single removeProperty('grid-area') reverses the move. The attack requires only two DOM mutations (set and remove), compared to eight mutations if the four sub-properties were changed individually. Attribute-level MutationObserver reports a single style attribute change, making the injected value harder to parse out of the combined style string without specifically looking for grid-area.

/* Attack: atomic repositioning via shorthand on mousedown */
document.addEventListener('mousedown', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  // Single setProperty moves all four grid placement values atomically
  btn.style.setProperty('grid-area', '1 / 100 / 2 / 101');
  // Button is now in column 100 — off-screen
  // Only 1 style attribute mutation (vs 4 mutations for sub-properties)
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  // Single removeProperty reverses all four grid placement values
  btn.style.removeProperty('grid-area');
  // Button returns to CSS-defined position — click already fired off-screen
});
// Detection: MutationObserver watching for grid-area style injection during mousedown
let isMouseDown = false;
document.addEventListener('mousedown', () => { isMouseDown = true; }, true);
document.addEventListener('mouseup', () => { isMouseDown = false; }, true);

const observer = new MutationObserver(muts => {
  if (!isMouseDown) return;
  for (const m of muts) {
    if (m.type === 'attributes' && m.attributeName === 'style') {
      const el = m.target;
      // Check inline style string for grid-area
      const ga = el.style.getPropertyValue('grid-area');
      if (ga) {
        // Parse the four values and check for large column numbers
        const parts = ga.split('/').map(p => parseInt(p.trim()));
        if (parts.some(n => !isNaN(n) && (Math.abs(n) > 20))) {
          console.warn('[SkillAudit] grid-area shorthand injected during mousedown with extreme line number:', ga, el);
        }
      }
    }
  }
});
document.querySelectorAll('.consent-dialog, .approve-btn').forEach(el =>
  observer.observe(el, { attributes: true })
);

Findings summary

High grid-area:1/100/2/101 — single shorthand declaration places button in implicit column 100; overflow:hidden clips it; BCR reports off-viewport coordinates; offsetParent and display checks pass; only BCR viewport intersection test detects the placement.
Medium Named grid-area pointing to zero-height row — grid-area:hidden-zone is opaque without reading grid-template-areas and grid-template-rows on parent; named area may have zero height, zero width, or be defined outside the dialog's visible rows; two-step audit required.
Medium Negative grid line numbers — extend implicit grid in the negative direction (left/top in LTR/horizontal-tb); auditors expecting only large positive column numbers as attack values miss the mirrored negative direction; BCR check detects both directions.
High JS mousedown injects grid-area shorthand — single setProperty atomically moves all four placement values; only 1 style mutation vs 4 for sub-properties; MutationObserver must parse grid-area value for extreme line numbers; removeProperty reverses after click fires.

SkillAudit checks grid-area sub-property values for off-grid line numbers, resolves named areas against the parent grid template, and performs BCR viewport intersection tests on all consent elements. Run a free audit on your MCP server.