MCP server CSS column-count consent security
CSS multi-column layout distributes an element's content across multiple columns. When applied to consent text, extreme values of column-count, column-gap, and column-fill can shatter consent legibility without removing the text from the DOM: columns narrower than a single character, gaps wider than the viewport, and short max-heights that clip all but the first column. textContent returns the full consent string while the user sees a garbled or clipped fragment.
Attack findings
Background: how CSS multi-column layout works
The CSS Multi-column Layout Module (Level 1) lets an element's inline content flow through a series of equally-sized columns. The column count, width, gap, and fill behaviour are all configurable. For legitimate use it mirrors a newspaper layout: readable columns of text at natural widths. The exploit uses the same mechanism with extreme values. The key insight for attackers is that textContent, innerText, and innerHTML are entirely unaffected by column layout — only the visual rendering changes.
Scope: Multi-column attacks require the consent element to have a fixed or constrained width. MCP servers typically wrap consent text in a fixed-width dialog or modal, making these attacks directly applicable in the most common consent flow UI pattern.
Attack 1 — extreme column-count fragments words mid-character (SA-CSS-COLC-001)
CSS distributes the element's inline content evenly across column-count columns. When the container is 400px wide and column-count:20 is set, each column is approximately 20px — accounting for default column gap. Increase the count further and columns become 2–3px wide: not wide enough to render a single character. The text reflows across 20 columns and the result is visually unintelligible — letter fragments distributed across dozens of hairline-width columns. A consent checker reading textContent sees "By clicking Install you authorize this server to access your filesystem" while the user sees what appears to be visual noise.
/* Attack: extreme column-count destroys consent legibility */
.consent-dialog .consent-text {
column-count: 20; /* 20 columns on a ~400px container = ~20px per column */
/* default column-gap: 1em; each column is roughly 20px - too narrow for words */
}
/* No other changes needed — standard consent text, standard font-size */
/* textContent: "By clicking Install you authorize this server to access your filesystem." */
/* Rendered: letter fragments across 20 hairline columns — visually illegible */
SA-CSS-COLC-001 (High). getComputedStyle returns the computed column-count as an integer. Any value above 6–8 for a 400px container produces sub-word column widths. Detection: check getComputedStyle(el).columnCount; flag if parseInt(columnCount) > 5 and the container width divided by column count produces a column width less than 60px (roughly a 4-character minimum).
/* Detection */
function checkColumnCountConsent(el) {
const cs = getComputedStyle(el);
const count = parseInt(cs.columnCount, 10);
if (!count || count <= 1) return null;
const containerWidth = el.getBoundingClientRect().width;
const approxColWidth = containerWidth / count;
if (approxColWidth < 60) {
return { vuln: 'SA-CSS-COLC-001', detail: `columnCount:${count}, approx col width:${approxColWidth.toFixed(1)}px` };
}
return null;
}
Attack 2 — column-gap:500px scatters consent words across viewport (SA-CSS-COLC-002)
The column-gap property sets the space between columns. Setting it to a large value — 500px, 100vw, or even larger — means each column of content is separated from the next by the full width of the viewport. The content reflows into however many columns are needed to hold it, and each column is offset 500px+ to the right. With the container's overflow property defaulting to visible (or explicitly set to hidden), the user sees either only the first column or all columns bled off-screen. The DOM text is intact. The visual result is one or two words visible on-screen followed by white space where the rest of the consent should be.
/* Attack: enormous column-gap scatters consent words off-screen */
.consent-text {
columns: auto; /* browser decides column count from available space */
column-gap: 500px; /* each column separated by viewport width */
overflow: hidden; /* clip the overflow so scattered columns are invisible */
}
/* With overflow:hidden, user sees first ~3 words of consent text.
Remaining content is clipped at right edge of container.
textContent: full consent string unaffected */
SA-CSS-COLC-002 (High). getComputedStyle(el).columnGap returns a pixel value when resolved. Flag any column-gap value greater than 50px on a consent element — normal column layout needs at most ~24px (1em at 24px font). Values at 100px+ are almost certainly attacks.
/* Detection */
function checkColumnGapConsent(el) {
const cs = getComputedStyle(el);
const gap = parseFloat(cs.columnGap);
if (!isNaN(gap) && gap > 50) {
return { vuln: 'SA-CSS-COLC-002', detail: `columnGap:${gap}px` };
}
return null;
}
Attack 3 — column-fill:auto with max-height clips consent below fold (SA-CSS-COLC-003)
The column-fill property controls how content is distributed across columns. The default column-fill:balance spreads content evenly. With column-fill:auto, content fills columns top to bottom until the container height is exhausted, then spills into the next column. An MCP server sets a short max-height (e.g., 24px — one line height) combined with column-fill:auto. Column 1 fills with the first line of consent. All remaining consent text flows into column 2, 3, and so on — but these columns are outside the container's max-height constraint and rendered to the right. With overflow:hidden, they are clipped. The user sees only the first sentence. The first sentence is typically a neutral preamble; the permission grants are buried in clipped columns.
/* Attack: column-fill:auto + max-height clips consent after first line */
.consent-text {
column-fill: auto;
max-height: 24px; /* one line visible */
overflow: hidden; /* horizontal overflow (other columns) clipped */
column-count: 999; /* force new column as soon as 24px height fills */
}
/* Column 1: "By installing this plugin, you acknowledge..." (first sentence only)
Column 2..999: remaining permission grants — clipped by overflow:hidden
textContent: full multi-sentence consent string */
SA-CSS-COLC-003 (Medium). The attack relies on column-fill:auto and a constrained height causing overflow. Detection: check getComputedStyle(el).columnFill === 'auto' and whether the element's scrollHeight exceeds its clientHeight by more than 50% (indicating significant clipped content).
Attack 4 — JS pointerover dynamically injects column-count at install hover (SA-CSS-COLC-004)
Static CSS analysis sees normal consent layout. The attack is deferred to the pointerover event on the install button. When the user moves their cursor toward the button, JS sets column-count:20 on the consent text element. The consent is readable during the page load review phase and becomes illegible only at the moment the user moves to click. The timing relies on the user's gaze tracking the install button rather than re-reading the consent text when the pointer enters the button's hit area. The mutation is reverted on pointerout so the consent recovers if the user moves away, making forensic analysis harder.
/* Attack: dynamic column-count injection on install button hover */
installBtn.addEventListener('pointerover', () => {
consentEl.style.columnCount = '20';
});
installBtn.addEventListener('pointerout', () => {
consentEl.style.columnCount = ''; /* revert — looks clean on inspection */
});
/* Detection: MutationObserver on consent element style */
new MutationObserver(() => {
const cs = getComputedStyle(consentEl);
const count = parseInt(cs.columnCount, 10);
if (count > 5) {
flagTampering('SA-CSS-COLC-004');
installBtn.disabled = true;
}
}).observe(consentEl, { attributes: true, attributeFilter: ['style'] });
SkillAudit detection: SkillAudit checks computed columnCount, columnGap, and columnFill values on all elements containing consent text. It flags column counts that produce sub-60px column widths, column gaps above 50px, and column-fill:auto paired with constrained heights. It also installs a MutationObserver during its dynamic analysis phase to catch pointerover injection. Run a free audit →
Detection summary
| Attack ID | Properties involved | Key detection signal |
|---|---|---|
| SA-CSS-COLC-001 | column-count:20 on narrow container | columnCount > 5 AND (containerWidth / columnCount) < 60px |
| SA-CSS-COLC-002 | column-gap:500px + overflow:hidden | computed columnGap > 50px on consent element |
| SA-CSS-COLC-003 | column-fill:auto + max-height:24px + overflow:hidden | columnFill === 'auto' AND scrollHeight > 1.5× clientHeight |
| SA-CSS-COLC-004 | JS pointerover sets column-count:20 at install hover | MutationObserver style change → columnCount check during pointer interaction |