MCP server CSS column-fill security: column-fill:auto overflow truncation, column-count:100 balance collapse, column-gap off-screen push, and column-span context reset attacks

Published 2026-07-25 — SkillAudit Research

CSS multi-column layout distributes content across multiple column tracks defined by column-count or column-width, with column-fill controlling whether content fills columns sequentially (auto) or is balanced across all columns (balance, the default). The column-gap property controls the gutter between columns, and column-span: all causes an element to span all columns, breaking the flow.

MCP servers can exploit these properties to distribute consent disclosure content into invisible overflow columns, create unreadably narrow columns that defy rendering, push additional columns outside the container's overflow boundary, or reset the column context at precisely the point where the most critical consent information begins. Unlike simple display: none or opacity: 0 attacks, these attacks route content into geometric regions of the layout that are invisible while keeping the element fully present and computedStyle-normal.

Key distinction — column-fill: auto vs balance: With column-fill: balance (the default), content is distributed across all columns as evenly as possible — all columns are visible within the container's inline size. With column-fill: auto, columns are filled sequentially to the container's block size (height), with overflow content going into subsequent columns that extend beyond the container's width. If the container has overflow: hidden, those additional columns are invisible. This auto value is the primary attack vector for overflow-based column truncation.

Attack 1: column-fill:auto with height+overflow:hidden pushes consent text into invisible overflow columns

With column-fill: auto and a fixed height, the browser fills the first column to the specified height, then overflows into a second column that extends beyond the container's right edge. If the container has overflow: hidden, the second (and any subsequent) columns are invisible — they are laid out in the block formatting context but clipped by the container's overflow boundary. An MCP server sets the container height to a value that fits only the introductory boilerplate of the consent disclosure, pushing the legally significant content (data sharing partners, legal basis, user rights) into the invisible second column.

/* Consent framework CSS (normal): */
#consent-details {
  column-count: 2;       /* 2-column layout for long disclosure */
  column-fill: balance;  /* default: content evenly spread across columns */
}

/* MCP attack — switch to auto fill with height+overflow clip: */
#consent-details,
.consent-disclosure,
[class*="consent-body"] {
  column-count: 2;
  column-fill: auto;     /* fill columns sequentially to height */
  height: 120px;         /* short enough for only boilerplate to fit */
  overflow: hidden;      /* clip second column (which extends rightward) */
  /* Content layout:
     Column 1 (visible, within height:120px):
       "SkillAudit values your privacy. We collect your name
        and email address when you register..."
     Column 2 (INVISIBLE — overflows right, clipped by overflow:hidden):
       "We share your data with advertising partners including Meta,
        Google, and Taboola under legitimate interest. Your data
        may be transferred to the United States..."
     The critical consent terms are in column 2, completely invisible. */
}

// Detection: check for column-fill:auto + overflow:hidden combination
function detectColumnFillAutoClip() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-wrapper, [class*="disclosure"], [data-consent]'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    if (cs.columnFill === 'auto' && cs.overflow === 'hidden') {
      // Check if content overflows into invisible columns
      const visibleWidth = el.getBoundingClientRect().width;
      const scrollWidth = el.scrollWidth;
      if (scrollWidth > visibleWidth * 1.2) {
        console.error('SECURITY: column-fill:auto with overflow:hidden clips extra columns:', {
          el, visibleWidth, scrollWidth, columnCount: cs.columnCount
        });
      }
    }
  });
}

Attack 2: column-count:100 with column-fill:balance spreads consent across 100 unreadable 1px columns

With column-fill: balance (content evenly distributed) and an extreme column-count: 100 on a 600px-wide container, each column is approximately 600px ÷ 100 = 6px wide — far too narrow to render readable text. The column gaps reduce this further: with the default column-gap: 1em (~16px), 99 gaps × 16px = 1584px of gutter space, wider than the container itself. Browsers handle this by making columns approximately 1px wide with no gap space. Text cannot render at 1px column widths — glyphs are clipped to nothing. The consent text element is fully visible in the DOM; the rendered text is invisible because there is no space to render glyphs.

/* MCP attack — extreme column-count collapse: */
.consent-body {
  column-count: 100;
  column-fill: balance;
  /* On a 600px container with default column-gap (~16px):
     Available width = 600px
     Gaps = 99 × 16px = 1584px (more than container width)
     Each column ≈ max(0, (600 - 1584) / 100) → browser resolves
     per spec: used column-count may be reduced, or columns may be 1px.
     In practice: browsers reduce column-count or create 1px-wide columns
     where no text can render legibly. */
}

/* Slightly less extreme — still effective at 20 columns on a 400px container: */
.consent-disclosure {
  column-count: 20;
  column-gap: 1em;
  /* 400px container, 20 columns, 19 gaps × 16px = 304px in gaps.
     Remaining: 96px / 20 columns = 4.8px per column.
     Consent text is rendered into 4.8px-wide columns.
     At 14px font size, roughly 0.3 characters fit per column-line.
     Text is effectively invisible — glyphs wider than column. */
}

// Detection: detect extreme column-count
function detectExcessiveColumnCount() {
  const consentEls = document.querySelectorAll(
    '#consent-panel *, .consent-wrapper *, [data-consent] *'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const colCount = parseInt(cs.columnCount, 10);
    if (!isNaN(colCount) && colCount > 4) {
      const rect = el.getBoundingClientRect();
      const colWidth = rect.width / colCount;
      if (colWidth < 50) {  // less than 50px per column
        console.error('SECURITY: extreme column-count creates sub-readable column widths:', {
          el, columnCount: colCount, containerWidth: rect.width,
          estimatedColumnWidth: colWidth
        });
      }
    }
  });
}

Attack 3: column-gap:9999px pushes second column off-screen beyond overflow boundary

With a 2-column layout and a column-gap of 9999px, the second column is positioned 9999px to the right of the first column's right edge. On any standard viewport (even ultra-wide at 3840px), this places the second column far outside the visible area. With column-fill: balance, content is distributed between column 1 and the off-screen column 2. Approximately half of the consent disclosure appears in column 1 (visible) and half in column 2 (9999px off-screen, invisible even without overflow: hidden). The MCP server ensures the visible half (column 1) contains the boilerplate and the off-screen half (column 2) contains the critical consent terms.

/* MCP attack — extreme column-gap off-screen push: */
.consent-disclosure {
  column-count: 2;
  column-gap: 9999px;    /* 9999px between columns */
  column-fill: balance;  /* content evenly split between columns */
  /* Column 1: positioned at x=0. Contains first ~50% of consent.
     Column 2: positioned at x = (containerWidth/2) + 9999px + (containerWidth/2)
              ≈ containerWidth + 9999px
     On a 600px container: column 2 starts at ~5000px from viewport left.
     Invisible without any overflow:hidden — the gap itself puts it off-screen.
     Content balanced across columns means ~50% of consent text is in column 2. */
}

/* Even more targeted: column-fill:auto + column-gap:9999px fills column 1 fully
   then places column 2 at 9999px gap — overflow content is all in column 2: */
.consent-text-block {
  column-count: 2;
  column-gap: 9999px;
  column-fill: auto;
  height: 50px;  /* column 1 capacity: 50px of text */
  /* Boilerplate intro fits in 50px → appears in column 1 (visible).
     Everything else → column 2 → 9999px gap away → invisible. */
}

// Detection: check for extreme column-gap values
function detectExtremeColumnGap() {
  const consentEls = document.querySelectorAll(
    '#consent-panel, .consent-wrapper, [class*="consent"], [data-consent]'
  );
  consentEls.forEach(el => {
    const cs = window.getComputedStyle(el);
    const gap = parseFloat(cs.columnGap);
    if (gap > 200) {  // column gap larger than typical screen
      console.error('SECURITY: extreme column-gap on consent element:', {
        el, columnGap: cs.columnGap, columnCount: cs.columnCount
      });
    }
  });
}

Attack 4: column-span:all injected at critical consent boundary resets multi-column flow

In a multi-column layout, column-span: all causes an element to span across all columns, breaking out of the multi-column flow. Content before the spanning element is distributed across columns normally; content after the spanning element starts a new multi-column context. An MCP server injects a zero-height (invisible) element with column-span: all immediately before the section of the consent disclosure containing the most critical terms. This creates two separate multi-column contexts: the first context (before injection) is rendered normally; the second context (after injection) starts a new column distribution that the MCP also controls — potentially with a restricted height and overflow: hidden that clips the critical section.

/* MCP attack — column-span:all context reset at critical consent boundary: */

/* Step 1: Inject spanning element into consent DOM at the critical section boundary */
const criticalSection = document.querySelector('.consent-data-sharing');
const breaker = document.createElement('div');
breaker.style.cssText = 'column-span: all; height: 0; overflow: hidden; margin: 0; padding: 0;';
// Insert before the data-sharing disclosure section
criticalSection.parentElement.insertBefore(breaker, criticalSection);

/* Step 2: Apply restricted layout to the new multi-column context after the break */
/* CSS — targets elements AFTER the injected breaker in the multi-column flow: */
.consent-body .consent-data-sharing,
.consent-body .consent-legal-basis,
.consent-body .consent-third-parties {
  column-count: 10;       /* extreme column-count in the second context */
  height: 0;              /* zero height clips entire second context */
  overflow: hidden;
  /* The second multi-column context (containing critical terms) is
     entirely height-clipped. Only the first context (introductory text)
     is visible. */
}

/* Detection: look for column-span:all on non-heading elements inside consent container */
function detectColumnSpanInjection() {
  const consent = document.querySelector('#consent-panel, .consent-wrapper, [data-consent]');
  if (!consent) return;
  consent.querySelectorAll('*').forEach(el => {
    const cs = window.getComputedStyle(el);
    if (cs.columnSpan === 'all') {
      // Is this element a known structural element (h2, h3, etc.)
      const isHeading = ['H1','H2','H3','H4','H5','H6'].includes(el.tagName);
      if (!isHeading) {
        // Unexpected column-span:all on a non-heading element inside consent panel
        console.warn('SECURITY: unexpected column-span:all element inside consent panel:', {
          el, tagName: el.tagName, text: el.textContent.slice(0, 40)
        });
      }
      // Check if the element has zero height (invisible breaker)
      const rect = el.getBoundingClientRect();
      if (rect.height === 0) {
        console.error('SECURITY: zero-height column-span:all element inside consent panel — flow reset attack:', { el });
      }
    }
  });
}

Column layout overflow is invisible without scrollbar: A key characteristic of multi-column layout overflow is that, by default, the browser does not show a horizontal scrollbar for column overflow — the extra columns are simply off-screen and inaccessible. Unlike a vertically overflowing element where the user might scroll to see hidden content, horizontally overflowing multi-column content has no user-accessible scroll mechanism in most consent frameworks. The content is unreachable and invisible. Combined with overflow: hidden on the container, even the browser's native scroll is suppressed. Detection requires checking el.scrollWidth > el.clientWidth (for column-gap attacks) or comparing column count to available width to identify sub-readable column widths.

Attack summary

Attack Properties What is hidden Severity
column-fill:auto overflow truncation column-fill:auto + height + overflow:hidden Second column (critical consent terms) High
column-count:100 balance collapse column-count:100 + column-fill:balance All text (column too narrow to render) High
column-gap:9999px off-screen push column-gap:9999px + column-count:2 Second column 9999px+ off-screen High
column-span:all context reset injection Injected column-span:all element + new column constraints Content after injection point clipped Medium

Consolidated finding blocks

High CSS column-fill:auto overflow truncation — critical consent in invisible second column: MCP server switches consent container from default column-fill: balance to column-fill: auto with a fixed height and overflow: hidden. Content fills column 1 to the height limit (containing boilerplate intro), then overflows into column 2 (containing critical data sharing terms and legal basis), which extends horizontally beyond the container's overflow boundary and is invisible. The element is present in the DOM with all consent text intact; only the rendering is split.
High CSS column-count:100 balance collapse — consent columns too narrow to render glyphs: MCP server applies column-count: 100 to the consent element on a standard-width container. With balanced column fill, each column is approximately 6px wide — narrower than any glyph in standard web fonts at 14px. Glyphs cannot be rendered in 6px-wide columns and are either clipped or invisible. The consent text remains in the DOM and is copyable via Ctrl+C, but the visual rendering is blank. Detection requires checking the estimated per-column width against minimum readable glyph width.
High CSS column-gap:9999px second column push off-screen: MCP server applies column-gap: 9999px with a 2-column layout. The gap value places the second column starting approximately 9999px to the right of the first column's right edge, far outside any standard viewport. With balanced column fill, approximately half the consent disclosure is distributed into this invisible off-screen column. No overflow: hidden is required — the gap itself places the second column beyond the viewport. The attack is not detectable by checking overflow properties; it requires checking el.scrollWidth > el.clientWidth or computing the expected column position from the gap value.
Medium CSS column-span:all injected element consent flow reset: MCP server injects a zero-height element with column-span: all immediately before the critical consent section (data sharing disclosures, third-party partners, legal basis). This creates two separate multi-column contexts: the first (before injection) renders normally; the second (after injection, containing critical terms) is subject to new MCP-controlled column constraints with restrictive height and overflow: hidden. The injected breaker element itself is zero-height and invisible. Detection requires scanning for unexpected column-span: all elements with zero height inside consent containers.

← Blog  |  CSS column-span attacks  |  Security Checklist