MCP server CSS multi-column layout consent security
CSS multi-column layout was designed for magazine-style text flow, but its core mechanic — distributing inline content across multiple columns that may extend beyond the visible container — can be weaponized to hide consent text. columns:1px forces all content into 1-pixel-wide invisible columns; column-gap:10000px places each column beyond the viewport; column-count:999 spreads each word into its own column. Four SA-CSS-MCL patterns document the complete multi-column attack surface for consent text.
Multi-column attacks exploit a property that distinguishes CSS columns from ordinary block layout: column boxes are generated inline (horizontally) and may extend far to the right of the container. When the container has overflow:hidden or a fixed width, all columns beyond the first visible area are clipped. The DOM text node is fully intact — textContent returns the complete consent string — but the text is physically distributed across invisible off-screen columns.
Unlike transforms or off-screen positioning, multi-column attacks use standard layout flow. The text is not positioned at a negative coordinate; it is in-flow content that simply overflows the column container. This structural difference makes it harder for generic overflow auditors to detect: the element’s bounding rect may appear non-zero, and the text is technically within the document’s column flow, not displaced outside it.
SA-CSS-MCL-001: columns:1px — 1px-wide columns with overflow:hidden
The columns shorthand sets both column-width and (implicitly) column-count:auto. With columns:1px, the browser attempts to create as many 1px-wide columns as needed to distribute the text. On a 400px-wide container, it creates ~400 columns, each 1px wide. At standard font sizes, a single glyph is 7–12px wide, so each column contains a fraction of a character — effectively, each glyph is split across multiple invisible columns. Combined with overflow:hidden, all content beyond the first 1px of the container width is clipped, rendering the consent text invisible.
/* Attack: 1px column width forces text into micro-columns */
.consent-text {
columns: 1px;
overflow: hidden;
/* Browser generates ~containerWidth/1 columns, each 1px wide.
A 16px glyph spans ~16 columns, each 1px visible slice.
overflow:hidden clips everything beyond container width (column 1 only).
Visual output: a 1px vertical sliver of the first glyph, or nothing.
DOM: el.textContent → complete consent string (fully intact). */
}
/* Also via column-width directly: */
.consent-text {
column-width: 1px;
overflow: hidden;
}
/* Detection:
const style = getComputedStyle(el);
const colWidth = parseFloat(style.columnWidth); // resolves to px
if (!isNaN(colWidth) && colWidth < 20) → CRITICAL (SA-CSS-MCL-001)
// Also check: columns property in stylesheet for shorthand form
// getComputedStyle returns columnWidth resolved value
// columnWidth 'auto' → not this attack; px value < 20 → CRITICAL */
Column boxes are inline overflow, not absolute positioning: The content is not placed at a negative left coordinate. It is in-flow content distributed into columns that extend horizontally beyond the container. Generic position-based auditors miss this. Check getComputedStyle(el).columnWidth for a resolved pixel value below a readable threshold.
columns:1px + overflow:hidden: text in micro-columns; glyphs split across 1px columns; all content clipped. Detection: computed columnWidth < 20px → CRITICAL.SA-CSS-MCL-002: column-width:1px + column-gap:10000px — columns beyond viewport
While columns:1px attacks through micro-column width, column-gap:10000px attacks through column spacing. With a large column gap, the first column starts at position 0, the second at approximately column-width + 10000px, the third at 2 × (column-width + 10000px), and so on. Every column beyond the first is placed 10000px or more to the right — beyond any viewport. Combined with overflow:hidden, only the content in the first column is visible. If the first column is narrow (e.g., column-width:100px), only a small portion of the consent text fits in that first column, and all remaining text is in off-screen columns.
/* Attack: huge column-gap pushes all columns beyond viewport */
.consent-text {
column-width: 100px; /* first column: 100px wide, fits ~3-4 words */
column-gap: 10000px; /* second column starts at 10100px → off-screen */
overflow: hidden;
/* Only first ~3-4 words of consent text visible.
"By clicking Allow, you consent to..." → only "By clicking" visible.
Remaining text: in columns at 10100px, 20200px, etc. → clipped. */
}
/* Extreme variant: 1px column + 10000px gap */
.consent-text {
column-width: 1px;
column-gap: 10000px;
overflow: hidden;
/* Second column at 10001px → all content beyond 1px clipped. */
}
/* Detection:
const style = getComputedStyle(el);
const colGap = parseFloat(style.columnGap); // may resolve from 'normal'
// 'normal' resolves to ~1em; large numeric value → attack
if (!isNaN(colGap) && colGap > 500) → CRITICAL (SA-CSS-MCL-002)
// Also: check for combination of small columnWidth + large columnGap */
column-gap resolves from 'normal' to ~1em: The default column-gap:normal resolves to approximately 1em. An attack value like 10000px will appear as a large pixel value in getComputedStyle(el).columnGap. Threshold: parseFloat(columnGap) > 500 flags the attack.
column-gap:10000px: each column beyond first placed far off-screen; overflow:hidden clips. Detection: computed columnGap > 500px → CRITICAL.SA-CSS-MCL-003: column-count:999 — one word per column
Setting column-count:999 on a short consent text distributes each word (or even each character) into its own column. With a fixed container width, only the columns that fit within the container width are visible — typically just the first one or two words. The remaining ~997 columns each contain subsequent words of the consent text but are placed beyond the container’s visible area and clipped by overflow:hidden. This attack is detectable via an unusually high column-count value combined with a fixed container.
/* Attack: 999 columns → each word in its own column */
.consent-text {
column-count: 999;
overflow: hidden;
/* Browser distributes text across 999 columns.
Each column ≈ containerWidth / 999 wide (sub-pixel on any container).
Columns 2-999: beyond container → clipped.
Visual: first ~1-2 characters of first word, then nothing.
DOM: el.textContent → full consent string intact. */
}
/* Even moderate counts hide most text: */
.consent-text {
column-count: 10;
/* 400px container / 10 columns = 40px per column
40px column shows ~3-4 words; remaining 6-9 columns clipped
if overflow:hidden is set */
}
/* Detection:
const style = getComputedStyle(el);
const colCount = parseInt(style.columnCount); // 'auto' or integer
if (colCount !== 'auto' && colCount > 5) → HIGH (SA-CSS-MCL-003)
// Threshold: column-count > 5 with overflow:hidden → review
// column-count > 50 → CRITICAL */
column-count:auto is safe; a large integer is not: getComputedStyle(el).columnCount returns either 'auto' (default, safe) or an integer string. Any value above 5 combined with overflow:hidden on a fixed container warrants review; values above 50 are CRITICAL.
column-count:999: one word per column; fixed container clips all beyond first; full text in DOM. Detection: computed columnCount > 50 with overflow:hidden → CRITICAL.SA-CSS-MCL-004: column-fill:auto + fixed height — column height clipping
column-fill:auto (as opposed to the default balance) fills columns sequentially: the first column fills to the container height, then the second column begins. When the container has a fixed height and overflow:hidden, only the content that fits within the first column’s height is visible. With a small fixed height (e.g., height:30px), only one or two lines of consent text fit in column one. The remaining text goes into column two (and beyond), which are placed horizontally beyond the container and clipped. This uses standard column-fill behaviour for legitimate pagination — the attack is purely in the container sizing.
/* Attack: column-fill:auto + small fixed height + overflow:hidden */
.consent-wrapper {
height: 30px; /* only ~1-2 lines fit in first column */
overflow: hidden; /* clips all columns beyond first */
}
.consent-text {
column-fill: auto;
/* 'auto': fills column 1 to container height (30px), then column 2...
Column 2 starts at container-width + column-gap → off-screen.
Consent text overflow → invisible. */
}
/* Compare: column-fill:balance (default) */
.consent-text {
column-fill: balance; /* distributes evenly, can also hide with column-gap */
}
/* Detection:
const style = getComputedStyle(el);
const parentStyle = getComputedStyle(el.parentElement);
if (style.columnFill === 'auto') {
// Check if container height clips content
if (el.scrollHeight > el.offsetHeight &&
parentStyle.overflow === 'hidden') → HIGH (SA-CSS-MCL-004)
} */
column-fill:auto + small container = silent pagination clip: This pattern mimics legitimate print-style pagination. The attack is in the container height constraint, not the column declaration itself. Detect by checking column-fill:auto combined with overflow-clipping containers where scrollHeight > offsetHeight.
column-fill:auto + fixed height + overflow:hidden: first column clips to small height; remaining text in off-screen columns. Detection: columnFill === 'auto' AND container scrollHeight > offsetHeight with overflow:hidden → HIGH.Summary of SA-CSS-MCL findings
| ID | Severity | Property | Attack mechanism | Detection |
|---|---|---|---|---|
| SA-CSS-MCL-001 | CRITICAL | columns:1px + overflow:hidden | Text in micro-columns; glyphs split; all content clipped | columnWidth < 20px |
| SA-CSS-MCL-002 | CRITICAL | column-gap:10000px + overflow:hidden | Columns pushed beyond viewport; only first ~3 words visible | columnGap > 500px |
| SA-CSS-MCL-003 | CRITICAL | column-count:999 + overflow:hidden | One word per column; fixed container clips all beyond first | columnCount > 50 with overflow:hidden |
| SA-CSS-MCL-004 | HIGH | column-fill:auto + fixed height | Pagination clips text beyond first column height | columnFill=auto + scrollHeight > offsetHeight |