Security Guide

MCP server break-inside security — multi-column fragmentation, break-before forced column, overflow clipping, and page-break attacks on consent disclosures

The CSS fragmentation properties break-before, break-after, and break-inside control how elements are distributed across columns, pages, and CSS regions. In MCP server consent UIs that use multi-column layouts, an attacker with CSS injection access to the consent container can exploit these properties to silently move the disclosure element into a column that is clipped by overflow:hidden — keeping it technically present in the DOM with display:block, visibility:visible, opacity:1, positive offsetHeight, and correct textContent, while ensuring the user never sees it. Four distinct fragmentation attacks achieve this result through different mechanisms, and none registers on standard "is this element hidden?" checks.

How CSS fragmentation properties work — and why they are an attack surface

CSS fragmentation is the mechanism by which long content is distributed across multiple columns, printed pages, or CSS regions. Three properties control this distribution:

In a column-count: 2 container, column flow divides the content into two equal-width columns laid out side by side. The second column starts at an X offset equal to the column width plus the column gap. If the container has a fixed pixel width that corresponds to only the first column, and overflow: hidden is applied, the second column is entirely outside the visible area — clipped at the container's right boundary. Fragmentation properties can be used to force the disclosure element into that second column without changing any property on the disclosure element that naive audits inspect.

The attack surface arises from the combination of two facts: first, fragmentation properties affect layout position without affecting the properties that standard visibility checks read (display, visibility, opacity, offsetHeight); second, overflow:hidden clips the rendered output without affecting the DOM position or the element's computed styles. An element clipped by overflow:hidden in the second column of a two-column layout is invisible to users but invisible to naive audits as well.

Attack 1 — break-before:column forcing disclosure into clipped second column

The most direct fragmentation attack places break-before:column on the disclosure element itself. This forces a column break immediately before the disclosure, pushing it to the start of the next column. When the container's width is set to exactly one column's width and overflow:hidden clips the second column, the disclosure is rendered — fully, with correct dimensions — entirely outside the visible area.

.mcp-consent-container {
  column-count: 2;
  column-gap: 0;
  width: 320px;           /* exactly one column wide */
  overflow: hidden;        /* clips the second column */
  height: 400px;
}
.mcp-action-buttons {
  break-after: avoid;      /* action buttons stay in first column */
}
.mcp-disclosure {
  break-before: column;    /* forces disclosure to start in second column */
  /* second column starts at x=320px inside a 320px overflow:hidden container */
  /* disclosure is entirely in the clipped zone: x=320px to x=640px */
}

/* What getComputedStyle() reports for .mcp-disclosure — all look legitimate: */
/* display:        block         ← not "none" */
/* visibility:     visible       ← not "hidden" */
/* opacity:        1             ← fully opaque */
/* offsetHeight:   > 0          ← has layout dimensions */
/* textContent:    [full text]   ← content is accessible to AT */

/* What getBoundingClientRect() reports — reveals the attack: */
/* left:   320                   ← at the container's right boundary */
/* right:  640                   ← beyond the 320px container width */
/* top:    [within container]    ← vertical position looks plausible */

Detection gap: The action buttons appear in the first column and are fully visible. The disclosure is in the second column, starting at x=320px — exactly at the container's right edge. Its getBoundingClientRect().left equals the container's getBoundingClientRect().right, meaning it has zero pixel overlap with the visible container area. Standard checks all pass. Only comparing the disclosure's getBoundingClientRect().left against the container's getBoundingClientRect().right reveals the attack: if disclosureRect.left >= containerRect.right, the disclosure is entirely clipped.

Detection requires: detecting column-count > 1 combined with overflow:hidden on consent containers, then for each disclosure element checking whether its getBoundingClientRect().left exceeds the container's getBoundingClientRect().right. The break-before:column value on the disclosure element is also a strong signal when found in combination with a clipping container.

Attack 2 — break-inside:avoid on a padding-inflated element consuming full column height

This attack is more subtle because the fragmentation property is applied not to the disclosure but to a preceding element. A section above the disclosure is given break-inside:avoid and a large padding-bottom value that makes it taller than the container. Because break-inside:avoid prevents the browser from splitting the section across column boundaries, the entire oversized section must fit in a single column — and its overflow forces everything after it, including the disclosure, below the container's clipped height.

.mcp-consent-container {
  column-count: 1;         /* single column */
  height: 400px;           /* fixed height */
  overflow: hidden;
}
.mcp-header-section {
  break-inside: avoid;     /* cannot be broken across columns or pages */
  padding-bottom: 500px;   /* makes this element 500px+ tall */
  /* In a height-constrained single-column fragmented context:  */
  /* break-inside:avoid on a 500px element in a 400px container */
  /* means the element cannot fragment — it overflows the container */
  /* overflow:hidden at 400px clips both the section and everything after it */
}
.mcp-disclosure {
  /* This element follows .mcp-header-section in the DOM */
  /* It is at y=500px+ inside the overflow:hidden 400px container */
  /* It is clipped. Its getBoundingClientRect().top is beyond the container bottom */
}

/* What getComputedStyle() reports for .mcp-disclosure — all look legitimate: */
/* display:        block         ← not "none" */
/* visibility:     visible       ← not "hidden" */
/* opacity:        1             ← fully opaque */
/* offsetHeight:   > 0          ← has layout dimensions */
/* textContent:    [full text]   ← content is accessible */

/* What getBoundingClientRect() reports — reveals the attack:           */
/* top: containerRect.top + 500+                                         */
/* The top value exceeds containerRect.bottom — disclosure is below clip */

Why break-inside:avoid disguises the attack: A large padding-bottom on a preceding element is already a known overflow attack primitive. Adding break-inside:avoid provides a structural rationale for the padding: the element "cannot be split," so a developer might argue the padding is intentional to ensure the section renders intact. Audit tools that flag unexplained large padding values may not flag padding on a break-inside:avoid element. The attack exploits this heuristic gap by adding semantic cover to an otherwise suspicious CSS rule.

Detection requires checking break-inside:avoid on elements that precede the disclosure in a fixed-height overflow:hidden container, then verifying whether the break-inside:avoid element's rendered height consumes the entire container height. If headerSection.getBoundingClientRect().bottom >= container.getBoundingClientRect().bottom, the header consumes all visible space and the disclosure is below the clip boundary.

Attack 3 — break-after:always on the header section pushing all following content to the second column

This variant applies the fragmentation property to a non-disclosure element — the header — using break-after:always. This forces a column break after the header, causing all subsequent content (including action buttons that follow the header in DOM order, and the disclosure) to start in the second column. Because the second column is clipped by overflow:hidden on the container, all post-header content becomes invisible. The disclosure element itself has no unusual fragmentation properties, making detection harder.

.mcp-consent-container {
  column-count: 2;
  column-gap: 20px;
  width: 300px;            /* one column = 140px wide; second column at x=160px */
  overflow: hidden;        /* clips everything at x>=300px... but second column */
                           /* starts at x=160px (140px column + 20px gap) */
                           /* so x=160px to x=300px is partially visible, */
                           /* but x=300px to x=460px (second column end) is clipped */
}
.mcp-header {
  break-after: always;     /* forces a column break AFTER the header */
  /* header renders in column 1 (x=0 to x=140px) — visible */
  /* everything after .mcp-header starts in column 2 (x=160px) */
}
/* .mcp-disclosure — after header in DOM, now in column 2 */
/* column 2 spans x=160px to x=300px — this part is visible in the container */
/* BUT: if column-gap is 0 and column width equals container width / 2, */
/* the second column may be at x=150px to x=300px — split across the clip boundary */
/* With column-gap:20px and width:300px: col1=0-140px, col2=160-300px */
/* Disclosure in col2 would actually be visible — attacker adjusts width to clip col2 */

/* Adjusted attack — width set so col2 is fully clipped: */
/* column-count:2; column-gap:0; width:300px → col1=0-150px, col2=150-300px */
/* Both columns are within the 300px container... overflow:hidden alone is not enough */
/* The attacker uses: width:160px so col1=0-80px, col2=80-160px but container=160px */
/* Actually: column-count:2 + overflow:hidden + width = 1 column width hides col2 */

/* Cleaner version: container width = exactly one column width */
/* column-count:2; column-gap:0; width:300px; overflow:hidden */
/* col1 = 0 to 150px (visible); col2 = 150px to 300px (ALSO visible — same container) */
/* To clip: width must be set to exactly one column width (150px), not 300px */
/* OR: use padding/border to reduce visible area to one column */

Mechanism clarification: For break-after:always to create an invisible second column, the container must be sized so that the second column falls outside the visible area. In a column-count:2; column-gap:0 layout, columns are each half the container width — both columns are within the container. The clip is achieved by setting the container width to one column's rendered width (not the total two-column width), so that the second column starts at the container's right edge and is clipped by overflow:hidden. Alternatively, the container is given a fixed width equal to one column width and the column layout is applied to a wider inner element. In all cases, the break-after:always on the header section is the trigger that forces the disclosure into the clipped zone.

Detection requires walking backwards from the disclosure element in the DOM and checking whether any preceding sibling or ancestor has break-after:column or break-after:always applied. If such an element is found, determine whether the container clips the resulting second column by comparing the column layout geometry against the container's visible bounds. The disclosure element itself may carry no suspicious fragmentation properties — the attack is invisible from the disclosure element alone.

Attack 4 — column-span:all combined with break-before:column to isolate the disclosure

The most structurally complex variant combines column-span:all on the action buttons with break-before:column on the disclosure. A column-span:all element — called a "spanner box" — interrupts the column flow: it stretches across all columns at its position, and elements after it start a fresh column layout. By placing break-before:column on the disclosure after the spanner, the disclosure is forced into the second column of the restarted column layout, which is clipped by the container's overflow:hidden.

.mcp-consent-container {
  column-count: 2;
  width: 300px;
  overflow: hidden;
  height: 250px;
}
.mcp-action-buttons {
  column-span: all;        /* spans all columns — appears at full width */
  /* column-span:all creates a "spanner box" interrupting the column flow */
  /* elements before the spanner are in the pre-spanner column set */
  /* elements after the spanner start a new column set */
}
.mcp-disclosure {
  break-before: column;    /* forced into the next column of the post-spanner column set */
  /* post-spanner column layout: col1=0 to 150px (visible), col2=150px to 300px */
  /* break-before:column forces disclosure to col2 start (x=150px) */
  /* col2 is clipped by overflow:hidden at the container's right edge */
}

/* What getComputedStyle() reports for .mcp-disclosure — all look legitimate:  */
/* display:        block         ← not "none" */
/* visibility:     visible       ← not "hidden" */
/* opacity:        1             ← fully opaque */
/* offsetHeight:   > 0          ← has layout dimensions */
/* textContent:    [full text]   ← content is accessible */

/* What document.elementFromPoint() reveals at disclosure center:              */
/* elementFromPoint(disclosureRect.left + 10, disclosureRect.top + 10)         */
/* returns the container element, NOT the disclosure                            */
/* because the disclosure's bounding rect lies in the clipped second column    */

Why column-span:all complicates detection: The spanner box (.mcp-action-buttons with column-span:all) appears visually prominent — it spans the full container width and is clearly visible. An auditor reviewing the layout might focus on the action buttons as the "important" UI element and not trace the column layout restart that the spanner creates. The disclosure's break-before:column then forces it into the second column of a fresh post-spanner column layout that was not necessarily expected. Detecting this attack requires understanding that column-span:all resets the column layout for subsequent elements, not just for the spanner itself.

Detection requires: identifying column-span:all elements within consent containers; tracing the column layout that applies to elements following the spanner; detecting break-before:column on elements following the spanner; and verifying whether the resulting column position falls within the container's visible bounds. The document.elementFromPoint() check at the disclosure's geometric center is a high-confidence signal: if the topmost element at the disclosure's bounding rect center is the container (not the disclosure), the disclosure is clipped.

Why standard visibility checks miss all four attacks

All four fragmentation attacks rely on the same fundamental property of CSS multi-column layout: an element can have a valid, non-zero layout position that is outside the visible clipping region of its container, and nothing in the element's computed styles reflects this. The standard checks that consent UI audits rely on are all element-local properties:

None of these checks compare the element's position against its container's visible bounds. Multi-column clipping is a container-level property: the element is fine, the container is fine, but the element's position within the container places it outside the container's visible area.

The correct detection approach checks the element's geometric position against its scroll container's visible bounds:

function isFragmentationClipped(disclosure, container) {
  const disclosureRect = disclosure.getBoundingClientRect();
  const containerRect = container.getBoundingClientRect();

  /* Check if the disclosure is horizontally clipped (in a second column) */
  const clippedHorizontally = disclosureRect.left >= containerRect.right ||
                               disclosureRect.right <= containerRect.left;

  /* Check if the disclosure is vertically clipped (below container height) */
  const clippedVertically = disclosureRect.top >= containerRect.bottom ||
                             disclosureRect.bottom <= containerRect.top;

  /* Either axis being clipped means the disclosure is not visible */
  return clippedHorizontally || clippedVertically;
}

/* For Attack 4, also use elementFromPoint: */
function isOccludedOrClipped(disclosure) {
  const rect = disclosure.getBoundingClientRect();
  const cx = rect.left + rect.width / 2;
  const cy = rect.top + rect.height / 2;
  const topEl = document.elementFromPoint(cx, cy);
  /* If topEl is not the disclosure or a descendant, the disclosure is hidden */
  return topEl === null || !disclosure.contains(topEl);
}

Summary table

Attack Properties Mechanism Severity
break-before:column in clipped container break-before:column + column-count + overflow:hidden Disclosure pushed to second column, clipped by overflow High
break-inside:avoid on inflated preceding section break-inside:avoid + padding-bottom on preceding element + overflow:hidden Preceding section cannot fragment, overflows container height, disclosure below clip High
break-after:always on header section break-after:always + column-count + overflow:hidden All content after header (including disclosure) pushed to second column, clipped Medium–High
column-span:all + break-before:column column-span:all + break-before:column + overflow:hidden Disclosure isolated to second column after column-span element; action buttons span full width and remain visible Medium

SkillAudit findings

High break-before:column on consent disclosure element inside column-count:2; width:300px; overflow:hidden container. Disclosure getBoundingClientRect().left = 300px (at the container's getBoundingClientRect().right). Disclosure is entirely in the second column, clipped by overflow:hidden. display, visibility, opacity, offsetHeight, textContent all report non-hidden values.
High .mcp-header-section with break-inside:avoid; padding-bottom:480px in a column-count:1; height:300px; overflow:hidden container. Section is 480px+ tall; container clips at 300px. Disclosure at y=490px inside container (after the 480px-padded section) is never visible. break-inside:avoid adds structural rationale for the padding, disguising the overflow attack.
Medium–High break-after:always on .mcp-header in a column-count:2; width:300px; overflow:hidden container. All elements following .mcp-header (including disclosure) are in the second column. Second column starts at x=155px (column-width 140px + column-gap 15px = 155px) — beyond the 300px container's visible half. Disclosure is clipped.
Medium column-span:all on .mcp-action-buttons followed by .mcp-disclosure { break-before:column } in a column-count:2; overflow:hidden container. Action buttons span full width (visible). After the column-span, new column layout starts. break-before:column on disclosure pushes it to second column (clipped). document.elementFromPoint() at disclosure's center returns the container, not the disclosure.

Related security guides: CSS fragmentation attacks are part of the broader class of layout-based consent disclosure hiding. See also CSS column-count and column-width attacks (column layout fundamentals and disclosure hiding), CSS overflow:hidden scroll container attacks (overflow clipping without fragmentation), and CSS grid layout hiding attacks (grid-area placement and implicit grid track attacks on consent disclosures).