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 */
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| column-count:20 — extreme column count makes each column too narrow for word-level reading; disclosure text is fragmented into disconnected syllables across 20 slivers | CSS 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 fragmentation | Text 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 flag | HIGH |
| 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 mobile | column-width set below readable threshold (~100px) applied to disclosure container; most effective at mobile viewport widths; column-rule may add misleading visual structure | Pathological 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 it | HIGH |
| 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 boundary | column-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 2 | Critical 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 seen | HIGH |
| 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 area | column-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 last | Column 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 reliable | HIGH |
Defences
- Check
getComputedStyle(el).columnCounton consent disclosure elements. Any value above 3 is suspicious for a consent dialog — legitimate multi-column layout in dialogs is extremely rare. Flag and block column-count values that fragment disclosure text below readable column widths. A safe threshold: reject any column-count greater than 1 on consent dialog text elements. - Check
getComputedStyle(el).columnWidth. Values below approximately 150px produce columns too narrow for comfortable prose reading. Any column-width below 100px on consent disclosure text should be treated as a potential fragmentation attack and reset toauto. - Detect hidden overflow columns. After dialog render, check
el.scrollWidth > el.clientWidthon the disclosure container. If true, the element has horizontal overflow — likely caused by column-gap or column-count producing overflow columns. Checkel.scrollHeight > el.clientHeightfor the vertical writing-mode variant. Either condition indicates hidden content that the user cannot see. - Check
getComputedStyle(el).columnFill. A value of'auto'on a consent disclosure element combined with a max-height constraint is a reliable indicator of the column-fill:auto burial attack. Consent disclosures should use the default'balance'or explicitly disallow multi-column layout entirely. - Enforce no multi-column layout on consent elements via CSP or allowlisted CSS. The most robust defence is a
Content-Security-Policy: style-src 'self'directive that prevents MCP servers from injecting any CSS. If CSS injection is required for theming, a CSS allowlist should explicitly blockcolumn-count,column-width,columns, andcolumn-fillproperties on consent dialog elements. - SkillAudit flags:
column-countabove 1 on consent disclosure containers;column-widthbelow 150px;column-fill:autocombined with overflow constraints;writing-modechanges on disclosure elements;el.scrollWidth > el.clientWidthorel.scrollHeight > el.clientHeighton disclosure containers indicating hidden overflow columns.
SkillAudit findings for this attack surface
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.