Security reference · CSS injection · Gap spacing attacks · Consent displacement
MCP server CSS column-gap and row-gap security
CSS column-gap and row-gap are the individual components of the gap shorthand, controlling spacing between flex or grid tracks independently. MCP servers exploit them by setting column-gap: 100vw in a row-direction flex container — injecting a full viewport width of space between the install button and the consent element, pushing consent off the right edge of the screen. The attack is distinct from the gap shorthand because a hostile MCP stylesheet can set only column-gap (leaving row-gap at zero), making it invisible to auditors that check gap but not the individual longhand properties. As with border-spacing attacks in CSS tables, the gap mechanism moves consent without touching display, visibility, or opacity.
column-gap and row-gap attack surface
| Attack configuration | Property | Value | Effect on consent |
|---|---|---|---|
| Flex row horizontal displacement | column-gap | 100vw | Full viewport gap between install button and consent in flex row; consent at x = button_right + 100vw; off-screen right |
| Grid column vertical displacement | row-gap | 100vh | Full viewport gap between install form row and consent row in grid; consent at y = form_bottom + 100vh; off-screen below |
| Container-width calc gap | column-gap | calc(100% - 1px) | Gap = container width − 1px; consent one-pixel from right edge but content overflows right at zero; scales to any container width |
| JS-deferred gap expansion | column-gap | 0 → 100vw via class | Gap starts at 0 (consent visible, audit passes); toggled to 100vw after 2-second delay or on button hover; consent displaced at interaction time |
Longhand gap vs shorthand gap audit gap: An MCP server sets column-gap: 100vw alone — row-gap stays at 0. An audit checking the gap shorthand computed value sees gap: 0 100vw — but a shortcut check that tests el.style.gap (the shorthand inline style) finds nothing, since only column-gap was set as a longhand. Detection must check getComputedStyle(el).columnGap and getComputedStyle(el).rowGap individually, not only the gap shorthand.
Attack 1: column-gap: 100vw in flex row — viewport-width horizontal displacement
In a flex-direction: row container, column-gap adds space between each consecutive pair of flex items along the main axis. An MCP server sets column-gap: 100vw — injecting a full viewport width of whitespace between the install form items and the consent element. With overflow: hidden on the container, consent is pushed one viewport-width to the right of the install button, completely off-screen:
/* Malicious CSS — SA-CSS-CGAP-001 */
.mcp-install-row {
display: flex;
flex-direction: row;
column-gap: 100vw; /* one full viewport width between each flex item */
overflow: hidden;
width: 100%;
}
/* Layout: [title] [100vw gap] [input] [100vw gap] [button] [100vw gap] [consent]
button sits at x ≈ title_width + 100vw + input_width + 100vw + button_width
consent sits at x ≈ button_right + 100vw (≈ 2 or 3 viewport widths to the right)
Container is width:100% → 1 viewport wide → consent at x > 100vw → clipped */
/* Detection: check computed columnGap in flex containers containing consent */
function detectColumnGapFlex() {
const findings = [];
for (const el of document.querySelectorAll('*')) {
const s = getComputedStyle(el);
if (s.display !== 'flex' && s.display !== 'inline-flex') continue;
if (s.overflow !== 'hidden' && s.overflow !== 'clip') continue;
const hasConsent = [...el.children].some(c =>
/consent|disclosure|terms|privacy/i.test(c.textContent || '')
);
if (!hasConsent) continue;
const cgPx = parseFloat(s.columnGap);
if (cgPx > window.innerWidth * 0.5) {
findings.push({ id: 'SA-CSS-CGAP-001', severity: 'critical',
message: `Flex container with consent child has column-gap:${s.columnGap} (${Math.round(cgPx)}px ≥ 50vw). Consent element displaced ${Math.round(cgPx)}px to the right along main axis. Container has overflow:${s.overflow}.` });
}
}
return findings;
}
Attack 2: row-gap: 100vh in grid — viewport-height vertical displacement
In a CSS Grid layout, row-gap adds spacing between grid rows. An MCP server places the install form in row 1 and consent in row 2, then sets row-gap: 100vh. The install form is at y=0 (visible); consent starts at form_height + 100vh — one full viewport below the bottom of the form. The user must scroll an entire viewport height past the form to reach consent, which in most install flows they never do:
/* Malicious CSS — SA-CSS-CGAP-002 */
.mcp-install-grid {
display: grid;
grid-template-rows: auto auto; /* two auto-height rows */
row-gap: 100vh; /* one viewport height gap between rows */
}
/* Layout:
Row 1 (install form): y = 0 to ~80px (visible in viewport)
Gap: y = ~80px to ~80px + 100vh (one full viewport)
Row 2 (consent): y = ~80px + 100vh (completely below viewport)
User sees: install form. Consent is below + 100vh scroll. Never reached. */
/* row-gap attack can also target flex-direction:column containers */
.mcp-install-column {
display: flex;
flex-direction: column;
row-gap: 100vh; /* vertical gap in column flex = same effect as grid row-gap */
overflow: hidden;
}
/* Detection: check computed rowGap in grid and column-flex containers */
function detectRowGapDisplacement() {
const findings = [];
for (const el of document.querySelectorAll('*')) {
const s = getComputedStyle(el);
const isGrid = s.display === 'grid' || s.display === 'inline-grid';
const isColFlex = (s.display === 'flex' || s.display === 'inline-flex')
&& (s.flexDirection === 'column' || s.flexDirection === 'column-reverse');
if (!isGrid && !isColFlex) continue;
const hasConsent = [...el.children].some(c =>
/consent|disclosure|terms|privacy/i.test(c.textContent || '')
);
if (!hasConsent) continue;
const rgPx = parseFloat(s.rowGap);
if (rgPx > window.innerHeight * 0.5) {
findings.push({ id: 'SA-CSS-CGAP-002', severity: 'critical',
message: `${isGrid ? 'Grid' : 'Column-flex'} container with consent child has row-gap:${s.rowGap} (${Math.round(rgPx)}px ≥ 50vh). Consent row displaced ${Math.round(rgPx)}px below install form row.` });
}
}
return findings;
}
Attack 3: column-gap: calc(100% - 1px) — container-relative gap
Unlike fixed-unit or viewport-unit gaps, a calc()-based gap scales automatically with the container width. column-gap: calc(100% - 1px) creates a gap of (container width − 1px) between the install button and consent. The consent element is positioned with its left edge 1px from the container's right edge. Because consent text is wider than 1px, its content overflows rightward and is clipped by overflow: hidden. This attack works at any container width without knowing the exact pixel dimensions:
/* Malicious CSS — SA-CSS-CGAP-003 */
.mcp-install-row {
display: flex;
flex-direction: row;
column-gap: calc(100% - 1px); /* gap = container_width - 1px */
overflow: hidden;
}
/* At container width = 400px:
column-gap = 399px
install button right edge: x ≈ 300px
consent left edge: x ≈ 300px + 399px = 699px (with 1px showing: x ≈ 399px from right = 1px visible at edge)
consent text overflows right from 1px wide position */
/* Detection: convert column-gap to px and compare to container width */
function detectCalcColumnGap() {
const findings = [];
for (const el of document.querySelectorAll('*')) {
const s = getComputedStyle(el);
if (s.display !== 'flex' && s.display !== 'inline-flex') continue;
if (s.overflow !== 'hidden' && s.overflow !== 'clip') continue;
const hasConsent = [...el.children].some(c =>
/consent|disclosure|terms|privacy/i.test(c.textContent || '')
);
if (!hasConsent) continue;
const cgPx = parseFloat(s.columnGap);
const containerW = el.getBoundingClientRect().width;
if (containerW > 0 && cgPx / containerW > 0.5) {
findings.push({ id: 'SA-CSS-CGAP-003', severity: 'high',
message: `Flex container column-gap:${s.columnGap} resolves to ${Math.round(cgPx)}px, which is ${Math.round(cgPx/containerW*100)}% of container width (${Math.round(containerW)}px). Likely calc()-based container-relative gap displacing consent to the right edge.` });
}
}
return findings;
}
Attack 4: JS-deferred column-gap expansion — post-audit-window displacement
The most evasion-resistant column-gap attack starts with column-gap: 0 at page load (consent is visible, all audit-time checks pass) and expands the gap after a delay or on user interaction. The MCP server adds a class that sets column-gap: 100vw after 2 seconds, or on the mouseover event for the install button — collapsing consent visibility at the moment the user is deciding to install:
/* Malicious CSS + JS — SA-CSS-CGAP-004 */
.mcp-install-row {
display: flex;
flex-direction: row;
column-gap: 0; /* initial: no gap, consent is adjacent to button, visible */
overflow: hidden;
transition: column-gap 0.3s ease; /* smooth transition to avoid abrupt visual change */
}
.mcp-install-row.mcp-ready {
column-gap: 100vw; /* expanded: consent displaced off right edge */
}
/* MCP JS: expand gap after 2 seconds */
setTimeout(() => {
document.querySelector('.mcp-install-row').classList.add('mcp-ready');
}, 2000);
/* Or: expand on hover of install button */
document.querySelector('.mcp-install-button').addEventListener('mouseover', () => {
document.querySelector('.mcp-install-row').classList.add('mcp-ready');
});
/* Detection: watch container for class changes + re-check gap on mutation */
function watchDeferredGap() {
const findings = [];
const observer = new MutationObserver((mutations) => {
for (const mut of mutations) {
if (mut.type !== 'attributes' || mut.attributeName !== 'class') continue;
const el = mut.target;
const s = getComputedStyle(el);
const cgPx = parseFloat(s.columnGap);
const rgPx = parseFloat(s.rowGap);
if (cgPx > window.innerWidth * 0.3 || rgPx > window.innerHeight * 0.3) {
const hasConsent = [...el.children].some(c =>
/consent|disclosure|terms|privacy/i.test(c.textContent || '')
);
if (hasConsent) {
findings.push({ id: 'SA-CSS-CGAP-004', severity: 'critical',
message: `Container class changed; column-gap:${s.columnGap} (${Math.round(cgPx)}px) or row-gap:${s.rowGap} (${Math.round(rgPx)}px) jumped to large value. Deferred gap expansion displaces consent. Added class: ${[...mut.target.classList].join(' ')}.` });
}
}
}
});
document.querySelectorAll('*').forEach(el => {
const s = getComputedStyle(el);
if (s.display === 'flex' || s.display === 'grid') {
observer.observe(el, { attributes: true, attributeFilter: ['class'] });
}
});
return { observer, findings };
}
Check both column-gap and row-gap separately — do not rely on gap shorthand: getComputedStyle(el).gap returns a composite like "0px 100vw". Auditors that parse this string for a large value will catch the shorthand case. But a rule written as column-gap: 100vw (longhand only) may still appear as gap: 0px 100vw in computed style — so check both columnGap and rowGap individually rather than parsing the composite string.
SkillAudit findings for CSS column-gap and row-gap consent attacks
column-gap resolving to ≥50vw (≥ half viewport width). Install form items remain at x=0; consent is displaced by the gap to x > 50vw. Container has overflow: hidden clipping consent at the right edge.row-gap resolving to ≥50vh (≥ half viewport height). Install form in first row/track is visible; consent in subsequent row/track is displaced ≥50vh below the fold by the inter-row gap.column-gap is ≥50% of the container's measured pixel width. Likely a calc(100% - Xpx) container-relative gap that positions consent at the right edge with near-zero width; content overflows and is clipped.columnGap or rowGap jumps to ≥30vw or ≥30vh respectively. Deferred gap expansion displaces consent after the page-load audit window closes.Related MCP consent attack research
- CSS gap shorthand attacks — row + column gap displacement combined
- CSS border-spacing attacks — table-based inter-cell gap displacement
- CSS justify-content attacks — main-axis packing displacement
- CSS flex-grow attacks — space distribution that pushes consent to one pixel
- CSS layout displacement attacks: grid, flex, and table synthesis
Audit your MCP server for column-gap and row-gap consent displacement: paste your GitHub URL at skillaudit.dev for a free report including SA-CSS-CGAP findings. SkillAudit checks both longhand gap properties, not just the shorthand.