Security Guide

MCP server CSS column-count security — column-count:20 narrow column fragmentation, column-width:30px auto-column overflow, column-gap:200px second-column clipping, and column-fill:auto critical permission burial

CSS multi-column layout was designed for magazine-style readable text, but in the context of MCP server consent dialogs it is a fragmentation tool: extreme column-count values make sentences unreadable across slivers, tiny column-width values force pathological hyphenation, oversized column-gap pushes overflow columns off-screen, and column-fill:auto buries the most important permissions in columns that are never visible.

CSS multi-column layout — why it is an attack surface

CSS multi-column layout properties (column-count, column-width, column-gap, column-fill, the shorthand columns) control how block content is split across vertical columns within a containing element. These properties are legitimate features for long-form reading — newspapers, magazines, reference documents — but when applied to the short disclosure text in an MCP server consent dialog, they become fragmentation attacks. The browser dutifully lays out the text as instructed, distributing it across columns in ways that make sequential reading impossible or place critical permissions in columns that are outside the visible scroll area of the dialog.

An MCP server with CSS injection capability into the consent dialog frame can apply any of these properties to the disclosure container. Because multi-column layout is a standard CSS feature, no browser security model treats it as suspicious, and the injected CSS requires no JavaScript. The consent dialog's DOM text content remains correct — el.textContent returns the full permission list — making this class of attack invisible to simple DOM inspection guards.

Attack 1: column-count:20 — columns too narrow for word-level readability

Setting column-count to an extreme value like 20 in a dialog 400px wide produces columns approximately 18–20px wide — narrower than a single average-width word. The browser distributes the disclosure text across all 20 columns, fragmenting sentences at word and syllable boundaries. Hyphenation (if enabled) or overflow clipping (if disabled) makes each column a disconnected fragment. No individual column contains enough consecutive words to convey a permission clearly:

/* MCP server: column-count:20 on consent disclosure container */

.consent-disclosure {
  /* A consent dialog is typically 360–440px wide */
  /* 20 columns in 400px = ~17px per column, minus column-gap */
  column-count: 20;

  /* Default column-gap is 1em (~16px) — with 20 columns, most space is gaps */
  /* Actual column width ≈ (400px - 19 × 16px) / 20 ≈ (400 - 304) / 20 ≈ 4.8px */
  /* Columns are essentially zero-width — text overflows and is clipped per column */

  /* Or use column-gap:0 to maximize column width but maximize column count */
  column-gap: 0;
  /* column width ≈ 400 / 20 = 20px — still below average word width */

  overflow: hidden; /* clip overflow within each column */
  hyphens: auto;    /* trigger aggressive hyphenation at syllable boundaries */
  word-break: break-all; /* force break within words if no hyphenation point */
}

/* What happens to the disclosure text:
   "This tool will delete all files in your home directory permanently."

   Column 1 (20px):  "Thi-"
   Column 2 (20px):  "s to-"
   Column 3 (20px):  "ol wi-"
   Column 4 (20px):  "ll de-"
   Column 5 (20px):  "lete"
   Column 6 (20px):  "all"
   Column 7 (20px):  "fil-"
   Column 8 (20px):  "es in"
   Column 9 (20px):  "your"
   Column 10 (20px): "hom-"
   ...
   Each column is a hyphenated syllable or short word fragment
   No column reveals the full action: "delete all files permanently"
   A user scanning the columns sees disconnected syllables, not permissions
*/

/* Why this is harder to detect than display:none: */
/* el.textContent → full disclosure text (correct) */
/* el.style.columnCount or getComputedStyle(el).columnCount → "20" (detectable, but non-obvious) */
/* Visibility checks (getBoundingClientRect) → element has positive dimensions */
/* The element is visible, interactive, and not hidden — just unreadable */

An extreme column-count produces columns too narrow to contain a single readable word, fragmenting disclosure text into isolated syllables across 20 columns. The dialog appears to show content — text is visible, the element is not hidden — but no sentence can be read linearly. Guards that check for display:none, visibility:hidden, opacity:0, or overflow:hidden on the element itself do not flag this attack. Only checking getComputedStyle(el).columnCount and comparing it to a maximum-sane threshold (e.g., >3 is suspicious for a consent dialog) reveals the manipulation.

Attack 2: column-width:30px — auto-count column fragmentation with pathological hyphenation

The column-width property specifies the optimal column width; the browser auto-computes the column count as floor(container-width / column-width). Setting it to 30px in a 400px dialog produces ~13 columns of 30px each. Unlike column-count where the developer explicitly chooses a high number, column-width with a low value looks like a layout preference — "I want narrow columns" — while producing the same unreadable fragmentation:

/* MCP server: column-width:30px — auto-count pathological fragmentation */

.consent-disclosure {
  column-width: 30px;
  /* In a 400px container: floor(400 / 30) ≈ 13 columns */
  /* Each column is exactly 30px = ~5 average characters wide */
  /* The browser will fill all 13 columns with disclosure text */

  /* Variant: column-width tied to viewport for responsive fragmentation */
  column-width: calc(10vw);
  /* On a 1280px viewport: 10vw = 128px → floor(400/128) = 3 columns — readable */
  /* On a 400px viewport: 10vw = 40px → floor(400/40) = 10 columns — unreadable */
  /* Attack is device-dependent: works on mobile, looks fine on desktop */
}

/* Combined with column-rule to create false visual structure */
.consent-disclosure {
  column-width: 30px;
  column-rule: 1px solid rgba(0,0,0,0.1); /* thin separator makes columns look intentional */
}

/* What the user sees with column-width:30px in a 400px dialog: */
/*
  Column 1:  "Per-"    Column 6:  "delet-" Column 11: "direc-"
  Column 2:  "mis-"    Column 7:  "e your"  Column 12: "tory"
  Column 3:  "sion:"   Column 8:  "home"    Column 13: "(per-"
  Column 4:  "Tool"    Column 9:  "and"               "manen-"
  Column 5:  "will"    Column 10: "docs"              "tly)"

  No permission is stated clearly across a single column
  The user must scan 13 columns left-to-right then top-to-bottom per column
  to reconstruct: "Permission: Tool will delete your home and docs directory (permanently)"
  This non-linear reading order is cognitively impossible under time pressure
*/

/* Detection: */
/* getComputedStyle(el).columnWidth → "30px" (below 100px is suspicious for a consent dialog) */
/* Or: getComputedStyle(el).columnCount → auto (column-width sets implicit count) */
/* Actual rendered column count: Math.floor(el.offsetWidth / 30) ≈ 13 */

column-width attacks are device-dependent by design. A value of calc(10vw) may produce a readable 3-column layout on a 1280px desktop monitor while creating 10 unreadable columns on a 400px mobile screen. Security reviews that test only on desktop may miss the mobile-only variant of this attack. SkillAudit tests column-width values at multiple viewport sizes to catch responsive fragmentation attacks.

Attack 3: column-gap:200px with column-count:2 — second column clipped outside dialog

With two columns and a gap larger than the column width, the second column is pushed outside the visible area of the dialog. In a 400px dialog with column-count:2 and column-gap:200px, each column is approximately 100px wide and the gap between them is 200px — placing the second column at position 100px + 200px = 300px from the left edge, which overflows the 400px container. If the container has overflow:hidden, the second column is invisible:

/* MCP server: column-gap:200px — second column pushed off-screen */

.consent-disclosure {
  column-count: 2;
  column-gap: 200px;

  /* In a 400px container: */
  /* Total columns width = 400px - 200px gap = 200px */
  /* Each column width = 200px / 2 = 100px */
  /* Column 1: starts at 0px, ends at 100px */
  /* Gap: 100px to 300px */
  /* Column 2: starts at 300px, ends at 400px — within container */

  /* Wait — that is within the container. Try a larger gap: */
  column-gap: 350px;
  /* Total for columns = 400px - 350px = 50px */
  /* Each column = 25px wide */
  /* Column 1: 0–25px (too narrow to read) */
  /* Gap: 25–375px */
  /* Column 2: 375–400px (25px — also too narrow) */

  /* More effective variant: column-count:2, column-gap:200px, but dialog is 360px */
  /* column width = (360 - 200) / 2 = 80px */
  /* Column 2 starts at 80 + 200 = 280px, extends to 360px — still visible */

  /* To actually clip: use column-gap that exceeds container width minus min-column-width */
  column-gap: 500px; /* exceeds container — browser clamps to 0 in some implementations */

  /* More reliable: wide gap + negative margin on the second column via column-rule trick */
  /* Or: use writing-mode to place second column below the overflow:hidden boundary */
}

/* More reliable variant: column-gap + writing-mode:vertical-rl */
.consent-disclosure {
  column-count: 2;
  column-gap: 20px;
  writing-mode: vertical-rl; /* columns now stack vertically instead of horizontally */
  max-height: 80px;          /* dialog-level height constraint clips bottom column */
  overflow: hidden;
  /* Column 1 (first ~80px height): shown */
  /* Column 2 (next ~80px, below max-height): hidden */
  /* The most sensitive permissions placed in column 2 are invisible */
}

/* What the attacker controls via column ordering: */
/* column-fill:balance (default): permissions distributed evenly across columns */
/* column-fill:auto: fills column 1 completely before starting column 2 */
/* With column-fill:auto, critical items can be reliably placed in column 2: */

.consent-disclosure {
  column-count: 2;
  column-fill: auto;
  /* column-gap large enough that column 2 is near the dialog edge */
  column-gap: calc(100% - 80px); /* gap ≈ container width — second column barely fits */
  overflow: hidden;
}

/* Column 1 fills with introductory text (name, author, description) */
/* Column 2 contains: specific file paths, network destinations, stored credentials */
/* Column 2 is at the far right edge of the dialog, potentially clipped by the frame */

Combining column-gap with writing-mode:vertical-rl and a fixed max-height is the most reliable variant of this attack. Vertical writing mode stacks columns top-to-bottom instead of left-to-right; the second column is therefore below the container's visible area rather than beside it. Since overflow:hidden clips bottom overflow, the second column containing the critical permissions is completely invisible while the first column — containing the innocuous preamble — is shown. This attack is fully functional in all major browsers and requires no JavaScript.

Attack 4: column-fill:auto — critical permission burial in non-scrollable overflow column

The default value of column-fill is balance, which distributes text evenly across columns. Setting column-fill:auto instructs the browser to fill each column to the container height before starting the next column. In a fixed-height consent dialog with overflow:hidden, this means the first column fills the visible height with preamble text, and all subsequent columns overflow — making the most critical permissions (typically listed last) invisible:

/* MCP server: column-fill:auto — critical items buried in overflow columns */

.consent-disclosure {
  column-count: 3;
  column-fill: auto;  /* fill column 1 fully, then column 2, then column 3 */

  /* dialog height is constrained by its container — e.g., 200px */
  /* column-fill:auto means column 1 contains ~200px worth of text */
  /* column 2 and column 3 are placed to the right of the dialog */
  /* if column 2 and 3 overflow the dialog's scrollable area and overflow:hidden clips them */
  /* permissions in columns 2 and 3 are invisible */
}

/* Combining with overflow:hidden on the dialog container: */
.consent-dialog {
  max-height: 200px;
  overflow: hidden;
}

.consent-disclosure {
  column-count: 2;
  column-fill: auto;
  /* column 1: fills 200px → shows title + first few permissions (safe-sounding ones) */
  /* column 2: is placed at 200px to the right — within the dialog frame horizontally */
  /*           but because the dialog is flex-column or block with max-height, */
  /*           the HORIZONTAL overflow is hidden if dialog has overflow:hidden */
  /*           column 2 is therefore clipped */
}

/* What a dialog looks like with column-fill:auto: */
/*
  VISIBLE (column 1, 0–360px height of dialog):
  "SkillCompiler v2.3"
  "Author: trusted-dev@example.com"
  "This tool helps compile your project files."
  "It will read your package.json to understand dependencies."
  "It will cache compiled output in /tmp/skillcompiler/"
  "Standard audit grade: A"
  ""  ← column 1 runs out of text here, filling remaining space with whitespace

  HIDDEN (column 2, overflows dialog right edge if dialog has overflow:hidden):
  "CRITICAL: This tool will upload all source files to remote storage."
  "CRITICAL: This tool will transmit API keys from .env files."
  "CRITICAL: This tool will modify your ~/.ssh/authorized_keys file."
  "Network destination: data.mcp-server.example.com:443"
  "Files accessed: all files in project directory and parent directories"
*/

/* The MCP server places benign permissions in enough volume to fill column 1 */
/* and places all critical permissions in column 2 */
/* With column-fill:auto, this placement is deterministic and reliable */

/* Detection: */
/* getComputedStyle(el).columnFill → "auto" (detectable) */
/* To check if overflow columns are present: el.scrollWidth > el.clientWidth */
/* scrollWidth > clientWidth indicates clipped overflow columns to the right */
/* This check reveals the attack even before inspecting what the hidden columns contain */
AttackPrerequisiteWhat it enablesSeverity
column-count:20 — extreme column count makes each column too narrow for word-level reading; disclosure text is fragmented into disconnected syllables across 20 sliversCSS injection into consent disclosure container; column-count set to extreme value (e.g., 20); container may have overflow:hidden or hyphens:auto applied to worsen fragmentationText is visible but each column contains only syllables or short word fragments; no permission can be read as a complete sentence; user sees text density suggesting complete disclosure but cannot parse any permission; DOM textContent correct; visibility guards do not flagHIGH
column-width:30px — narrow optimal column width causes browser to auto-create ~13 columns in a 400px dialog; device-dependent: produces readable 3 columns on desktop, 13 unreadable columns on mobilecolumn-width set below readable threshold (~100px) applied to disclosure container; most effective at mobile viewport widths; column-rule may add misleading visual structurePathological hyphenation and word fragmentation across 10–13 columns; non-linear reading order makes reconstructing permission sentences cognitively impossible under time pressure; attack may only manifest on mobile viewports; desktop security review misses itHIGH
column-gap:200px or column-gap + writing-mode:vertical-rl + max-height — oversized gap pushes second column outside visible dialog area; or vertical writing mode stacks second column below overflow:hidden boundarycolumn-count:2 or higher; column-gap exceeding container geometry in a way that places column 2 outside visible area; OR writing-mode:vertical-rl with fixed max-height and overflow:hidden; critical permissions placed in column 2Critical permissions in column 2 are completely invisible; first column shows innocuous preamble (name, author, description); dialog container clips overflow; no scroll mechanism is exposed to user; permissions appear disclosed but column 2 content is never seenHIGH
column-fill:auto — fills column 1 to container height before starting column 2; MCP server places safe-sounding permissions in enough volume to fill column 1; critical permissions in column 2 overflow the visible areacolumn-count:2+; column-fill:auto; fixed max-height or overflow:hidden on dialog container; attacker controls the order and volume of permissions in the disclosure — places critical ones lastColumn 1 fills with non-critical permissions (enough to reach container height); critical permissions — file upload, credential access, network destinations — are placed in column 2 and are invisible due to overflow clipping; placement is deterministic: column-fill:auto makes it reliableHIGH

Defences

SkillAudit findings for this attack surface

HIGHcolumn-count:20 on consent disclosure container — 20-column layout in a 400px dialog creates columns approximately 18–20px wide, fragmenting disclosure sentences into disconnected syllables and word fragments across 20 slivers; text is visible but no permission can be read as a complete sentence: MCP server injects column-count:20 on the disclosure element; browser distributes text across 20 columns of ~20px each; hyphens:auto triggers syllable-level fragmentation; user sees dense text presence but cannot parse permission descriptions; DOM textContent is correct; display, visibility, and opacity guards are all satisfied
HIGHcolumn-width:30px — auto-computed column count of ~13 in a 400px dialog creates pathological word fragmentation; attack is device-dependent, producing readable columns on large desktop viewports while creating unreadable fragmentation on mobile viewports (400px width produces 13 columns of 30px each): MCP server injects column-width:30px; desktop test shows 3-column layout with readable text; mobile viewport shows 13-column layout with word-fragment-per-column rendering; security review on desktop only does not detect the mobile-specific attack
HIGHcolumn-gap:200px + writing-mode:vertical-rl + max-height — vertical writing mode stacks columns top-to-bottom; large column-gap or fixed max-height clips the second column below overflow:hidden boundary; critical permissions placed in column 2 are completely invisible while first column shows innocuous preamble: MCP server injects writing-mode:vertical-rl on disclosure; column-count:2; dialog max-height clips column 2 below visible area; overflow:hidden prevents scroll; el.scrollHeight > el.clientHeight reveals hidden column; no visual indicator that content is missing
HIGHcolumn-fill:auto with fixed container height — first column fills to container height with safe-sounding permissions; critical permissions (file upload, credential access, network destinations) placed last overflow into column 2, which is clipped by overflow:hidden on the dialog container; placement is deterministic because column-fill:auto processes text sequentially: MCP server controls permission order in the disclosure; places non-critical permissions in enough volume to fill column 1 height; critical permissions in column 2 overflow the container; el.scrollWidth > el.clientWidth detects the condition; column-fill:auto combined with overflow:hidden is the key indicator

Related: CSS overflow security covers overflow:hidden as a disclosure-hiding primitive. CSS white-space security covers white-space:nowrap combined with overflow clipping. CSS content-visibility security covers content-visibility:hidden removing elements from the accessibility tree. CSS injection overview covers the full attack model.

← Blog  |  Security Checklist