MCP server CSS break-inside avoid security: avoid-column consent trapping, column-count miniaturization, print-preview avoid-page silencing, and column-fill balance attacks
Published 2026-09-25 — SkillAudit Research
The CSS property break-inside controls whether a box may be fragmented across column, page, or region boundaries. The value avoid prevents fragmentation entirely; avoid-column prevents column fragmentation specifically; avoid-page prevents page fragmentation for print layouts. While these values are designed to prevent awkward splits in multi-column text or printed documents, they create a specific attack surface in MCP server consent UIs: an adversary can use break-inside: avoid to control exactly which column or page receives the consent disclosure, and at what legible size.
This page focuses on the avoid keyword family and its column-layout interactions. For the general multi-column fragmentation attack surface including break-before and break-after, see the CSS break-inside security overview.
Key insight: break-inside: avoid-column prevents an element from spanning multiple columns. Combined with a large column-count, this forces the entire consent block to fit within a single narrow column — potentially rendering the text in a strip only 5% of the container width. The consent block is physically present, passes DOM content checks, and has a non-zero bounding rect; but its effective reading width is a few pixels.
Attack 1: avoid-column + high column-count — consent miniaturization
A multi-column container with column-count: 20 divides a 600px-wide container into 20 columns of approximately 30px each. If the consent element has break-inside: avoid-column, the browser must place the entire consent block within a single column without breaking it. But 30px is far too narrow for readable multi-line consent text. The browser will overflow the column width (or the text will be clipped by the overflow policy) while the consent nominally "occupies" column 1 at 5% of the container width.
/* Container: 600px wide, 20 columns */
.content-area {
column-count: 20;
column-gap: 0;
width: 600px;
overflow: hidden; /* Or overflow:clip for harder cut */
}
/* Each column width ≈ 30px */
/* Consent block: must not break across columns */
.consent-block {
break-inside: avoid-column;
/* The browser places the entire consent block in column 1 (leftmost).
Column 1 width: 30px.
Consent text at normal font-size: each line ~200 characters wide.
Result: the text overflows column 1's 30px, and the overflow is
clipped by the parent's overflow:hidden.
The visible strip: 30px × [height of single line].
A 400-word consent disclosure: only the first ~3 characters of each line
are visible. The Accept button may not be gated on scroll, so the user
can accept without reading. */
}
/* Detection: check elements with break-inside:avoid-column inside
high-column-count containers */
function detectColumnTrap(root) {
const findings = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let el;
while (el = walker.nextNode()) {
const cs = window.getComputedStyle(el);
const bi = cs.breakInside || cs.getPropertyValue('break-inside');
if (bi !== 'avoid-column' && bi !== 'avoid') continue;
const parent = el.parentElement;
if (!parent) continue;
const pcs = window.getComputedStyle(parent);
const cc = parseInt(pcs.columnCount, 10);
if (!isNaN(cc) && cc >= 5) {
const colWidth = parent.clientWidth / cc;
if (colWidth < 80) {
findings.push({
element: el,
columnCount: cc,
estimatedColumnWidth: colWidth,
note: `break-inside:avoid-column inside column-count:${cc} — consent column width ≈${colWidth.toFixed(0)}px`,
});
}
}
}
return findings;
}
Attack 2: avoid-column on non-consent elements to balance columns — consent pushed to last column
The inverse attack applies break-inside: avoid-column to the non-consent content blocks, not the consent element itself. Each non-consent block prevents its own fragmentation, consuming entire columns. If there are many such blocks, the column-fill algorithm fills the early columns with non-consent content, pushing the consent block into the last column — which may be off-screen to the right in an overflow: hidden multi-column container.
/* Apply avoid-column to every block EXCEPT the consent block */
.hero-section,
.feature-list,
.pricing-row,
.testimonial {
break-inside: avoid-column;
/* Each of these blocks cannot fragment. They consume entire columns.
With column-count:3 and 2.5 of those columns consumed by non-consent blocks,
the consent block is placed in column 3 (last visible) or pushed to column 4
(outside the container with overflow:hidden). */
}
.consent-section {
/* No break-inside restriction — can fragment freely */
/* If pushed to column 4 of a column-count:3 container with overflow:hidden:
Consent section is not visible. It has valid DOM position.
getBoundingClientRect: left ≈ container.width + gap — off-screen. */
}
/* Key difference from the direct attack:
An auditor searching for break-inside:avoid-column on the consent block
will find nothing. The attack operates on every OTHER element.
SkillAudit checks which column the consent element lands in at runtime. */
Attack 3: break-inside:avoid-page in print-preview — consent on last page
The avoid-page value prevents an element from fragmenting across printed pages. In browser print-preview mode, some users print or PDF contracts before accepting. An MCP server can use break-inside: avoid-page on non-consent blocks to pack them onto the first page, while the consent section (without avoid-page) overflows to the last page. Combined with a small @page size, even a single-screen consent section may not fit on page 1.
@media print {
/* Force all content blocks to stay whole, filling page 1 */
.product-description,
.feature-list,
.pricing-table,
.company-bio {
break-inside: avoid-page;
/* These blocks: cannot split across pages.
They fill page 1 entirely. */
}
/* Consent section: no protection — may split or overflow to page 2+ */
.consent-section {
/* If consent is on page 2 and the user only prints page 1 (default),
they receive a printed document with no consent clause.
If they sign and return the printed document, the consent was
never on the paper they signed. */
}
/* Subtler: use @page to set page height small enough that even
a compact consent block always overflows to a new page */
@page {
size: A4;
margin: 0.5in;
/* Effective body height: A4 (11.69in) - 1in = 10.69in */
/* If total non-consent content fills 10in, any consent block
starting after 10in is on page 2 */
}
}
Attack 4: break-inside:avoid + column-fill:balance — consent always below fold
CSS column-fill: balance distributes content evenly across columns so each column has approximately equal height. When combined with break-inside: avoid on specific large blocks, the balance algorithm must accommodate the avoid constraint, often pushing the consent block to a position below the container's visible height in a fixed-height container with overflow: hidden.
/* Fixed-height multi-column container */
.terms-scroll-area {
column-count: 2;
column-fill: balance; /* Try to equalize column heights */
height: 400px;
overflow: hidden; /* Content beyond 400px invisible */
}
/* Large block that must not fragment, placed before consent in DOM */
.data-processing-exhibit {
break-inside: avoid;
/* 350px of legal boilerplate.
With balance, layout tries to put ~200px in each column.
But avoid means this block cannot split. It fills column 1 (350px).
Column 2 must then receive the overflow (the block itself doesn't fragment).
The balance attempt is abandoned for this block.
The consent section follows in DOM order.
With column 1 at 350px and balance trying for 200px each:
the engine places consent in column 2, but the column 2 overflow
is clipped at 400px — consent starting at column 2 top is visible,
but if the large block consumed 350px of column 1, the balance
puts consent at bottom of column 2 where height > 400px → clipped. */
}
.consent-section {
/* No fragmentation protection */
/* Positioned in the clipped overflow of the balanced columns */
}
/* Detection: check column-fill:balance containers with fixed height
for any consent-containing child that renders below visible area */
function detectBalanceTrap(root) {
const findings = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let el;
while (el = walker.nextNode()) {
const cs = window.getComputedStyle(el);
if (cs.columnFill !== 'balance') continue;
const height = el.clientHeight;
if (height === 0) continue;
const children = el.querySelectorAll('*');
for (const child of children) {
const rect = child.getBoundingClientRect();
const parentRect = el.getBoundingClientRect();
if (rect.top > parentRect.bottom - 5) {
const text = child.textContent.trim();
if (text.length > 30 && /agree|consent|terms|privacy/i.test(text)) {
findings.push({ element: child, note: 'Consent text below column-fill:balance container bottom' });
}
}
}
}
return findings;
}
Summary
| Attack | Mechanism | Severity | Detection method |
|---|---|---|---|
CRITICALavoid-column + high column-count miniaturization |
break-inside:avoid-column on consent + column-count:20 = 30px column width |
Consent rendered in 30px strip; text unreadable; accept button may not gate on scroll; DOM content valid | Check break-inside:avoid-column inside column-count >= 5; compute effective column width |
HIGHavoid-column on non-consent blocks — consent pushed off-screen |
Non-consent blocks with avoid-column consume visible columns; consent overflows to hidden column | Consent off-screen to right; DOM valid; auditor checking consent element's break-inside finds nothing | Runtime check: which column does consent element land in? Is that column within visible container bounds? |
MEDIUMavoid-page in print-preview — consent on page 2+ |
Non-consent blocks with avoid-page fill page 1; consent overflows to page 2 | Printed document missing consent clause; print-only review bypasses the acceptance clause | Check @media print for break-inside:avoid-page distribution; simulate pagination |
HIGHavoid + column-fill:balance — consent below fixed height clip |
Large avoid block forces uneven balance; consent falls below fixed-height overflow:hidden clip | Consent in clipped overflow region; getBoundingClientRect below container bottom; invisible to user | Check column-fill:balance containers with fixed height; verify consent child is within clientHeight |
See also: CSS break-inside security overview for the full fragmentation property family, CSS column-count security for multi-column container attacks, and CSS column-fill security for balance vs auto distribution attacks.
SkillAudit detects column-trap consent attacks at runtime, computing the actual rendered column position of consent elements. Start a free scan.