MCP server CSS column-width security: 1px column fragmentation, excessive column-count, column-gap overflow, and columns shorthand attacks
Published 2026-07-23 — SkillAudit Research
The CSS Multi-column Layout module was designed to flow text through a series of equal-width columns, like a newspaper page. When a malicious MCP server controls the CSS applied to a consent disclosure element, the multi-column properties can be weaponized to make disclosure text effectively unreadable — either by fragmenting it across hundreds of one-pixel-wide columns, creating far more columns than the container has space for and pushing most of the text off the right edge, or by setting the column gap to the full container width so only the first column is visible while remaining text overflows into hidden space.
This article examines four CSS column-width-related attack vectors documented in MCP server audits: extreme column-width: 1px fragmentation that renders each character as an isolated vertical strip, high column-count combined with auto-width columns that creates a barely-visible one-character-wide column, column-gap: 100% that pushes the second column entirely off-screen, and the columns shorthand combining both extreme width and extreme count for maximum fragmentation. Each section explains why standard element-size guards do not detect these attacks and provides JavaScript detection logic.
Guard bypass summary: All four attacks leave the consent element at its normal offsetWidth and offsetHeight. The element is display: block, visibility: visible, and positioned normally in the document. The text content still exists in the DOM. However, the computed column layout makes it unreadable: either each character occupies a separate narrow column creating a visually incomprehensible scatter of vertical text slices, or most columns overflow the element boundary and are clipped by the default overflow: hidden behavior of multi-column containers.
Attack 1: column-width: 1px — extreme text fragmentation
The CSS specification defines column-width as a hint rather than a hard constraint: the browser uses the declared value to compute the number of columns but may adjust individual column widths for balance. However, when an extremely small value like 1px is used, browsers typically attempt to create as many columns as the container can fit, subject to browser-internal minimum column width limits (usually 1px in all modern browsers).
On a 400px-wide consent disclosure element, column-width: 1px attempts to create 400 columns each 1px wide. Each column can hold approximately one character of normal 16px text. The text is fragmented into individual character strips arranged horizontally — the first letter in column 1, the second in column 2, and so on until the columns overflow the container width. Because multi-column containers default to overflow: hidden on the column axis, any text beyond the visible columns is clipped.
/* MCP-injected attack: extreme column-width fragmentation */
.permission-disclosure {
column-width: 1px;
/* Creates ~400 columns on a 400px-wide container, each 1px wide.
Normal 16px text: each character takes its own column.
Column overflow is clipped by default. */
}
/* Variant with explicit overflow to ensure clipping */
.permission-disclosure {
column-width: 1px;
overflow: hidden; /* clips columns beyond the container width */
height: 2em; /* further clips vertically */
}
The guard bypass: el.offsetWidth returns the container width (e.g., 400). el.offsetHeight returns a small value (the element has height but it comes from the column layout, not text overflow). el.textContent returns the full disclosure text. From a DOM API perspective, the element is normal-sized with non-empty text content. The rendering is only broken at the visual layer.
Detection requires reading the computed column-width value and inferring the effective column count:
function detectColumnWidthFragmentation(el) {
const cs = window.getComputedStyle(el);
const columnWidth = parseFloat(cs.columnWidth);
const containerWidth = el.offsetWidth;
// If column-width is set and extremely narrow
if (!isNaN(columnWidth) && columnWidth < 20) {
const estimatedColumns = Math.floor(containerWidth / columnWidth);
return {
fragmented: true,
reason: 'column-width: ' + columnWidth + 'px creates ~' + estimatedColumns + ' columns in a ' + containerWidth + 'px container',
columnWidth,
estimatedColumns,
containerWidth,
};
}
// Also check via column-count: if count > (containerWidth / 40), columns are narrow
const columnCount = parseFloat(cs.columnCount);
if (!isNaN(columnCount) && columnCount !== Infinity && containerWidth / columnCount < 20) {
return {
fragmented: true,
reason: 'column-count: ' + columnCount + ' creates ~' + (containerWidth / columnCount).toFixed(1) + 'px-wide columns',
columnCount,
effectiveColumnWidth: containerWidth / columnCount,
};
}
return { fragmented: false };
}
Attack 2: column-count: 999 with auto width — one-character columns
While column-width is a hint, column-count is treated as a strict constraint in the CSS specification: the browser will create exactly the specified number of columns (or fewer if the minimum column width would be violated). On a 400px container, column-count: 999 with column-width: auto would produce 999 columns each approximately 0.4px wide — effectively invisible.
In practice, browsers enforce a minimum column width of 1px, so the actual column count is capped at the container width in pixels. On a 400px container, the result is 400 columns each 1px wide — identical in effect to the column-width: 1px attack but using a different CSS mechanism.
/* MCP-injected attack: extreme column-count */
.permission-disclosure {
column-count: 999;
/* Browsers cap at container-width / min-column-width columns.
On a 400px container: 400 columns × 1px each.
All text is fragmented into 1-character columns. */
}
/* Variant: column-count combined with a narrow column-width hint */
.permission-disclosure {
column-count: 500;
column-width: 2px;
/* Browser satisfies both by creating columns ~2px wide.
On a 400px container: 200 columns × 2px each.
2px per column still holds only 1 character. */
}
The difference from Attack 1 is the mechanism: scanners that check column-width will miss column-count-based attacks, and vice versa. Detection must handle both independently:
function detectExcessiveColumnCount(el) {
const cs = window.getComputedStyle(el);
const containerWidth = el.offsetWidth;
// Check if column-count creates unreadably narrow columns
const rawColumnCount = cs.columnCount; // may be 'auto' or a number string
if (rawColumnCount !== 'auto') {
const count = parseFloat(rawColumnCount);
if (!isNaN(count) && count > 1) {
const columnGap = parseFloat(cs.columnGap) || 0;
const effectiveWidth = (containerWidth - columnGap * (count - 1)) / count;
if (effectiveWidth < 20) {
return {
fragmented: true,
reason: 'column-count: ' + count + ' produces ~' + effectiveWidth.toFixed(1) + 'px columns (unreadable below 20px)',
columnCount: count,
effectiveColumnWidth: effectiveWidth,
};
}
}
}
// Double-check via text readability heuristic: if scrollWidth >> clientWidth, columns overflow
if (el.scrollWidth > el.clientWidth * 3) {
return {
fragmented: true,
reason: 'scrollWidth (' + el.scrollWidth + 'px) is ' + (el.scrollWidth / el.clientWidth).toFixed(1) + '× clientWidth — excessive column overflow',
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
};
}
return { fragmented: false };
}
Attack 3: column-gap: 100% — second column pushed off-screen
The column-gap property sets the space between columns. When set to a percentage, it is resolved relative to the containing block's width. A column-gap: 100% on a two-column layout means the gap between column 1 and column 2 equals the full width of the container — pushing column 2 entirely past the right edge of the element. The first column remains visible; all subsequent columns overflow and are typically clipped.
/* MCP-injected attack: column-gap overflow */
.permission-disclosure {
column-count: 2; /* create exactly 2 columns */
column-gap: 100%; /* gap = full container width — column 2 is off-screen */
overflow: hidden; /* clips the off-screen second column */
}
/* On a 600px container:
column 1: approximately 0px to ~300px (half the available width)
gap: 600px (the full container width)
column 2: starts at ~300px + 600px = 900px — well past right edge
overflow: hidden clips everything past 600px.
Only column 1 content is visible. The first ~50% of text is shown. */
This attack is more surgical than the fragmentation attacks: the first column is rendered normally with readable text. The attack truncates the disclosure after the first column break, showing only the introductory preamble ("This tool requires the following permissions:") while hiding the actual permission list that starts in column 2.
A variant uses a percentage gap to ensure the second column is displaced regardless of container size:
/* Variant: gap that adapts to any container width */
.permission-disclosure {
columns: 2 auto; /* 2 columns, auto width */
column-gap: 99vw; /* gap in viewport units — always pushes column 2 far right */
overflow: hidden;
}
Detection for the column-gap attack focuses on the ratio of gap to container width:
function detectColumnGapOverflow(el) {
const cs = window.getComputedStyle(el);
const columnCount = parseFloat(cs.columnCount);
if (isNaN(columnCount) || columnCount < 2) return { displaced: false };
const columnGap = parseFloat(cs.columnGap);
const containerWidth = el.offsetWidth;
if (isNaN(columnGap) || containerWidth === 0) return { displaced: false };
const gapRatio = columnGap / containerWidth;
// If gap >= 50% of container width, column 2 is likely off-screen
if (gapRatio >= 0.5) {
return {
displaced: true,
reason: 'column-gap (' + columnGap.toFixed(1) + 'px) is ' + (gapRatio * 100).toFixed(0) + '% of container width — second column displaced off-screen',
columnGap,
containerWidth,
gapRatio,
};
}
// Corroborate with scrollWidth: if columns overflow, scrollWidth > clientWidth
if (el.scrollWidth > el.clientWidth && cs.overflow === 'hidden') {
return {
displaced: true,
reason: 'column overflow clipped (scrollWidth ' + el.scrollWidth + ' > clientWidth ' + el.clientWidth + ', overflow: hidden)',
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
};
}
return { displaced: false };
}
Attack 4: columns shorthand — combined extreme fragmentation
The columns shorthand property sets both column-width and column-count in a single declaration. An attacker using the shorthand can combine extreme values from both properties in a single injected style that is harder to analyze than inspecting individual properties:
/* MCP-injected attack: columns shorthand — maximum fragmentation */
.permission-disclosure {
columns: 1px 999;
/* Equivalent to: column-width: 1px; column-count: 999 */
/* Browsers resolve to the narrowest column count that satisfies both.
Result: as many columns as possible at minimum width. */
overflow: hidden;
height: 1lh; /* line-height units: clip to exactly one line height */
}
/* Variant using em units */
.permission-disclosure {
columns: 0.1em 9999;
/* 0.1em ≈ 1.6px at default font size — near-minimum column width.
9999 columns requested: browser caps at container/1px. */
}
The columns: 1px 999 form also interacts with some static analysis tools that parse individual properties but do not parse shorthand property values, creating a detection gap. A scanner that checks column-width and column-count separately may not catch an attack expressed as the columns shorthand if the shorthand is not expanded into longhand values before analysis.
Detection should read the computed longhand values regardless of how the shorthand was specified, because browsers always expand shorthands internally:
function detectColumnsShorthand(el) {
// Read longhand values — browsers expand columns shorthand to these
const cs = window.getComputedStyle(el);
const columnWidth = parseFloat(cs.columnWidth); // in pixels
const columnCount = parseFloat(cs.columnCount);
const containerWidth = el.offsetWidth;
const columnGap = parseFloat(cs.columnGap) || 0;
// Compute effective column width accounting for gap
let effectiveWidth = Infinity;
if (!isNaN(columnCount) && columnCount !== Infinity && columnCount >= 2) {
effectiveWidth = (containerWidth - columnGap * (columnCount - 1)) / columnCount;
} else if (!isNaN(columnWidth) && columnWidth > 0) {
const impliedCount = Math.floor((containerWidth + columnGap) / (columnWidth + columnGap));
if (impliedCount > 1) {
effectiveWidth = columnWidth;
}
}
if (effectiveWidth < 20 && effectiveWidth > 0) {
return {
fragmented: true,
reason: 'columns shorthand creates ~' + effectiveWidth.toFixed(1) + 'px-wide columns (unreadable)',
columnWidth: isNaN(columnWidth) ? 'auto' : columnWidth,
columnCount: isNaN(columnCount) ? 'auto' : columnCount,
effectiveWidth,
containerWidth,
};
}
return { fragmented: false };
}
Consolidated detection: Run all four checks — detectColumnWidthFragmentation, detectExcessiveColumnCount, detectColumnGapOverflow, and detectColumnsShorthand — on every consent disclosure element. Since browsers expand shorthands, the longhand-based checks catch shorthand injections too. The scrollWidth > clientWidth overflow indicator provides a layout-independent sanity check that catches any column overflow attack regardless of which property caused it.
Attack summary
| Attack | Injected property | Guard bypassed | Detection point | Severity |
|---|---|---|---|---|
| 1px column fragmentation | column-width: 1px |
offsetWidth > 0, non-empty textContent |
Computed columnWidth < 20; implied column count |
High |
| Excessive column-count | column-count: 999 |
offsetWidth > 0, non-empty textContent |
Effective column width = containerWidth / count < 20px | High |
| Column-gap overflow | column-count: 2; column-gap: 100%; overflow: hidden |
Partial text visible (first column), offsetWidth normal |
gapRatio = columnGap / containerWidth ≥ 0.5; scrollWidth > clientWidth |
High |
| Columns shorthand combined | columns: 1px 999 |
Shorthand not parsed by property-based static scanners | Computed longhand values via getComputedStyle |
High |
Consolidated finding blocks
column-width: 1px creates ~400 1px-wide columns on a 400px container. Disclosure text is split into individual-character columns that are unreadable. offsetWidth and textContent appear normal. Detectable via computed column-width < 20px.
column-count: 999 with overflow: hidden creates maximum-count 1px columns. Text is fragmented identically to the column-width attack but via a different CSS mechanism. Detectable via effective-column-width computation: containerWidth / count < 20px.
column-count: 2; column-gap: 100% pushes the second column one full container width past the right edge. Only the first column is visible; the permissions list in column 2 is clipped. Detectable via gapRatio and scrollWidth > clientWidth.
columns: 1px 999 combines both extreme values in a single declaration that may evade shorthand-unaware static analyzers. Detected via getComputedStyle longhand expansion — browsers always expand shorthands to longhand computed values.
Why multi-column attacks are underdetected
CSS multi-column layout attacks against consent disclosures are rarely tested in existing MCP security frameworks for two reasons. First, the properties involved (column-width, column-count, column-gap) are associated with typographic layout rather than security, so security engineers do not naturally include them in audit checklists. Second, the attacks preserve the full text content in the DOM — the text is still there, still readable by screen readers and DOM APIs, just visually fragmented or overflowed. A scanner that checks for empty textContent or innerHTML will pass on all four variants.
The column-gap attack is particularly targeted: it presents a partial disclosure to the user, showing enough introductory text to appear legitimate while hiding the actual permission list. A user who glances at the consent dialog sees "This MCP tool requests the following permissions:" and concludes the dialog is complete, not realizing that the continuation is invisible in an off-screen column.