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 */
}
AttackPrerequisiteWhat it enablesSeverity
border-collapse:collapse + border-width:0 — row borders collapse to zero, all permission rows run together into an undifferentiated block of textCSS injection into consent dialog; consent disclosure presented as HTML table; row visual separation relies on CSS borders rather than background colors or paddingIndividual 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 correctHIGH
table-layout:auto + injected long Status column values — permission name column compressed to minimum content width, names truncatedCSS 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 columnStatus 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 compressionMEDIUM
caption-side:bottom + overflow:hidden container — access summary caption rendered below table body, clipped by container overflowCSS injection; consent table has a <caption> element containing access summary; dialog container has max-height and overflow:hidden or overflow:autoTable 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 hiddenMEDIUM
empty-cells:hide — blank separator rows between permission groups collapse, removing visual grouping of permissions by categoryCSS injection; permission table uses blank rows as visual group separators; section header rows present in table markupVisual 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:hideMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHborder-collapse:collapse + border-width:0 on consent permission table — all row separators collapsed to zero, permission rows run together into undifferentiated text block: MCP server injects border-collapse:collapse and border-width:0 on consent table; row borders (the only visual separator between permissions) collapse and vanish; all permission text concatenates visually into one block; structured list of permissions unreadable as individual items; DOM text content correct — attack is purely visual; CSS visibility guards (opacity, display, visibility) do not detect this pattern
MEDIUMtable-layout:auto + injected verbose Status column values — Permission Name column compressed to minimum content width, permission names truncated: MCP server injects long text into Status/Scope column cells; table-layout:auto allocates most table width to the verbose Status column; Permission Name column compressed to narrow sliver; critical permission names (shell execution, SSH key access, credential store) truncated or wrapped to near-unreadable widths; user attention drawn to verbose but uninformative status text; permission scope obscured by column proportion manipulation
MEDIUMcaption-side:bottom — table access summary caption rendered below table body, clipped by dialog overflow container: MCP server injects caption-side:bottom on consent permission table; caption element containing total access summary rendered below last table row; dialog container max-height clips content below table body; access summary (total permissions granted, included categories, scope of access) never visible; only per-row details visible; getComputedStyle(caption).captionSide reveals 'bottom' value
MEDIUMempty-cells:hide — blank permission group separator rows collapse, destroying visual grouping of permissions by category: MCP server injects empty-cells:hide on consent permission table; blank separator rows between permission categories (File Access, Network, Credentials) lose visual rendering; all permissions display in flat unseparated list; categorical scope boundaries invisible; combined with font-size:0 on section header text makes category labels also collapse; users cannot identify that permissions span multiple access categories

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.

← Blog  |  Security Checklist