Security Research · September 11, 2026

CSS Grid Placement End Properties as MCP Consent Bypass: grid-column-end and grid-row-end

CSS grid placement splits into start and end properties for both the column and row axes. The start properties — grid-column-start and grid-row-start — move a consent button's origin off-screen. The end properties covered here operate differently: they extend the button's trailing edge far off-screen while the button's leading edge and origin remain in-viewport. Standard BCR-in-viewport checks measure BCR.left and BCR.top, which pass. The actual interactive content — the label, the rendered border, the visible button body — is thousands of pixels off-screen in the overflow-hidden grid container. This article covers four attack patterns per property, the differences between start and end attacks, and a consolidated GridEndConsentAudit detection class.

Start vs. end: different attack geometry, same audit blind spot

Grid placement divides into four explicit longhand properties: grid-column-start, grid-column-end, grid-row-start, and grid-row-end. These combine into the grid-area shorthand. Each specifies a grid line number — the point in the implicit or explicit grid where the element's edge is anchored.

The start properties move an element's leading edge: a large grid-column-start value pushes the element's left edge to an implicit column far to the right of the viewport, moving the entire element off-screen. This is geometrically straightforward — the element is simply not in-viewport at all — but it still passes display !== 'none' and visibility !== 'hidden' checks.

The end properties are different. A large grid-column-end value on an auto-placed element stretches the element's right edge to an implicit column far off-screen, while the element's left edge stays at column 1 (or wherever auto-placement put it). The element spans from its origin in the viewport to a point thousands of pixels off-screen to the right. This produces a very wide element whose visible in-viewport portion is the empty left section — while the label, the button text, and the interactive core are at the right end of the element, completely off-screen.

The partially-visible element problem: A consent button stretched to grid-column-end:100 has BCR.left in-viewport, BCR.top in-viewport, and BCR.width equal to 40,000+ pixels. The standard in-viewport check — BCR.left < window.innerWidth && BCR.top < window.innerHeight && BCR.right > 0 && BCR.bottom > 0 — passes. The button is "in-viewport" by this test. But the visible portion is an empty strip of the element's leftmost pixels, and the actual button content is unreachable.

The attack geometry for end properties

Consider a grid container with display: grid; grid-template-columns: repeat(10, 1fr); overflow: hidden. A consent button auto-placed in this grid lands at column 1, row 1. The visible width of the container is 600px, so each column is 60px wide.

Now the MCP server adds grid-column-end: 100 to the button. The browser extends the implicit grid to accommodate column 100. The element now spans from column 1 to column 100 — a width of approximately 5,940px (99 columns × 60px). The container's overflow: hidden clips everything beyond 600px. The user sees the leftmost 600px of the element's content — which, for a button, is typically whitespace or the button's background. The label text is horizontally centered or right-aligned within the 5,940px element, placing it at x ≈ 2970px or 5880px — both far beyond the clip boundary.

The same geometry applies to grid-row-end on the vertical axis: the button's bottom edge is pushed to row 1000, creating an element that is tens of thousands of pixels tall. The visible top portion is empty whitespace; the label is at the vertical center or bottom of the element, below the fold by a vast margin.

Attack surface 1: grid-column-end — trailing edge stretched off-screen

The full attack surface for this property is covered in the grid-column-end security guide. The four patterns are:

Pattern 1 — large positive integer line number: grid-column-end: 100 extends the element's right edge to implicit column 100. The visible in-viewport left edge remains at column 1. The element is thousands of pixels wide; overflow: hidden on the container clips the right portion. The label — typically centered or right-aligned within the element — is off-screen. BCR.left and BCR.top pass in-viewport checks. Only checking BCR.width (which equals computed element width) reveals the stretch.

Pattern 2 — span keyword: grid-column-end: span 100 places the element's right edge 100 columns from wherever auto-placement put the left edge. Unlike an absolute line number, this is relative to the placement position. The effect is identical: element is 100 columns wide, label is off-screen, container clips the overflow. The key difference is that span is relative, so if auto-placement moves the element (due to other items in the grid), the exact off-screen distance changes — but the element is always too wide to be usable.

Pattern 3 — large negative end line: grid-column-end: -100 extends the implicit grid in the negative direction. Negative line numbers count from the end of the explicit grid. A value of -100 creates 98 implicit columns before column 1, extending the grid to the left. The button spans from its auto-placed position to column -100 — which is far to the left, off-screen. BCR.right may be in-viewport while BCR.left is negative (off-screen left). Detection must check Math.abs(BCR.left) or verify both edges independently.

Pattern 4 — JS mousedown injection: The JS mousedown handler sets grid-column-end: 100 on the button's inline style immediately before the dialog's click handler fires. The click lands on the visible left edge of the stretched element — which is empty whitespace, not the interactive button area. The handler adds the dialog's "confirm" class or dispatches the approval event, but the user's click was not on the functional button content. The inline style may be cleaned up at mouseup, but the consent was already "collected" via click on an empty area.

/* Attack 1: grid-column-end large positive integer */
.consent-grid {
  display: grid;
  grid-template-columns: repeat(10, 60px);
  overflow: hidden;
  width: 600px;
}

/* Intended: button occupies one grid column */
.approve-btn {
  /* auto-placed at column 1, row 1 */
  padding: 12px 24px;
  text-align: center; /* label at center of element */
}

/* Attack: button now spans from column 1 to column 100 */
.approve-btn {
  grid-column-end: 100;
  /* element is now 5940px wide (99 × 60px columns)
     visible portion: leftmost 600px (empty whitespace or left background)
     label text centered at x ≈ 2970px — 2370px off-screen to the right
     BCR.left: in-viewport ✓ (at ~0px)
     BCR.width: 5940px ← reveals attack
     BCR.right: 5940px ← far exceeds window.innerWidth */
}
/* Attack 2: span keyword — relative to auto-placement position */
.approve-btn {
  grid-column-end: span 100;
  /* wherever auto-placement puts the left edge, the right edge is 100 columns further
     equivalent attack: element too wide for content to be visible */
}

/* Attack 3: negative end line — extends grid leftward */
.approve-btn {
  grid-column-end: -50; /* 48 implicit columns to the left of explicit grid start */
  /* BCR.right: may be in-viewport
     BCR.left: far negative (off-screen to the left)
     element spans from explicit grid start position to far-left implicit grid */
}
// Detection: audit grid-column-end attack
function auditGridColumnEnd(el) {
  const cs = getComputedStyle(el);
  const colEnd = cs.getPropertyValue('grid-column-end');
  const bcr = el.getBoundingClientRect();

  // Check for large absolute line number
  if (colEnd !== 'auto') {
    const n = parseInt(colEnd, 10);
    if (!isNaN(n)) {
      if (n > 10 || n < -5) {
        console.warn('[SkillAudit] grid-column-end is a large line number:', colEnd,
          '— element may extend far off-screen:', el);
      }
    }
    // Check for span keyword
    if (colEnd.startsWith('span')) {
      const spanN = parseInt(colEnd.replace('span', ''), 10);
      if (!isNaN(spanN) && spanN > 5) {
        console.warn('[SkillAudit] grid-column-end span:', spanN,
          '— element spans', spanN, 'columns; label likely off-screen:', el);
      }
    }
  }

  // BCR dimension check — element too wide to be usable
  if (bcr.width > window.innerWidth * 1.5) {
    console.warn('[SkillAudit] consent element BCR.width (', bcr.width, 'px)',
      'greatly exceeds viewport width (', window.innerWidth, 'px)',
      '— grid-column-end may have stretched element off-screen:', el);
  }

  // Right edge off-screen check
  if (bcr.right > window.innerWidth + 100) {
    console.warn('[SkillAudit] consent element BCR.right (', bcr.right.toFixed(0), 'px)',
      'is far beyond viewport right edge —',
      'element may be stretched rightward by grid-column-end:', el);
  }

  // Left edge off-screen check (negative end line attack)
  if (bcr.left < -100) {
    console.warn('[SkillAudit] consent element BCR.left (', bcr.left.toFixed(0), 'px)',
      'is far off-screen left — negative grid-column-end may extend grid leftward:', el);
  }
}

The "partially visible ≠ usable" principle: A consent element with BCR.left: 2px, BCR.top: 100px, BCR.width: 5940px, BCR.height: 40px has its origin in-viewport. A naïve in-viewport check that tests BCR.top < window.innerHeight && BCR.left < window.innerWidth passes. The element is clearly "in the viewport" by this definition. But the usable interactive surface — the rendered button content — is between x=2970px and x=5940px. The audit must separately check that BCR dimensions are proportionate to the expected button dimensions, not just that one corner is in-viewport.

Attack surface 2: grid-row-end — bottom edge stretched below fold

The complete attack patterns for this property are documented in the grid-row-end security guide. The structure mirrors grid-column-end but operates on the vertical axis.

Pattern 1 — large positive integer line number: grid-row-end: 1000 extends the element's bottom edge to implicit row 1000. If each row is 40px, the element is now 39,960px tall. The visible in-viewport top portion is the first ~600px of the element's height — which, for a button, is the background or an empty upper section. The label text at vertical center is at y ≈ 19,980px, tens of thousands of pixels below the fold. BCR.top passes in-viewport. BCR.height: 39,960px is the indicator of the attack.

Pattern 2 — span keyword (row): grid-row-end: span 1000 extends the element's bottom edge 1000 rows from wherever auto-placement put the top edge. Combined with overflow: hidden on the container, the user sees only the topmost visible sliver of the element — empty background color, no label.

Pattern 3 — negative end lines (bidirectional): grid-row-end: -100 extends the implicit grid upward. The element's bottom edge is at negative implicit row -100, which is above the viewport origin. BCR.bottom becomes negative — the entire element is above the fold, clipped entirely out of the visible area. Detection requires checking BCR.bottom > 0 and BCR.top < window.innerHeight, not just BCR.top.

Pattern 4 — JS mousedown injection (row): Same pattern as the column variant but injects grid-row-end: 1000 on the button's inline style. The click lands on the visible top sliver of the element — the empty header area of the stretched button. The consent event is dispatched from a click on an empty area, not on the actual button content.

/* Attack 1: grid-row-end large positive integer */
.consent-grid {
  display: grid;
  grid-template-rows: repeat(5, 40px);
  overflow: hidden;
  height: 200px;
}

.approve-btn {
  /* auto-placed at row 1, column 1 */
  text-align: center;
  /* label vertically centered within element */
}

/* Attack: button extends to row 1000 */
.approve-btn {
  grid-row-end: 1000;
  /* element is 39960px tall (999 × 40px rows)
     visible top portion: 200px (the container clips the rest)
     visible top 200px: upper background of button — empty
     label (vertically centered at y≈19980px): completely off-screen
     BCR.top: in-viewport ✓ (e.g., 8px from top)
     BCR.height: 39960px ← reveals the attack
     BCR.bottom: 39968px ← far exceeds window.innerHeight */
}

/* Attack 3: negative end line — element extends above fold */
.approve-btn {
  grid-row-end: -100;
  /* implicit rows created above row 1; element's bottom edge at row -100
     BCR.bottom may be negative (element entirely above viewport)
     completely invisible to user */
}
// Detection: audit grid-row-end attack
function auditGridRowEnd(el) {
  const cs = getComputedStyle(el);
  const rowEnd = cs.getPropertyValue('grid-row-end');
  const bcr = el.getBoundingClientRect();

  if (rowEnd !== 'auto') {
    const n = parseInt(rowEnd, 10);
    if (!isNaN(n)) {
      if (n > 10 || n < -5) {
        console.warn('[SkillAudit] grid-row-end is a large line number:', rowEnd,
          '— element may extend far below fold or above viewport:', el);
      }
    }
    if (rowEnd.startsWith('span')) {
      const spanN = parseInt(rowEnd.replace('span', ''), 10);
      if (!isNaN(spanN) && spanN > 5) {
        console.warn('[SkillAudit] grid-row-end span:', spanN,
          '— element spans', spanN, 'rows; label likely below fold:', el);
      }
    }
  }

  // BCR height check
  if (bcr.height > window.innerHeight * 1.5) {
    console.warn('[SkillAudit] consent element BCR.height (', bcr.height, 'px)',
      'greatly exceeds viewport height (', window.innerHeight, 'px)',
      '— grid-row-end may have stretched element below fold:', el);
  }

  // Bottom edge off-screen
  if (bcr.bottom > window.innerHeight + 100) {
    console.warn('[SkillAudit] consent element BCR.bottom (', bcr.bottom.toFixed(0), 'px)',
      'is far below viewport — element likely stretched by grid-row-end:', el);
  }

  // Negative end line: element entirely above fold
  if (bcr.bottom < 0) {
    console.warn('[SkillAudit] consent element BCR.bottom is negative (',
      bcr.bottom.toFixed(0), 'px) — element is entirely above viewport;',
      'negative grid-row-end may have extended grid upward:', el);
  }
}

Why end properties are harder to detect than start properties

The key difference between start and end property attacks is their relationship to the in-viewport origin check:

Property BCR.top / BCR.left BCR.bottom / BCR.right BCR.width / BCR.height Naïve in-viewport check
grid-column-start: 100 Off-screen (large positive) Off-screen (larger positive) Normal element size Fails — BCR.left > innerWidth
grid-column-end: 100 In-viewport (near 0) Off-screen (very large) Enormous (element width) Passes — BCR.left < innerWidth ✓
grid-row-start: 1000 Off-screen (very large) Off-screen (larger) Normal element size Fails — BCR.top > innerHeight
grid-row-end: 1000 In-viewport (near top) Off-screen (very large) Enormous (element height) Passes — BCR.top < innerHeight ✓

Start property attacks move the entire element off-screen. A naïve in-viewport check catches them because BCR.left > window.innerWidth or BCR.top > window.innerHeight. End property attacks keep the element origin in-viewport while extending the trailing edge off-screen. The same naïve check sees the in-viewport origin and passes.

This is the "partially visible ≠ usable" principle applied to grid placement. An element can have one corner in the viewport while its entire interactive surface is off-screen. Visibility audits must check all four edges — and also check that the element's dimensions are proportionate to what a consent button should be.

The span keyword and auto-placement interactions

The span keyword for end properties creates a relative extension from the element's placement position. When auto-placement is involved, the exact outcome depends on other items in the grid. An MCP server that sets grid-column-end: span 50 on a button gets a consistent attack across all auto-placement scenarios: regardless of which column the button lands on, its right edge is always 50 columns further. Auditors who focus on computed values for absolute line numbers will see a span keyword and must separately calculate what span N translates to in absolute coordinates for the element's actual placement position.

// Resolving span N to absolute pixel offset
function resolveGridSpanToPixels(el, axis) {
  const cs = getComputedStyle(el);
  const prop = axis === 'column' ? 'grid-column-end' : 'grid-row-end';
  const endVal = cs.getPropertyValue(prop).trim();

  if (!endVal.startsWith('span')) return null;
  const spanN = parseInt(endVal.replace('span', ''), 10);
  if (isNaN(spanN)) return null;

  const bcr = el.getBoundingClientRect();
  // The element's current rendered size tells us how far the span extends
  // For column: bcr.width represents the full span; bcr.left is the start
  // If bcr.width >> viewport width, span N is the attack
  if (axis === 'column') {
    return { spanN, renderedWidth: bcr.width, startX: bcr.left, endX: bcr.right };
  } else {
    return { spanN, renderedHeight: bcr.height, startY: bcr.top, endY: bcr.bottom };
  }
}

// Usage
const colResult = resolveGridSpanToPixels(consentBtn, 'column');
if (colResult && colResult.renderedWidth > window.innerWidth * 2) {
  console.warn('[SkillAudit] grid-column-end: span', colResult.spanN,
    'produces element width of', colResult.renderedWidth.toFixed(0), 'px —',
    'label likely off-screen at x ≈', (colResult.startX + colResult.renderedWidth / 2).toFixed(0) + 'px');
}

Negative end lines and bidirectional implicit grid extension

CSS grid negative line numbers count from the end of the explicit grid, not from column 1. In a grid with grid-template-columns: repeat(5, 1fr), the explicit column lines are 1 through 6 (the grid has 5 tracks, creating 6 lines). Line -1 is the same as line 6 (the rightmost explicit line). Line -6 is the same as line 1 (the leftmost explicit line). Line -7 through -∞ creates implicit columns to the left of the explicit grid.

An attack using grid-column-end: -50 creates 44 implicit columns to the left of the explicit grid start (since -6 is line 1, -7 is one implicit column to the left, and -50 is 44 implicit columns to the left). The element's right edge is anchored to this negative-side implicit line, extending the element leftward. BCR.left may be a large negative number; BCR.right may be in-viewport. Standard in-viewport checks that only verify BCR.right > 0 pass, but the element extends far to the left of the visible area and the in-viewport right edge is the trailing empty background portion of the element.

Detection requires checking all four edges with separate conditions:

// Bidirectional boundary check for grid end attacks
function checkGridEndBoundaries(el) {
  const bcr = el.getBoundingClientRect();
  const w = window.innerWidth;
  const h = window.innerHeight;
  const issues = [];

  // Column axis
  if (bcr.right > w + 200) {
    issues.push(`BCR.right=${bcr.right.toFixed(0)}px — element extends ${(bcr.right - w).toFixed(0)}px off-screen right`);
  }
  if (bcr.left < -200) {
    issues.push(`BCR.left=${bcr.left.toFixed(0)}px — element extends ${Math.abs(bcr.left).toFixed(0)}px off-screen left`);
  }
  if (bcr.width > w * 2) {
    issues.push(`BCR.width=${bcr.width.toFixed(0)}px — element is ${(bcr.width / w).toFixed(1)}× viewport width`);
  }

  // Row axis
  if (bcr.bottom > h + 200) {
    issues.push(`BCR.bottom=${bcr.bottom.toFixed(0)}px — element extends ${(bcr.bottom - h).toFixed(0)}px below fold`);
  }
  if (bcr.top < -200) {
    issues.push(`BCR.top=${bcr.top.toFixed(0)}px — element extends ${Math.abs(bcr.top).toFixed(0)}px above viewport`);
  }
  if (bcr.height > h * 2) {
    issues.push(`BCR.height=${bcr.height.toFixed(0)}px — element is ${(bcr.height / h).toFixed(1)}× viewport height`);
  }

  if (issues.length > 0) {
    console.warn('[SkillAudit] grid end property attack detected on consent element:', issues, el);
  }
  return issues;
}

JS mousedown injection and the click-on-empty-area exploit

The mousedown injection pattern for end properties is more subtle than for start properties. When grid-column-start is injected at mousedown, the element moves off-screen and the user's click lands on an empty area of the page — not the element at all. The click event does not fire on the element.

When grid-column-end or grid-row-end is injected at mousedown, the situation is different: the element's origin stays in its original position. The element stretches, but the user's click was aimed at the element's visible in-viewport area. The click does land on the element — on the stretched, empty left/top portion that is now in-viewport. The element's event handler fires. If the event handler treats any click on the element as a consent confirmation, it records the consent from a click on an empty area of the element, not on the rendered button content.

/* JS mousedown injection: grid-column-end stretches button; click lands on empty left portion */
document.addEventListener('mousedown', e => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  // Inject grid-column-end before click fires
  btn.style.setProperty('grid-column-end', '100');
  /* The button's left edge stays at its original viewport position.
     The right edge stretches to column 100 (off-screen right).
     User's mouse coordinates are over the original visible area (the left sliver).
     The click event fires on the element — it IS under the mouse pointer.
     But the rendered button content (label, border, visual affordance) is off-screen.
     If the click handler says: "if you clicked this element, consent granted" —
     consent is recorded from a click on an empty background area. */
}, true); /* capture phase */

// MutationObserver detection for mousedown grid-column-end injection
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 style = m.target.style;
    const colEnd = style.getPropertyValue('grid-column-end');
    const rowEnd = style.getPropertyValue('grid-row-end');
    const gridArea = style.getPropertyValue('grid-area');
    if (colEnd || rowEnd || gridArea) {
      console.warn('[SkillAudit] grid placement end property mutated during mousedown:',
        { 'grid-column-end': colEnd, 'grid-row-end': rowEnd, 'grid-area': gridArea },
        m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Click fires on the element, but not on the content: End property injection during mousedown puts the click on the element, not outside it. Standard click listeners on the button element still fire. The attack exploits the fact that "click on this button" is conflated with "user meaningfully interacted with the rendered button content." An audit checking e.target === consentButton confirms the click target but not whether the click was on the rendered label or an invisible stretched area.

Consolidated GridEndConsentAudit detection class

The following class combines all detection axes for both grid-column-end and grid-row-end attacks. It runs at DOMContentLoaded for static detection, and attaches a mousedown MutationObserver for dynamic injection detection.

class GridEndConsentAudit {
  constructor(selector = '[data-consent-action]') {
    this.selector = selector;
    this.inMousedown = false;
    this._attachMousedownObserver();
  }

  auditAll() {
    const elements = document.querySelectorAll(this.selector);
    elements.forEach(el => this.audit(el));
  }

  audit(el) {
    this._checkGridColumnEnd(el);
    this._checkGridRowEnd(el);
    this._checkBCRDimensions(el);
  }

  _checkGridColumnEnd(el) {
    const cs = getComputedStyle(el);
    const colEnd = cs.getPropertyValue('grid-column-end').trim();
    if (colEnd === 'auto') return;

    if (colEnd.startsWith('span')) {
      const n = parseInt(colEnd.replace('span', ''), 10);
      if (!isNaN(n) && n > 5) {
        this._warn('grid-column-end: span ' + n + ' — element spans ' + n +
          ' columns; right edge likely off-screen', el);
      }
    } else {
      const n = parseInt(colEnd, 10);
      if (!isNaN(n) && (n > 10 || n < -5)) {
        this._warn('grid-column-end: ' + n +
          ' — large line number; element trailing edge likely off-screen', el);
      }
    }
  }

  _checkGridRowEnd(el) {
    const cs = getComputedStyle(el);
    const rowEnd = cs.getPropertyValue('grid-row-end').trim();
    if (rowEnd === 'auto') return;

    if (rowEnd.startsWith('span')) {
      const n = parseInt(rowEnd.replace('span', ''), 10);
      if (!isNaN(n) && n > 5) {
        this._warn('grid-row-end: span ' + n + ' — element spans ' + n +
          ' rows; bottom edge likely below fold', el);
      }
    } else {
      const n = parseInt(rowEnd, 10);
      if (!isNaN(n) && (n > 10 || n < -5)) {
        this._warn('grid-row-end: ' + n +
          ' — large line number; element trailing edge likely off-screen', el);
      }
    }
  }

  _checkBCRDimensions(el) {
    const bcr = el.getBoundingClientRect();
    const w = window.innerWidth;
    const h = window.innerHeight;

    if (bcr.width > w * 1.5) {
      this._warn('BCR.width=' + bcr.width.toFixed(0) + 'px (' +
        (bcr.width / w).toFixed(1) + '× viewport) — grid-column-end attack likely', el);
    }
    if (bcr.height > h * 1.5) {
      this._warn('BCR.height=' + bcr.height.toFixed(0) + 'px (' +
        (bcr.height / h).toFixed(1) + '× viewport) — grid-row-end attack likely', el);
    }
    if (bcr.right > w + 200) {
      this._warn('BCR.right=' + bcr.right.toFixed(0) + 'px off-screen right', el);
    }
    if (bcr.bottom > h + 200) {
      this._warn('BCR.bottom=' + bcr.bottom.toFixed(0) + 'px below fold', el);
    }
    if (bcr.left < -200) {
      this._warn('BCR.left=' + bcr.left.toFixed(0) + 'px off-screen left (negative end line)', el);
    }
    if (bcr.bottom < 0) {
      this._warn('BCR.bottom=' + bcr.bottom.toFixed(0) + 'px — element entirely above viewport', el);
    }
  }

  _attachMousedownObserver() {
    document.addEventListener('mousedown', () => { this.inMousedown = true; }, true);
    document.addEventListener('mouseup',   () => { this.inMousedown = false; }, true);

    new MutationObserver(mutations => {
      if (!this.inMousedown) return;
      for (const m of mutations) {
        if (m.attributeName !== 'style') continue;
        const s = m.target.style;
        const colEnd = s.getPropertyValue('grid-column-end');
        const rowEnd = s.getPropertyValue('grid-row-end');
        const gridArea = s.getPropertyValue('grid-area');
        if (colEnd || rowEnd || gridArea) {
          this._warn('grid placement end property injected during mousedown: ' +
            JSON.stringify({ 'grid-column-end': colEnd, 'grid-row-end': rowEnd, 'grid-area': gridArea }),
            m.target);
        }
      }
    }).observe(document.body, {
      attributes: true,
      attributeFilter: ['style'],
      subtree: true
    });
  }

  _warn(msg, el) {
    console.warn('[SkillAudit:GridEnd]', msg, el);
  }
}

// Usage
document.addEventListener('DOMContentLoaded', () => {
  const audit = new GridEndConsentAudit('[data-consent], .approve-btn, .consent-button');
  audit.auditAll();
});

Relationship to grid-area shorthand attacks

The grid-area shorthand sets all four placement properties simultaneously in the order row-start / column-start / row-end / column-end. An attack using grid-area: 1 / 1 / 1000 / 100 applies both grid-row-end: 1000 and grid-column-end: 100 in a single declaration. This means a MutationObserver watching for grid-column-end or grid-row-end mutations must also watch for grid-area mutations — the shorthand sets all four sub-properties atomically with a single style mutation, not four separate mutations.

The GridEndConsentAudit class above includes grid-area in its mousedown MutationObserver for exactly this reason. Static detection via getComputedStyle automatically resolves the shorthand to individual properties, so the _checkGridColumnEnd and _checkGridRowEnd methods work correctly whether the values were set via shorthand or longhand.

Findings summary

High grid-column-end: N (large positive integer) — element origin stays in-viewport; right edge extends to implicit column N (potentially 40,000+ px off-screen); visible in-viewport portion is empty left background; label is off-screen; BCR-in-viewport check passes (BCR.left < window.innerWidth); only BCR.width and BCR.right reveal the attack.
High grid-row-end: N (large positive integer) — element top stays in-viewport; bottom edge extends to implicit row N (potentially 39,960+ px below fold); visible in-viewport top portion is empty background; label is below fold; BCR-in-viewport check passes (BCR.top < window.innerHeight); only BCR.height and BCR.bottom reveal the attack.
High span N variants — same off-screen geometry as absolute line numbers but relative to auto-placement position; computed values show "span 100" not an absolute line number; detection must either read the span value directly or check BCR dimensions; auto-placement variations don't change the attack effectiveness.
Medium Negative end line numbers — extend the implicit grid in the opposite direction; element may have BCR.left or BCR.top off-screen with the trailing edge in-viewport; bidirectional check required; detectors checking only BCR.right > 0 and BCR.bottom > 0 miss this variant.
High JS mousedown injection (end properties) — unlike start property injection, the click event fires on the element because the element origin stays at the cursor position; click handler fires on an empty stretched area; consent is recorded from click on background, not rendered button content; MutationObserver in capture phase during mousedown required.

SkillAudit audits all four grid placement properties (start and end, column and row), validates BCR dimensions against expected consent button size ranges, and monitors grid placement mutations during mousedown windows. Run a free audit on your MCP server's consent flow.