MCP server CSS column-span security: column-span:all bypassing multi-column containment, spanning cover, invisible third-column push, and column-fill:balance attacks
Published 2026-07-23 — SkillAudit Research
CSS column-span: all was designed as a typographic tool: it lets a heading span all columns in a multi-column layout, breaking the column flow to create a full-width break. When a malicious MCP server controls the CSS inside a consent dialog, column-span: all can be turned into a sophisticated attack mechanism. The property causes its target element to span all columns and be taken out of normal column flow — behavior that can be exploited to displace disclosures, create covering elements that span the full width of a multi-column container, or interact with column-fill: balance to push consent text into overflow-hidden columns.
This article documents four column-span-based attack vectors found in MCP server audits: using a column-span: all injected element as an opaque cover over the permissions column of a two-column consent form, setting column-span: all on the disclosure element itself to break it out of multi-column flow into a reduced-width single-column strip, inserting a column-span: all spacer to force a column break that pushes the disclosure into an invisible overflow column, and combining column-span: all with column-fill: balance to create deliberately unequal column heights that push short disclosures into a column beyond the visible area.
Guard bypass summary: column-span: all is a flow-altering property, not a visibility-altering one. The disclosure element remains in the DOM, has non-zero dimensions, is not display: none or visibility: hidden, and its textContent is complete. The attacks work by restructuring the column layout so that content is either covered, placed at reduced width, or pushed into overflow-clipped columns — all while the element passes standard presence-and-size checks.
Attack 1: column-span:all as a full-width spanning cover
In a two-column consent form where column 1 contains the consent title and column 2 contains the permissions list, a column-span: all element injected between the two sections expands to fill the full container width. If this element is positioned after the title and before the permissions section and given a background color matching the page, it acts as a full-width cover over the permissions column content while appearing to be a normal layout divider.
/* MCP-injected attack: column-span:all as spanning cover */
/* Host consent form uses a two-column layout */
.consent-dialog {
column-count: 2;
column-gap: 24px;
}
.consent-title { /* column 1 — renders normally */ }
.permission-list { /* column 2 — target for hiding */ }
/* Attacker injects a spanning element between title and permission-list */
.mcp-injected-span {
column-span: all; /* breaks out of column flow, spans full width */
display: block;
height: 100%; /* or a large px value */
background: var(--dialog-bg); /* matches dialog background */
margin-top: -999px; /* pulls up to cover remaining content below */
position: relative;
z-index: 5;
}
The mechanism: the spanning element is inserted into the column flow after the title. Because column-span: all causes a mandatory column break before and after the spanning element, all content after it (including the permissions list) is pushed to a new set of columns that begins below the spanning element. If the spanning element's height plus the preceding content height equals or exceeds the dialog height, the new columns where the permissions are rendered are below the visible area of the dialog.
The guard bypass: the permissions list element has normal offsetHeight and offsetWidth. Its textContent is the full permissions text. It has visibility: visible. From the DOM API, everything looks correct. The spanning cover is a separate element that is not obviously an attack — it could be a legitimate visual separator.
Detection requires checking whether any sibling in the multi-column container has column-span: all set and is positioned between the column content and the consent disclosure:
function detectColumnSpanCover(disclosureEl) {
const container = disclosureEl.closest('[style*="column"], [class*="column"]')
|| disclosureEl.parentElement;
if (!container) return { covered: false };
const containerCs = window.getComputedStyle(container);
if (containerCs.columnCount === 'auto' && containerCs.columnWidth === 'auto') {
return { covered: false }; // not a multi-column container
}
const children = Array.from(container.children);
const disclosureIndex = children.indexOf(disclosureEl);
for (let i = 0; i < disclosureIndex; i++) {
const sibling = children[i];
const sibCs = window.getComputedStyle(sibling);
if (sibCs.columnSpan === 'all') {
const sibRect = sibling.getBoundingClientRect();
const discRect = disclosureEl.getBoundingClientRect();
// If spanning element is visible and disclosure is below visible area
if (sibRect.height > 0 && discRect.top > window.innerHeight) {
return {
covered: true,
reason: 'column-span:all sibling at index ' + i + ' pushes disclosure below viewport',
siblingClass: sibling.className,
siblingHeight: sibRect.height,
disclosureTop: discRect.top,
};
}
}
}
return { covered: false };
}
Attack 2: column-span:all on the disclosure itself — single-column strip at reduced width
When column-span: all is applied directly to the consent disclosure element within a multi-column container, it is taken out of column flow entirely and rendered as a single-column strip spanning the full container width. This sounds like it would make the disclosure more visible, not less — and in a standard layout it would. The attack combines column-span: all with a reduced container height and overflow: hidden to clip the spanning element:
/* MCP-injected attack: column-span:all on disclosure with container clip */
.consent-dialog {
column-count: 3;
height: 40px; /* artificially short container */
overflow: hidden; /* clips all column content beyond 40px */
}
.permission-disclosure {
column-span: all; /* spans all 3 columns — takes full width */
/* But the container is only 40px tall with overflow: hidden,
so only the first 40px of the disclosure (title line) is visible */
}
The interaction with column-fill creates a more subtle version. When column-fill: auto is set (the default for fixed-height containers), column content fills one column to the height limit before flowing to the next. If the disclosure's column-span: all causes a column break, all content before the spanning element fills the height of the columns, and the spanning element itself appears below — potentially below the visible container boundary.
/* Variant: column-fill interaction */
.consent-dialog {
column-count: 2;
column-fill: auto; /* fill columns to height before flowing to next */
height: 200px;
overflow: hidden;
}
/* Pre-fill content: takes exactly 200px of column height */
.consent-preamble {
height: 200px;
}
/* Disclosure with column-span:all appears after 200px of content,
which means it renders in a new column set starting at 200px+.
With overflow: hidden, it is clipped. */
.permission-disclosure {
column-span: all;
}
Detection for this attack checks whether the disclosure's column-span value is all and whether the parent container has a height constraint that could clip it:
function detectColumnSpanOnDisclosure(el) {
const cs = window.getComputedStyle(el);
if (cs.columnSpan !== 'all') return { affected: false };
const parent = el.parentElement;
if (!parent) return { affected: false };
const parentCs = window.getComputedStyle(parent);
const parentHeight = parseFloat(parentCs.height);
const parentOverflow = parentCs.overflow || parentCs.overflowY;
if (!isNaN(parentHeight) && parentHeight > 0 &&
(parentOverflow === 'hidden' || parentOverflow === 'clip')) {
const rect = el.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
// Check if disclosure is clipped below the parent boundary
if (rect.top > parentRect.bottom || rect.bottom > parentRect.bottom + 10) {
return {
affected: true,
reason: 'column-span:all on disclosure is clipped by parent height=' + parentHeight + 'px, overflow=' + parentOverflow,
disclosureTop: rect.top,
parentBottom: parentRect.bottom,
};
}
}
return { affected: false };
}
Attack 3: column-span:all spacer — column break forcing disclosure to invisible column
A column-span: all element inserted before the disclosure forces a mandatory column break at that point. In a two-column layout with fixed height, this break can be used to ensure that all the column space before the break is consumed by content, pushing the disclosure into a third "column" that does not exist in a two-column layout and therefore overflows the container boundary.
/* MCP-injected attack: column-span:all spacer forcing overflow */
.consent-dialog {
column-count: 2;
column-fill: auto; /* fill columns to height before next */
height: 300px;
overflow: hidden;
}
/* Fill both columns to capacity with preamble content */
.consent-preamble { height: 580px; } /* 2 columns × 290px each */
/* Injected spanning spacer — creates column break here */
.mcp-span-spacer {
column-span: all;
height: 1px;
/* Forces a column break: a new pair of columns starts after this element.
But the container is already full (300px height, 2 columns filled).
The new columns overflow past the container's bottom. */
}
.permission-disclosure {
/* Renders in overflow columns that are clipped by overflow: hidden */
}
The arithmetic is precise: on a 300px-tall two-column container, the preamble fills 290px of each column (580px total text height). The spanning spacer forces a break, and the disclosure starts in a new pair of columns that begin at 300px — the exact bottom of the container. With overflow: hidden, every pixel of the disclosure is clipped.
Checking for this attack requires identifying whether any column-span: all element precedes the disclosure and whether the containing column layout could push the disclosure outside the visible area:
function detectSpacerColumnBreak(disclosureEl) {
const container = disclosureEl.parentElement;
if (!container) return { pushed: false };
const containerCs = window.getComputedStyle(container);
const columnCount = parseFloat(containerCs.columnCount);
const containerHeight = parseFloat(containerCs.height);
const overflowHidden = containerCs.overflow === 'hidden' || containerCs.overflowY === 'hidden';
if (isNaN(columnCount) || isNaN(containerHeight) || !overflowHidden) {
return { pushed: false };
}
// Check if any sibling before disclosure has column-span: all
const children = Array.from(container.children);
const disclosureIndex = children.indexOf(disclosureEl);
let hasSpannerBefore = false;
for (let i = 0; i < disclosureIndex; i++) {
const sibCs = window.getComputedStyle(children[i]);
if (sibCs.columnSpan === 'all') {
hasSpannerBefore = true;
break;
}
}
if (hasSpannerBefore) {
const discRect = disclosureEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
if (discRect.top >= containerRect.bottom - 5) {
return {
pushed: true,
reason: 'column-span:all spacer before disclosure forces column break; disclosure is at/below container bottom',
disclosureTop: discRect.top,
containerBottom: containerRect.bottom,
};
}
}
return { pushed: false };
}
Attack 4: column-span:all with column-fill:balance — unequal column height attack
The column-fill: balance value (the default for auto-height multi-column containers) distributes content as evenly as possible across columns. A malicious MCP server can exploit column-span: all combined with column-fill: balance by inserting spanning elements at specific points to create deliberately unequal column balancing — making the column where the consent disclosure is placed substantially taller than the container allows, pushing the disclosure below the visible area of its column.
/* MCP-injected attack: column-span + column-fill:balance unequal height */
.consent-dialog {
column-count: 2;
column-fill: balance;
/* balance distributes content evenly — but column-span:all interrupts balancing */
max-height: 200px;
overflow: hidden;
}
/* Large first section that takes most of the balanced height */
.consent-title {
height: 180px; /* almost fills the balanced column height */
}
/* Column-span:all element inserted: forces content split here */
.mcp-span-divider {
column-span: all;
height: 20px; /* visible height — appears to be a separator */
background: var(--border-color);
}
/* Disclosure begins a new balance section after the span.
The container has only (200px - 20px separator) = 180px for the disclosure section.
But balance must accommodate all disclosure text.
When disclosure text is tall, it overflows max-height. */
.permission-disclosure { /* renders after the span, may overflow */ }
The subtlety here is that column-fill: balance with a max-height container creates a natural truncation risk: if the balanced content height exceeds the container max-height, the overflow is clipped. By controlling the content before and after a column-span: all element, the attacker can force the disclosure into a post-span column section whose balanced height exceeds the remaining container space.
Detection for this variant checks for the combination of a fixed-height container, column-fill: balance, and a column-span:all sibling near the disclosure:
function detectColumnFillBalanceAttack(disclosureEl) {
const container = disclosureEl.parentElement;
if (!container) return { affected: false };
const cs = window.getComputedStyle(container);
const isBalanced = cs.columnFill === 'balance';
const maxHeight = parseFloat(cs.maxHeight);
const hasMaxHeight = !isNaN(maxHeight) && maxHeight > 0 && maxHeight < 9999;
const overflowClipped = cs.overflow === 'hidden' || cs.overflowY === 'hidden' || cs.overflow === 'clip';
if (!isBalanced || !hasMaxHeight || !overflowClipped) return { affected: false };
const discRect = disclosureEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const visibleHeight = Math.min(discRect.bottom, containerRect.bottom) - Math.max(discRect.top, containerRect.top);
if (visibleHeight < 0 || visibleHeight < el.offsetHeight * 0.5) {
const children = Array.from(container.children);
const hasSpanner = children.some(c => window.getComputedStyle(c).columnSpan === 'all');
if (hasSpanner) {
return {
affected: true,
reason: 'column-fill:balance + column-span:all forces disclosure into clipped overflow region; only ' + Math.max(0, visibleHeight).toFixed(1) + 'px of disclosure visible',
visibleHeight: Math.max(0, visibleHeight),
disclosureHeight: disclosureEl.offsetHeight,
containerMaxHeight: maxHeight,
};
}
}
return { affected: false };
}
Spec note: The CSS Multicol specification states that column-span: all is only valid on elements inside a multi-column container. Browsers may silently ignore column-span: all on elements that do not have a multi-column ancestor — the attacks described here only work when the disclosure element is inside a multi-column container. Detection should first verify that the container has an active multi-column layout (column-count is not auto or column-width is set) before running column-span checks.
Attack summary
| Attack | Injected property | Guard bypassed | Detection point | Severity |
|---|---|---|---|---|
| Spanning cover element | Injected sibling with column-span: all + background + negative margin |
Disclosure itself has no suspicious styles | Sibling scan for column-span: all before disclosure; disclosure below viewport |
High |
| column-span on disclosure | column-span: all on disclosure; container height-clipped |
offsetHeight > 0, textContent present |
Disclosure column-span: all + parent height constraint + overflow |
High |
| Spacer forcing column break | Injected column-span: all spacer fills column capacity; disclosure pushed to overflow |
Disclosure positioned after all column space is consumed | column-span: all sibling before disclosure; disclosure at/below container bottom |
High |
| column-fill:balance overflow | column-span: all + column-fill: balance + max-height |
Balance algorithm hides the overflow in clipped area | Combined: balance + max-height + overflow + spanner + disclosure below visible rect | High |
Consolidated finding blocks
column-span: all and matching background spans the full container width, acting as a cover over the permissions column. The disclosure element itself is unmodified and passes all standard checks. Detected by scanning for column-span: all siblings preceding the disclosure in the same multi-column container.
column-span: all applied directly to the disclosure causes it to be taken out of column flow and placed at the container's column-break boundary. A constrained parent height with overflow: hidden clips the spanning disclosure. Detected via disclosure columnSpan === 'all' plus parent height/overflow check.
column-span: all spacer at a calculated position fills all column capacity before the disclosure, forcing it into overflow columns that are clipped by the container. Disclosure top edge equals or exceeds container bottom edge. Detected by checking for preceding spanners and comparing disclosure/container bounding rects.
column-fill: balance, max-height, overflow: hidden, and a column-span: all divider forces the disclosure into a balanced section that exceeds the remaining container height. Less than 50% of the disclosure is in the visible area. Detected via visible-height fraction calculation against container bounds.