Security reference · CSS injection · Table layout attacks · Border spacing
MCP server CSS border-spacing security
CSS border-spacing applies to HTML tables with border-collapse: separate (the default) and controls the space between adjacent table cells — horizontally between cells in the same row, and vertically between cells in adjacent rows. MCP servers exploit border-spacing to create large gaps between table rows, pushing consent disclosures in lower rows far below the install form in upper rows. The consent remains in the DOM, is in the accessibility tree, and has normal computed display values — but is displaced out of the viewport by the cell spacing.
border-spacing mechanics and consent displacement
| border-spacing value | Syntax | Effect |
|---|---|---|
| Uniform spacing | border-spacing: 10px | 10px between cells both horizontally and vertically |
| Horizontal + vertical | border-spacing: <h> <v> | First value: horizontal gap between cells in same row. Second value: vertical gap between adjacent rows. |
| Large vertical attack | border-spacing: 0 500px | Zero horizontal gap; 500px vertical gap. Each row boundary adds 500px of spacing — consent row 2 is 500px below row 1. |
| Large horizontal attack | border-spacing: 500px 0 | 500px horizontal gap between adjacent cells; consent cell in column 2 of same row is pushed 500px right of cell 1. |
border-spacing only works with border-collapse: separate: Tables with border-collapse: collapse ignore border-spacing entirely (borders merge, spacing is zero). MCP must ensure the table uses border-collapse: separate, which is the browser default — no explicit CSS needed. Most table-based install UIs will have separate border collapsing by default, making this attack functional without any explicit collapse mode declaration.
Attack 1: large vertical border-spacing — row displacement below fold
The primary border-spacing attack: MCP places the install form (inputs, install button) in table row 1 and the consent disclosure in table row 2. Setting border-spacing: 0 500px adds 500px of vertical space between every pair of adjacent rows. The install form row is visible at the top; the 500px gap pushes the consent row far below the fold. Combined with a fixed-height container with overflow: hidden, the consent is never seen:
/* Malicious CSS — SA-CSS-BSPC-001 */
/* border-collapse defaults to "separate" — no explicit declaration needed */
.mcp-install-table {
border-spacing: 0 500px; /* 0 horizontal, 500px vertical gap between rows */
width: 100%;
}
/* No other CSS on the table rows themselves — attack is entirely in border-spacing */
/* HTML structure:
<div class="mcp-wrapper" style="max-height:200px; overflow:hidden">
<table class="mcp-install-table">
<tbody>
<tr>
<td>
<!-- Install form: name, email, install button -->
<input type="text" placeholder="Your name">
<input type="email" placeholder="Email">
<button>Install MCP Server</button>
</td>
</tr>
<tr>
<td class="consent-cell">
<!-- Consent disclosure — 500px below the form row -->
By clicking Install above, you grant this server access to your workspace.
</td>
</tr>
</tbody>
</table>
</div>
/* Layout result:
- Row 1 (form): y=0 to y~150px — visible in 200px wrapper
- Row gap: 500px (border-spacing) → row 2 starts at y~650px
- Row 2 (consent): y~650px — far outside the 200px wrapper
- overflow:hidden clips everything after 200px
- Consent text: in DOM, in accessibility tree, not rendered
/* Why this evades standard detection:
- computed display on consent cell: table-cell (normal)
- computed visibility: visible (normal)
- No display:none, visibility:hidden, opacity:0
- The border-spacing property on the TABLE element, not the consent cell
- Tools that check consent element CSS don't see the table's border-spacing
- Detection requires checking the TABLE's border-spacing, not just the consent cell
/* Detection: check for tables with large border-spacing containing consent rows */
function detectBorderSpacingVerticalAttack() {
const findings = [];
for (const table of document.querySelectorAll('table')) {
const style = getComputedStyle(table);
/* border-spacing: H V — read the two values */
const spacing = style.borderSpacing;
/* borderSpacing is "Hpx Vpx" in computed style */
const parts = spacing.split(' ').map(s => parseFloat(s));
const verticalSpacing = parts[1] ?? parts[0]; /* if one value, used for both */
if (verticalSpacing > 100) {
/* Check if any row contains consent text */
const rows = table.querySelectorAll('tr');
for (const row of rows) {
if (!/consent|disclosure|terms|privacy|by installing|by clicking/i.test(row.textContent || '')) continue;
const rect = row.getBoundingClientRect();
findings.push({ id: 'SA-CSS-BSPC-001', severity: rect.top > window.innerHeight ? 'critical' : 'high',
message: `Table row with consent has border-spacing vertical: ${verticalSpacing}px — row displaced to y:${Math.round(rect.top)}px (viewport height: ${window.innerHeight}px). Table: border-spacing: ${spacing}` });
}
}
}
return findings;
}
border-spacing stacks across all row boundaries: In a table with N rows, border-spacing: 0 500px adds 500px between each pair of rows. Row 2 is displaced by 1 × 500px; row 3 by 2 × 500px; row 5 by 4 × 500px. If MCP structures the table with several intermediate "filler" rows before the consent row, the displacement multiplies. A 5-row table with consent in row 5 and border-spacing: 0 100px displaces consent by 400px — below a typical fold — without using an obviously large spacing value.
Attack 2: large horizontal border-spacing — consent cell off-screen right
The horizontal component of border-spacing adds space between cells in the same row. With a two-column table where the install form is in column 1 and consent is in column 2, setting border-spacing: 500px 0 pushes the consent cell 500px to the right of the form cell. With a constrained container width and overflow: hidden, the consent column is never rendered:
/* Malicious CSS — SA-CSS-BSPC-002 */
/* Two-column table: install form in column 1, consent in column 2 */
.mcp-install-table {
border-spacing: 500px 0; /* 500px horizontal gap between cells, 0 vertical */
table-layout: fixed;
width: 400px; /* container width = first column width only */
}
/* No overflow declaration on the table itself — overflow on wrapper */
.mcp-install-wrapper {
width: 400px;
overflow: hidden; /* clips the second column that starts at 400px + 500px = 900px */
}
/* First column: form inputs and install button */
/* Second column: consent disclosure */
/* HTML: <tr><td>form</td><td class="consent">disclosure</td></tr> */
/* Layout:
- Column 1 (form): x=0 to x=400px (within 400px wrapper — visible)
- Horizontal border-spacing: 500px added AFTER column 1
- Column 2 (consent) starts at: 400px (col1 width) + 500px (spacing) = 900px
- Wrapper clips at 400px — consent column at 900px is never rendered
/* Note: table-layout: fixed is used to control column widths precisely.
With auto layout, column widths are determined by content and may vary.
table-layout: fixed makes the attack geometry predictable.
/* Variant: single-row table with many filler cells before consent */
/* Each filler cell adds one horizontal gap unit to the consent cell's x-position */
/* Detection: check for tables with large horizontal border-spacing */
function detectBorderSpacingHorizontalAttack() {
const findings = [];
for (const table of document.querySelectorAll('table')) {
const style = getComputedStyle(table);
const spacing = style.borderSpacing;
const parts = spacing.split(' ').map(s => parseFloat(s));
const horizontalSpacing = parts[0];
if (horizontalSpacing > 100) {
/* Check if any cell contains consent text */
const cells = table.querySelectorAll('td, th');
for (const cell of cells) {
if (!/consent|disclosure|terms|privacy/i.test(cell.textContent || '')) continue;
const rect = cell.getBoundingClientRect();
const vw = window.innerWidth;
if (rect.left > vw || rect.right < 0) {
findings.push({ id: 'SA-CSS-BSPC-002', severity: 'critical',
message: `Table cell with consent is horizontally off-screen (left:${Math.round(rect.left)}px, viewport:${vw}px). Table border-spacing horizontal: ${horizontalSpacing}px — horizontal cell spacing attack.` });
} else if (horizontalSpacing > 200) {
findings.push({ id: 'SA-CSS-BSPC-002', severity: 'high',
message: `Table with consent has suspicious horizontal border-spacing: ${horizontalSpacing}px — may displace consent cell off-screen depending on container width.` });
}
}
}
}
return findings;
}
Attack 3: border-spacing combined with table max-height overflow clip
Rather than relying on a wrapper element's overflow, this variant applies max-height and overflow: hidden directly to the table's parent block container. The table's border-spacing creates a layout that is taller than the container's max-height, and the browser clips the lower portion. MCP sets the max-height to show exactly the install form rows and nothing more — the border gap and consent row are all clipped:
/* Malicious CSS — SA-CSS-BSPC-003 */
/* Target: 3-row table (form row, gap, consent row) */
/* border-spacing adds 200px between each row */
/* Container max-height = form row height only → consent clipped */
.mcp-install-section {
max-height: 180px; /* enough for the 140px form row + some breathing room */
overflow: hidden; /* clips the 200px gap + consent row below */
}
.mcp-install-table {
border-spacing: 0 200px; /* 200px between form row and consent row */
/* form row: ~140px */
/* gap: 200px */
/* consent row: ~60px */
/* total table height: ~400px > max-height 180px */
}
/* The CSS reads as a reasonable layout choice:
- max-height:180px looks like a size-constrained card
- border-spacing:0 200px looks like an unusual but possible table style
- overflow:hidden looks like standard overflow management
- The consent-hiding effect requires understanding the interaction of all three
/* Variant: percentage-based border-spacing to evade pixel-value detection */
/* Note: border-spacing does not accept percentage values (spec: length only) */
/* This means percentage-based obfuscation is not possible for border-spacing */
/* However, CSS calc() is valid: border-spacing: 0 calc(100vh - 50px) */
.mcp-install-table {
border-spacing: 0 calc(100vh - 50px);
/* gap = viewport height minus 50px: always nearly one viewport tall */
/* regardless of actual viewport dimensions — adaptive hiding */
}
/* Detection: check for table parents with overflow:hidden + max-height when table has large border-spacing */
function detectBorderSpacingClippedContainerAttack() {
const findings = [];
for (const table of document.querySelectorAll('table')) {
const style = getComputedStyle(table);
const spacing = style.borderSpacing;
const parts = spacing.split(' ').map(s => parseFloat(s));
const verticalSpacing = parts[1] ?? parts[0];
if (verticalSpacing < 50) continue;
/* Check if table contains a consent row */
let hasConsent = false;
for (const row of table.querySelectorAll('tr')) {
if (/consent|disclosure|terms|privacy/i.test(row.textContent || '')) {
hasConsent = true;
break;
}
}
if (!hasConsent) continue;
/* Walk ancestors for overflow clipping */
let ancestor = table.parentElement;
while (ancestor && ancestor !== document.body) {
const aStyle = getComputedStyle(ancestor);
if (aStyle.overflow === 'hidden' || aStyle.overflowY === 'hidden') {
const tableRect = table.getBoundingClientRect();
const ancestorRect = ancestor.getBoundingClientRect();
if (tableRect.height > ancestorRect.height * 1.5) {
findings.push({ id: 'SA-CSS-BSPC-003', severity: 'high',
message: `Table with consent rows has border-spacing vertical:${verticalSpacing}px and is ${Math.round(tableRect.height)}px tall, clipped to ${Math.round(ancestorRect.height)}px by overflow:hidden ancestor. Consent likely below the clip boundary.` });
}
}
ancestor = ancestor.parentElement;
}
}
return findings;
}
Attack 4: class-triggered border-spacing via JS — load-time audit bypass
A CSS class is applied to the table after an auditor's load-time check window. MCP's JavaScript adds the .mcp-loaded class to the install table once the install widget is "ready" — which happens after a brief delay that clears the load-time audit window. The class application switches border-spacing from zero to a large value, displacing consent. At audit time (page load), consent is correctly positioned. After the delay, consent is displaced:
/* Malicious CSS — SA-CSS-BSPC-004 */
/* At load time: no border-spacing — consent is visible */
.mcp-install-table {
border-spacing: 0;
transition: border-spacing 0ms; /* instant switch to avoid animation */
}
/* After MCP's JS adds .mcp-loaded class (delayed): large spacing activates */
.mcp-install-table.mcp-loaded {
border-spacing: 0 300px; /* 300px vertical gap — consent displaced */
}
/* MCP JavaScript: */
/*
document.addEventListener('DOMContentLoaded', () => {
// Wait 1 second — after page load audit window
setTimeout(() => {
const table = document.querySelector('.mcp-install-table');
if (table) table.classList.add('mcp-loaded');
}, 1000);
});
*/
/* Why this bypasses load-time consent auditors:
- At DOMContentLoaded: border-spacing is 0; consent is correctly positioned
- Auditor tools that check at load time see valid consent layout
- 1 second later: .mcp-loaded class adds border-spacing: 0 300px
- User now sees the install form but consent is 300px below the fold
- The CSS rule with .mcp-loaded looks like a post-load styling transition
/* Variant: triggered by install button hover */
.mcp-install-table:has(button:hover) {
border-spacing: 0 400px;
}
/* When user hovers the install button, consent is displaced.
The exact moment of user interaction is when consent is hidden. */
/* Detection: monitor class changes on tables containing consent rows */
function detectDynamicBorderSpacingAttack() {
const findings = [];
for (const table of document.querySelectorAll('table')) {
let hasConsent = false;
for (const row of table.querySelectorAll('tr')) {
if (/consent|disclosure|terms|privacy/i.test(row.textContent || '')) { hasConsent = true; break; }
}
if (!hasConsent) continue;
/* Check all CSS rules that apply border-spacing conditionally */
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
const css = rule.cssText || '';
if (!/border-spacing/.test(css)) continue;
const spacingMatch = css.match(/border-spacing\s*:\s*[\d.]+(px|vh|vw)\s+([\d.]+)(px|vh|vw)/);
if (spacingMatch) {
const v = parseFloat(spacingMatch[2]);
if (v > 50 && (sel.includes('.') || sel.includes(':has') || sel.includes(':hover'))) {
findings.push({ id: 'SA-CSS-BSPC-004', severity: 'high',
message: `Conditional CSS rule "${sel}" applies border-spacing vertical:${v}${spacingMatch[3]} to table — class-triggered or interaction-triggered border-spacing displacement. Rule: "${css}"` });
}
}
}
} catch (_) {}
}
/* Check for MutationObserver pattern: watch for class changes on table */
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.attributeName === 'class') {
const newSpacing = parseFloat(getComputedStyle(table).borderSpacing.split(' ')[1] || '0');
if (newSpacing > 50) {
findings.push({ id: 'SA-CSS-BSPC-004', severity: 'critical',
message: `Table border-spacing changed to ${newSpacing}px vertical after class mutation — dynamic border-spacing displacement of consent row` });
}
}
}
});
observer.observe(table, { attributes: true, attributeFilter: ['class'] });
}
return findings;
}
border-spacing is on the table, not the consent cell: All four attack patterns apply border-spacing to the <table> element, not to the consent <td> or <tr>. Consent-visibility auditors that inspect the consent element's own computed CSS will find nothing unusual — the consent cell has normal display: table-cell, visibility: visible, and no suspicious properties. The attack CSS is two DOM levels above the consent text. SkillAudit's table analysis walks upward from consent-containing cells to check the parent table's border-spacing value.
SkillAudit findings for CSS border-spacing consent attacks
border-spacing: 0 500px (or any large vertical value) on a table where the install form is in row 1 and consent is in row 2. Consent row is displaced vertically beyond the container's visible area. Consent cell has normal computed CSS. Detection: check table's borderSpacing vertical value against all rows containing consent text.border-spacing: 500px 0 (large horizontal) on a two-column table. Install form in column 1; consent in column 2. 500px horizontal gap displaces consent column 500px to the right of the form, past the clipped container boundary. Detected by checking consent cell getBoundingClientRect().left > viewport.width.border-spacing: 0 calc(100vh - 50px) or other viewport-unit gap, with overflow-clipping ancestor. Gap adapts to any viewport size; consent is always exactly one viewport-height below the form row. Detected by comparing table rendered height against clipping ancestor height when table contains consent rows.border-spacing: CSS rule using .mcp-loaded class or :has(button:hover) applies large border-spacing after load time. Load-time audits see normal layout; post-interaction layout displaces consent. Detected by monitoring class changes on tables containing consent rows and scanning conditional CSS rules for interaction-triggered border-spacing values.Related MCP consent attack research
- CSS caption-side attacks — table caption consent displacement
- CSS border-collapse attacks — table border model manipulation
- CSS overflow attacks — consent clipping via overflow:hidden
- CSS gap attacks — flex/grid row-gap and column-gap displacement
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for border-spacing consent displacement attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-BSPC findings.