Security Guide
MCP server CSS grid-row-start security — large positive row places button below fold, span 1000 extends height off-screen, negative line extends implicit grid up, JS mousedown injection
CSS grid-row-start specifies the starting grid row line for a grid item. When a consent dialog uses a CSS grid layout, an MCP server can set grid-row-start to a large integer to place the approve button in an implicit row far below the visible fold. The parent container's overflow: hidden clips the button — it has a valid DOM node, non-null offsetParent, and correct display property, but its getBoundingClientRect().top is far beyond window.innerHeight.
CSS grid-row-start — property overview
grid-row-start is the individual longhand for the row start line of a grid item's placement, distinct from the grid-row shorthand and the grid-area four-value shorthand. It accepts a positive or negative integer (line number), a named line, or a span value. Positive integers reference explicit row lines or extend into the implicit grid below. Negative integers reference lines counting from the end of the explicit row grid; large negatives extend the implicit grid above the first explicit row. The span N value causes the item to span N row tracks from its auto-placed start position. Related: grid-area shorthand, grid-column-start.
Attack 1: large positive integer — button placed in implicit row far below the fold
The consent dialog container is a grid with one or two defined rows. Setting grid-row-start: 1000 on the approve button places it in implicit row 1000 — far below the viewport. The parent has a fixed height and overflow: hidden. The button has valid display: block, visibility: visible, and a non-null offsetParent. Its BCR reports correct width and height. But its top value is approximately row_count × row_height pixels below the grid top — potentially thousands of pixels below the fold.
/* Attack: grid-row-start:1000 places button in implicit row 1000 — below the fold */
.consent-dialog {
display: grid;
grid-template-rows: 60px 40px; /* 2 defined rows, 100px total */
height: 100px;
overflow: hidden; /* clips all content below 100px */
}
.approve-btn {
grid-row-start: 1000; /* implicit row 1000 — ~40,000px below top of grid */
/* BCR: { top: ~40000, bottom: ~40040, y: ~40000, height: 40 }
top > window.innerHeight → off-viewport below fold.
display ✓ (block), visibility ✓ (visible), offsetParent ✓ (non-null)
Standard visibility checks pass. Only BCR.top comparison detects attack. */
}
// Detection: check BCR top against viewport height
function auditGridRowPlacement(el) {
const bcr = el.getBoundingClientRect();
const vh = window.innerHeight;
const vw = window.innerWidth;
if (bcr.top > vh || bcr.bottom < 0 || bcr.left > vw || bcr.right < 0) {
console.warn('[SkillAudit] consent element BCR is outside viewport:', bcr, el);
}
// Also check computed grid-row-start for suspicious values
const cs = getComputedStyle(el);
const grs = cs.getPropertyValue('grid-row-start');
if (grs && !isNaN(parseInt(grs)) && Math.abs(parseInt(grs)) > 10) {
console.warn('[SkillAudit] grid-row-start is a large integer:',
grs, '— may place element in implicit off-screen row:', el);
}
}
offsetParent does not confirm visibility: el.offsetParent is null only for elements with display: none, fixed positioning, or a body ancestor. A grid item in implicit row 1000 with overflow: hidden clipping it has a valid offsetParent. Defensive checks that use offsetParent !== null as a "visible" indicator are insufficient. Only BCR viewport comparison is reliable.
Attack 2: span keyword — button spans 1000 rows, mostly below the fold
With grid-row-start: span 1000, the button's auto-placed start is row 1 (or wherever the grid engine places it), but it spans through 1000 implicit rows — making the element approximately 1000 × row_height pixels tall. Most of this height extends beyond the fold. The parent's overflow: hidden clips the element to the container's height. Any button content or interactive area positioned at padding-top: 5000px is far below the visible window. The element has valid dimensions — its height is enormous — but the portion within the visible window is an empty space with no interactive content.
/* Attack: span 1000 makes element extremely tall, extending below fold */
.approve-btn {
grid-row-start: span 1000;
/* Spans rows 1 through 1001 (auto-placed at row 1).
Element height = sum of 1000 implicit row heights (auto = content height).
In a dialog with 100px height and overflow:hidden, only the first ~100px
of the element is visible — the rest is clipped below the fold.
Button label positioned with padding-top:500px is never in the clipped window. */
display: flex;
align-items: flex-end; /* button content at the bottom — beyond visible window */
padding-bottom: 100px; /* pull content further toward the off-screen area */
}
Attack 3: negative line number — implicit grid extends upward, button above fold
Negative row line numbers count from the end of the explicit row grid. Values beyond the explicit grid extend the implicit grid upward (for rows) — above the first explicit track. Setting grid-row-start: -1000 places the button in an implicit row far above the grid's starting position. With overflow: hidden on the parent, the button is clipped above the container's top edge. Auditors who only check for large positive values miss this direction. The BCR reports a large negative top value — off-screen above the page.
/* Attack: negative grid-row-start extends implicit grid upward */
.consent-dialog {
display: grid;
grid-template-rows: 60px 40px;
height: 100px;
overflow: hidden;
}
.approve-btn {
grid-row-start: -1000;
/* Line -1000 is far above the explicit grid start.
The button is placed in an implicit row ~998 tracks above row 1.
BCR: top ≈ -40000px — above the viewport.
overflow:hidden clips the content above the dialog's top edge.
Auditors checking only for positive-large values miss this mirrored direction. */
}
Bidirectional implicit grid: The CSS grid implicit grid extends in both directions — positive values extend downward (below the last explicit row), and negative values extend upward (above the first explicit row). An audit checking for parseInt(grs) > 10 catches the downward case but misses parseInt(grs) < -10. Detection must use Math.abs(parseInt(grs)) > threshold.
Attack 4: JS mousedown injection — moves button to row 1000 before click fires
The consent button is visible in row 1. A mousedown listener synchronously sets grid-row-start: 1000 on the button's inline style. The browser immediately reflows the grid — the button moves to implicit row 1000, far below the fold. Click fires at the original screen coordinates — now an empty area. At mouseup, the inline style is cleared and the button moves back to row 1. The entire sequence is invisible to the user: the button is visible before mousedown and returns to visibility at mouseup with no perceptible movement during the click event window.
/* Attack: JS mousedown injects grid-row-start:1000 — button moves below fold */
document.addEventListener('mousedown', () => {
const btn = document.querySelector('.approve-btn');
if (!btn) return;
btn.style.setProperty('grid-row-start', '1000');
/* Synchronous grid reflow — button is now at row 1000, ~40,000px below fold.
Click fires at original coordinates — empty space. */
});
document.addEventListener('mouseup', () => {
const btn = document.querySelector('.approve-btn');
if (!btn) return;
btn.style.removeProperty('grid-row-start');
/* Button returns to row 1 — user sees no indication of attack. */
});
// Detection: MutationObserver during mousedown window
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 grs = m.target.style.getPropertyValue('grid-row-start');
if (grs && !isNaN(parseInt(grs)) && Math.abs(parseInt(grs)) > 5) {
console.warn('[SkillAudit] grid-row-start injected during mousedown:',
grs, m.target);
}
// Also check grid-row shorthand and grid-area
const gr = m.target.style.getPropertyValue('grid-row');
if (gr && gr.split('/')[0] && Math.abs(parseInt(gr)) > 5) {
console.warn('[SkillAudit] grid-row shorthand injected during mousedown:', gr, m.target);
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
Findings summary
SkillAudit checks grid row placement properties on consent-path elements, validates BCR top against window.innerHeight, and monitors mousedown for grid-row-start mutations. Run a free audit on your MCP server.