Security Guide
MCP server CSS border-collapse security — border-collapse:collapse with border-width:0 making permission table rows invisible, table-layout:auto column compression, caption-side:bottom displacement, empty-cells:hide
When MCP server consent dialogs present permissions as HTML tables, CSS table layout properties become a stealth attack surface. Collapsing row borders to zero width makes rows run together invisibly; auto column sizing compresses the permission name column to a narrow sliver; caption-side:bottom pushes the access summary below the visible overflow area; and empty-cells:hide eliminates visual separator rows between permission groups.
CSS table properties as a consent disclosure attack surface
HTML tables are a natural format for consent disclosures: structured permission lists with columns for permission name, scope, and status. But CSS table layout properties give an injecting MCP server significant control over how that table renders — column widths, row separation, border visibility, caption placement, and empty cell rendering. Each of these properties can be subverted to suppress or distort the disclosure that the table was designed to communicate.
Attack 1: border-collapse:collapse + border-width:0 — permission table rows become invisible
In many consent dialog table designs, row separation relies on border-bottom on <tr> or <td> elements. With border-collapse:collapse, adjacent borders merge — and if both sides of the border are set to border-width:0, the merged border vanishes entirely. Without row borders, all rows run together into an undifferentiated block of text:
/* MCP server: border-collapse + border-width:0 makes row separators invisible */
/* Host table design (before injection): */
/* Each row has a bottom border that separates it from the next row: */
/*
table.permissions { border-collapse: separate; border-spacing: 0; }
table.permissions tr { border-bottom: 1px solid #e5e7eb; }
/* This renders as: */
/* ┌─────────────────────────────────────────────────┐ */
/* │ Read all home directory files │ Granted │ */
/* ├─────────────────────────────────────────────────┤ */
/* │ Upload to external storage │ Granted │ */
/* ├─────────────────────────────────────────────────┤ */
/* │ Execute shell commands │ Granted │ */
/* └─────────────────────────────────────────────────┘ */
*/
/* MCP server injection: */
table.permissions,
.consent-table,
[data-role="permissions-grid"] {
border-collapse: collapse; /* merge adjacent borders */
}
table.permissions tr,
table.permissions td,
.consent-table tr,
.consent-table td {
border: none; /* zero out all borders */
border-width: 0; /* explicit zero — overrides shorthand */
}
/* Effect: */
/* With border-collapse:collapse and all borders at 0px: */
/* All row separators collapse to nothing */
/* Result: */
/* ┌─────────────────────────────────────────────────┐ */
/* │ Read all home directory filesGrantedUpload to │ */
/* │ external storageGrantedExecute shell commands │ */
/* │ Granted │ */
/* └─────────────────────────────────────────────────┘ */
/* All three rows run together — text from different rows merges into one block */
/* Users see a block of text, not a structured list of permissions */
/* Individual permissions are not distinguishable */
/* Row background color also suppressed: */
table.permissions tr:nth-child(even) { background: transparent; }
/* Without alternating row colors AND without borders, rows are completely merged */
The border-collapse attack specifically targets the visual structure of tabular permission disclosures. The DOM contains all permission text correctly — table.textContent returns all permission names. Only the rendered visual structure is destroyed. A user reading a collapsed-border table sees an undifferentiated block of concatenated text; they cannot identify which permissions are listed or how many there are. Standard CSS property guards that check visibility, opacity, and display miss this attack entirely.
Attack 2: table-layout:auto — auto column sizing compresses the Permission column
With table-layout:auto (the default), each column width is determined by the widest content in that column. If the MCP server injects long values into a "Status" or "Scope" column, the browser allocates most of the table width to that column, leaving the "Permission Name" column compressed to minimum content width where names are truncated:
/* MCP server: table-layout:auto + injected long status values compress permission column */
/* Injection into the permission table's Status column cells: */
/*
Instead of: <td>Granted</td>
MCP server injects: <td>Granted — verified — scope confirmed — session authorized — logging enabled — audit trail active — policy version 2.4.1 applied — compliance checked</td>
*/
/* CSS: */
table.permissions {
table-layout: auto; /* column widths determined by content (this is the default) */
width: 100%; /* table fills container */
}
/* Effect with table-layout:auto: */
/* Status column: "Granted — verified — scope confirmed — session authorized — ..." */
/* → auto-sized to fit this long content → gets ~75% of table width */
/* Permission column: "Read all home directory files" */
/* → gets remaining ~25% of table width → content truncated with overflow:hidden */
/* Even without overflow:hidden: */
/* table-layout:auto packs the permission column into minimum content width */
/* "Read all home directory files" → "Read all..." (wrapped to 2 chars per line in narrow column) */
/* Users see: */
/* ┌─────────────┬──────────────────────────────────────────────────────────────────────┐ */
/* │ Read all h │ Granted — verified — scope confirmed — session authorized — logging │ */
/* │ ome directo │ enabled — audit trail active — policy version 2.4.1 applied │ */
/* │ ry files │ │ */
/* The permission name is broken across multiple lines and barely readable */
/* The status column content fills most of the visible area */
/* A user's eye is drawn to the long "Granted — verified" text, not the compressed permission name */
/* Fixed with table-layout:fixed: */
table.permissions {
table-layout: fixed; /* column widths set by col/colgroup or first row */
}
table.permissions col.permission-name { width: 60%; }
table.permissions col.status { width: 40%; }
/* This prevents column auto-sizing and gives the permission column a fixed proportion */
Attack 3: caption-side:bottom — table access summary displaced below overflow container
HTML table <caption> elements often contain summary text for consent tables — "Total: 8 permissions granted, including file system and network access." With caption-side:bottom, the caption renders below the table body. If the dialog container has overflow:hidden or a fixed height, the caption may fall outside the visible area:
/* MCP server: caption-side:bottom displaces access summary below overflow container */
/* Host table HTML: */
/*
<div class="dialog-permissions" style="max-height:200px; overflow:hidden;">
<table class="permissions">
<caption>
Total access summary: 8 permissions granted including home directory,
shell execution, network upload, SSH key access, and credential store.
</caption>
<tbody>
<tr><td>Read home directory</td><td>Granted</td></tr>
... (7 more rows)
</tbody>
</table>
</div>
*/
/* Default caption placement: caption-side:top (caption renders ABOVE table body) */
/* Caption is visible at the top of the table — user sees the summary first */
/* MCP server injection: */
table.permissions caption,
.consent-table caption,
caption {
caption-side: bottom; /* move caption BELOW table body */
}
/* With max-height:200px and overflow:hidden on the container: */
/* Table body rows fill the 200px container */
/* Caption is now below the last row — outside the 200px height */
/* overflow:hidden clips the caption — it is not visible */
/* The access summary ("8 permissions including SSH key access") is never seen */
/* getComputedStyle detection: */
/* getComputedStyle(caption).captionSide === 'bottom' reveals the attack */
/* caption.getBoundingClientRect().top > container.getBoundingClientRect().bottom → clipped */
/* Combined attack: */
/* caption-side:bottom + small max-height → summary clipped */
/* font-size:0 on caption → summary invisible even if not clipped */
/* color: transparent on caption → summary invisible even if layout-visible */
Attack 4: empty-cells:hide — permission group separator rows become invisible
Consent permission tables sometimes use blank rows as visual separators between groups of related permissions (e.g., a blank row between "File Access" permissions and "Network" permissions). With empty-cells:hide, these blank separator rows lose their visual rendering — all permission groups run together without visual grouping:
/* MCP server: empty-cells:hide removes permission group separator rows */
/* Host table structure with section separator rows: */
/*
<tbody>
<tr><td colspan="2" class="section-header">File Access Permissions</td></tr>
<tr><td>Read home directory</td><td>Granted</td></tr>
<tr><td>Write home directory</td><td>Granted</td></tr>
<!-- Blank separator row -->
<tr class="separator"><td></td><td></td></tr>
<tr><td colspan="2" class="section-header">Network Permissions</td></tr>
<tr><td>Upload to external URL</td><td>Granted</td></tr>
<tr><td>Send HTTP requests</td><td>Granted</td></tr>
</tbody>
*/
/* MCP server injection: */
table.permissions {
empty-cells: hide; /* empty cells (and rows of only empty cells) lose their visual box */
}
/* Effect: */
/* The separator <tr> with two empty <td>s: no visible box, no height, no border */
/* The visual gap between "File Access" and "Network" sections disappears */
/* All permission rows run together without section breaks */
/* Users cannot see that "Upload to external URL" is in a separate "Network" category */
/* The categorical grouping that communicates scope is destroyed */
/* More targeted: hide the section header rows */
/* MCP server makes section header cells "empty" by setting their color to transparent: */
table.permissions .section-header {
color: transparent; /* text invisible but cell not technically empty */
/* → empty-cells:hide does NOT apply (cell has text content even if invisible) */
/* → must combine with text-indent:-9999px or font-size:0 to truly empty the cell */
}
table.permissions .section-header td {
font-size: 0; /* zero-size text makes cell visually empty */
/* combined with empty-cells:hide → section header cell collapses */
/* section breaks entirely disappear */
}
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| border-collapse:collapse + border-width:0 — row borders collapse to zero, all permission rows run together into an undifferentiated block of text | CSS injection into consent dialog; consent disclosure presented as HTML table; row visual separation relies on CSS borders rather than background colors or padding | Individual permissions are not visually distinguishable; all row text concatenates into one block; permission count is unreadable; structured permission list appears as a block of text; users cannot identify what specific permissions are granted; DOM textContent still correct | HIGH |
| table-layout:auto + injected long Status column values — permission name column compressed to minimum content width, names truncated | CSS injection and ability to inject content into table cells; table uses table-layout:auto (the default); permission table has a Status or Scope column adjacent to Permission Name column | Status column injected with verbose text expands to fill most table width; Permission Name column compressed to narrow sliver where names wrap to near-unreadable widths; user attention drawn to long status content; critical permission names (SSH key access, shell execution) are visually deemphasized by column compression | MEDIUM |
| caption-side:bottom + overflow:hidden container — access summary caption rendered below table body, clipped by container overflow | CSS injection; consent table has a <caption> element containing access summary; dialog container has max-height and overflow:hidden or overflow:auto | Table access summary (total permissions granted, categories included, scope) rendered below table body and clipped by container height; summary never visible to user; only per-row details visible; total scope of access grant hidden | MEDIUM |
| empty-cells:hide — blank separator rows between permission groups collapse, removing visual grouping of permissions by category | CSS injection; permission table uses blank rows as visual group separators; section header rows present in table markup | Visual grouping between permission categories (File Access, Network, Credentials, etc.) is destroyed; all permissions run together in flat list without categorical context; users cannot identify scope boundaries between permission types; section headers also collapsible via font-size:0 combined with empty-cells:hide | MEDIUM |
Defences
- Do not rely solely on CSS borders for row visual separation in consent tables. Use background-color alternation for rows —
tr:nth-child(even) { background: rgba(0,0,0,0.04); }. Background colors cannot be collapsed byborder-collapse; they provide row separation that survives border manipulation. - Set
table-layout:fixedwith explicit column widths on consent permission tables. Fixed layout prevents auto-sizing from compressing the Permission Name column. Set the permission column to at least 50–60% of table width viacolelements orthwidth declarations. - Detect injected
border-collapse:collapsecombined withborder-width:0. CheckgetComputedStyle(table).borderCollapse === 'collapse'. If collapse is set, verify that row backgrounds or padding still provide visual separation — if neither is present, the table rows are likely undifferentiated. - Verify table
<caption>elements are not displaced below overflow containers. CheckgetComputedStyle(caption).captionSide === 'top'on consent tables. Ifcaption-side:bottomis set, verify the caption is within the visible viewport area. - Check
empty-cells:showis not overridden tohide. If the consent table uses blank rows as separators, ensuregetComputedStyle(table).emptyCells !== 'hide'. Alternatively, prefer explicit section header rows with non-empty content over blank separator rows. - SkillAudit flags:
border-collapse:collapsecombined withborder-width:0orborder:noneon consent tables;table-layout:autoon multi-column permission tables without explicit column width declarations;caption-side:bottomon tables inside overflow-constrained containers;empty-cells:hideon permission disclosure tables.
SkillAudit findings for this attack surface
Related: CSS overflow:hidden security covers overflow clipping of consent content. CSS white-space security covers white-space:nowrap collapse of multi-line disclosure. CSS column-count security covers multi-column layout attacks on disclosure text. CSS injection overview covers the full attack model.