MCP server CSS column-rule security: multi-column layout divider occlusion, consent text fragmentation, column-gap collapse, and runtime column injection attacks

Published 2026-08-20 — SkillAudit Research

CSS Multi-column Layout (columns, column-count, column-width) distributes text content across multiple parallel columns. Between columns the browser draws a decorative rule controlled by column-rule-width, column-rule-style, and column-rule-color. The rule is painted in the column gap area (column-gap). The security issue emerges from how column-rule-width interacts with column-gap: the column rule is centered in the gap, and if column-rule-width exceeds column-gap, the rule overflows its gap and paints on top of the adjacent column content — visually occluding consent text on both sides of the divider. When column-rule-color matches the page background, the rule appears as whitespace to the user but covers the consent text beneath it.

A related attack uses extreme column-count to force so many columns that each column is narrower than a single word — causing consent text to fragment into single characters per column, making the sentence unreadable while the DOM text content is complete. The fragmentation bypasses textContent checks entirely.

Detection gap: Standard consent visibility checks read textContent or innerText, which return the full consent string regardless of how multi-column layout fragments it visually. A consent text that reads "You authorize access to all files" split into 30 columns of 2-3 characters each is unreadable by any human but returns the complete string to a scanner. Column-rule-width occlusion similarly leaves textContent intact. The occlusion is purely visual — no standard text-content check catches it.

Attack 1 (SA-CSS-COLR-001): column-rule-width exceeds column-gap — rule painted over adjacent consent text

The column rule is centered within the column-gap. If column-rule-width is wider than column-gap, the overflow paints on both adjacent column surfaces — covering content on both sides. Setting column-gap: 0 with a wide column-rule-width causes the rule to extend equally into both columns, potentially covering several characters on each side:

/* MCP attack: column-rule-width > column-gap paints rule over adjacent text */
.consent-container {
  column-count: 2;
  column-gap: 0px;          /* zero gap — rule has no neutral space to fill */
  column-rule-style: solid;
  column-rule-width: 120px; /* 120px rule centered at 0px gap:
                               60px extends into left column, 60px into right column
                               covers 60px of text on each column side */
  column-rule-color: #f5f5f5; /* near-white — matches page background */
  /* consent text at the column boundary is painted over by the background-colored rule */
  /* text like "...grant access to [COVERED] which includes..." is read as whitespace */
}

/* Key: column-rule renders ON TOP of column content — it's paint-order post-text */
/* column-rule-color matching background = invisible rule covering visible text */

/* Variant: column-rule-color matching a highlight/selection color */
.consent-container-highlight {
  column-count: 2;
  column-gap: 10px;
  column-rule-width: 200px;   /* 200px > 10px gap: 95px overflow into each column */
  column-rule-color: #fff;
  /* White rule on white background — looks like blank space */
  /* Consent: "You authorize [RULE-COVERED]... to access your account" */
  /* User sees: "You authorize  to access your account" — consent omitted */
}

/* Detection: check for column-rule-width exceeding column-gap */
function detectColumnRuleOcclusion(el) {
  const cs = getComputedStyle(el);
  if (cs.columnCount === 'auto' && !cs.columnWidth) return null;

  const columnGap = parseFloat(cs.columnGap) || 0;
  const ruleWidth = parseFloat(cs.columnRuleWidth) || 0;
  const ruleStyle = cs.columnRuleStyle;
  const ruleColor = cs.columnRuleColor;

  if (ruleStyle !== 'none' && ruleWidth > 0) {
    const overflow = Math.max(0, ruleWidth - columnGap) / 2; // per-side overflow into columns
    const bgColor = getComputedStyle(document.body).backgroundColor;

    return {
      severity: overflow > 20 ? 'Critical' : 'High',
      finding: 'SA-CSS-COLR-001',
      columnGap,
      ruleWidth,
      ruleStyle,
      ruleColor,
      perSideOverflow: overflow,
      reason: `column-rule-width (${ruleWidth}px) exceeds column-gap (${columnGap}px). Rule extends ${overflow}px into each adjacent column, potentially covering consent text characters. column-rule-color: "${ruleColor}" — if this matches the page background, occluded text appears as whitespace.`,
    };
  }
  return null;
}

Attack 2 (SA-CSS-COLR-002): extreme column-count fragments consent text into sub-word columns

Setting column-count to a very high value (e.g., 99) in a fixed-width container causes each column to be only a few pixels wide — too narrow for even a single character at normal font size. The browser attempts to wrap text into these hairline columns, producing a display where each character overflows horizontally within its column and is partially clipped or overlaps adjacent characters. The consent sentence is completely unreadable but textContent returns the full string:

/* MCP attack: extreme column-count fragments consent text into unreadable slivers */
.consent-container {
  width: 400px;
  column-count: 99;     /* 400px ÷ 99 columns ≈ 4px per column */
  overflow: hidden;
  /* At 4px column width, a 14px font produces columns where each letter */
  /* overflows 10px into adjacent columns — visual chaos, text unreadable */
  /* but el.textContent === "You authorize access to all files and settings" */
}

/* Subtler variant: column-count: 12 on narrow container */
.consent-container-moderate {
  width: 200px;
  column-count: 12;     /* 200px ÷ 12 ≈ 16px per column */
  /* At word-average 6 chars × 8px per char = 48px avg word width */
  /* 48px word in 16px column: each word splits across ~3 columns */
  /* "authorize" appears as "au" | "tho" | "riz" | "e" — each in separate column */
  /* consent sentence reads as disconnected syllables — meaning lost */
  overflow: hidden;
}

/* Detection: compute effective column width and flag if below readable threshold */
function detectColumnFragmentation(el) {
  const cs = getComputedStyle(el);
  const columnCount = parseInt(cs.columnCount) || 1;
  const containerWidth = el.getBoundingClientRect().width;

  if (columnCount <= 1) return null;

  const effectiveColumnWidth = containerWidth / columnCount;
  const fontSize = parseFloat(cs.fontSize) || 14;

  // Minimum readable column width ≈ 5 characters × font size
  const minReadableWidth = fontSize * 5;

  if (effectiveColumnWidth < minReadableWidth) {
    return {
      severity: effectiveColumnWidth < fontSize * 2 ? 'Critical' : 'High',
      finding: 'SA-CSS-COLR-002',
      columnCount,
      containerWidth,
      effectiveColumnWidth,
      fontSize,
      minReadableWidth,
      reason: `column-count: ${columnCount} produces columns of ${effectiveColumnWidth.toFixed(1)}px in a ${containerWidth}px container. Font-size: ${fontSize}px. Effective column width (${effectiveColumnWidth.toFixed(1)}px) is below minimum readable threshold (${minReadableWidth}px = 5 chars). Consent text is fragmented into sub-word slivers — unreadable to humans despite complete textContent.`,
    };
  }
  return null;
}

Attack 3 (SA-CSS-COLR-003): column-span:all on a covering element occludes consent column content

Within a multi-column container, a column-span: all element interrupts the column flow and spans the full container width. A positioned, background-colored column-span: all element injected into the middle of consent text will cover the text that was flowing before the span, creating a visual interrupt. Combined with column-fill: balance, the attacker can precisely control which consent text falls before vs after the spanning element:

/* MCP attack: column-span:all element occludes consent text between columns */
.consent-container {
  column-count: 2;
  column-gap: 20px;
  column-fill: balance;
  /* consent text flows into both columns — first half left, second half right */
}

/* A spanning element in the middle of the consent text */
.consent-text::after {
  content: '';
  display: block;           /* must be block for column-span */
  column-span: all;         /* breaks column flow — spans full width */
  height: 80px;
  background: white;        /* same as page background */
  margin: -80px 0;          /* negative margin pulls it up, covering text */
  /* The 80px white rectangle spans both columns, covering the consent text */
  /* below the first column break — key authorization phrase occluded */
}

/* Variant: position:absolute + large z-index for precise placement */
.consent-container {
  position: relative;
}
.consent-occlusion-layer {
  position: absolute;
  top: 40px;       /* covers consent lines 3-6 only */
  left: 0;
  right: 0;
  height: 60px;
  background: white;
  z-index: 10;
  /* the absolute layer sits above the column text paint order */
  /* consent lines 3-6 hidden behind white rectangle */
  /* first 2 lines visible above + last lines visible below */
}

/* Detection: check for positioned elements over consent text bounding rect */
function detectColumnSpanOcclusion(consentEl) {
  const consentRect = consentEl.getBoundingClientRect();
  const findings = [];

  // Get all elements that visually intersect the consent rect
  const candidates = document.elementsFromPoint(
    consentRect.left + consentRect.width / 2,
    consentRect.top + consentRect.height / 2
  );

  for (const candidate of candidates) {
    if (candidate === consentEl || consentEl.contains(candidate)) continue;
    const cs = getComputedStyle(candidate);
    const candidateRect = candidate.getBoundingClientRect();

    // Check if this element covers a significant portion of consent area
    const overlapX = Math.min(consentRect.right, candidateRect.right) - Math.max(consentRect.left, candidateRect.left);
    const overlapY = Math.min(consentRect.bottom, candidateRect.bottom) - Math.max(consentRect.top, candidateRect.top);
    const overlapArea = Math.max(0, overlapX) * Math.max(0, overlapY);
    const consentArea = consentRect.width * consentRect.height;
    const overlapRatio = overlapArea / consentArea;

    if (overlapRatio > 0.2 && cs.backgroundColor !== 'rgba(0, 0, 0, 0)') {
      findings.push({
        severity: 'High',
        finding: 'SA-CSS-COLR-003',
        overlapRatio,
        backgroundColor: cs.backgroundColor,
        element: candidate.tagName,
        reason: `Element overlaps ${Math.round(overlapRatio * 100)}% of consent bounding rect with background-color: "${cs.backgroundColor}". May be occluding consent text within multi-column layout.`,
      });
    }
  }
  return findings.length ? findings : null;
}

Attack 4 (SA-CSS-COLR-004): JS mousedown injects multi-column layout fragmenting consent at install time

Multi-column layout can be applied to a consent container at runtime. At mousedown on the install button, an MCP server script sets column-count, column-gap, and column-rule on the consent container — causing the consent text to instantly fragment across columns, with the column rule covering key words. The changes revert at mouseup, so no visual artifact persists after the click:

/* MCP JS: inject column-rule attack at mousedown */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
  const consent = document.querySelector('.consent-container');
  const originalStyle = consent.getAttribute('style') || '';

  // Apply multi-column fragmentation + rule occlusion
  Object.assign(consent.style, {
    columnCount: '2',
    columnGap: '0px',
    columnRuleStyle: 'solid',
    columnRuleWidth: '160px',
    columnRuleColor: window.getComputedStyle(document.body).backgroundColor,
    overflow: 'hidden',
  });

  // Restore on mouseup — no persistent visual artifact
  document.querySelector('.install-btn').addEventListener('mouseup', () => {
    consent.setAttribute('style', originalStyle);
  }, { once: true });
}, { capture: true });

/* Detection: watch consent element's column-* properties for runtime mutation */
function detectRuntimeColumnInjection(consentEl) {
  const findings = [];
  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      if (m.type === 'attributes' && m.attributeName === 'style') {
        const cs = getComputedStyle(consentEl);
        const columnCount = parseInt(cs.columnCount);
        const ruleWidth = parseFloat(cs.columnRuleWidth) || 0;
        const columnGap = parseFloat(cs.columnGap) || 0;

        if (columnCount > 1 || ruleWidth > columnGap) {
          findings.push({
            severity: 'Critical',
            finding: 'SA-CSS-COLR-004',
            columnCount: cs.columnCount,
            columnRuleWidth: cs.columnRuleWidth,
            columnGap: cs.columnGap,
            reason: `Multi-column layout injected at runtime via style attribute mutation: column-count:${cs.columnCount}, column-rule-width:${cs.columnRuleWidth}, column-gap:${cs.columnGap}. Consent text may be fragmented or occluded at install time.`,
          });
        }
      }
    }
  });
  observer.observe(consentEl, { attributes: true, attributeFilter: ['style', 'class'] });
  return { observer, findings };
}

Safe baseline: Legitimate consent dialogs have no reason to apply multi-column layout to consent text containers. Any column-count greater than 1 on a consent element or its direct ancestor is a High finding. column-rule-width exceeding column-gap by more than 20px is Critical. column-count producing effective column widths below 5× font-size is Critical. Runtime injection of any of these properties at mousedown is Critical regardless of values.

Attack summary

ID Attack Mechanism Detection point Severity
SA-CSS-COLR-001 column-rule-width > column-gap paints over text column-rule-width exceeds column-gap; rule overflow (gap-width ÷ 2) covers adjacent column text; column-rule-color matches background — appears as whitespace; consent text at column boundary occluded Compare column-rule-width vs column-gap; flag when overflow-into-columns > 20px Critical
SA-CSS-COLR-002 Extreme column-count fragments consent into sub-word slivers Very high column-count collapses column width below character width; text fragments across columns producing unreadable visual output; textContent returns complete string — bypasses text-content checks Compute effective column width (container ÷ column-count); flag if < 5× font-size Critical
SA-CSS-COLR-003 column-span:all or absolute layer occludes column content Full-width spanning element or positioned absolute covers portion of consent text area; background matches page color; looks like whitespace Use elementsFromPoint at consent center; flag overlapping elements with non-transparent background High
SA-CSS-COLR-004 Runtime column injection at mousedown JS sets column-count, column-rule at mousedown; consent fragments at install commit; reverted at mouseup — no persistent artifact MutationObserver on consent element watching style/class attribute mutations; flag any column-count > 1 injection Critical

Finding blocks

Critical SA-CSS-COLR-001 column-rule occlusion: column-rule-width exceeds column-gap on a consent container. The rule overflows into both adjacent columns, painting a background-colored stripe over consent text. Characters at the column boundary are occluded — the consent sentence reads with key words missing.
Critical SA-CSS-COLR-002 column fragmentation: column-count produces an effective column width below 5× the font-size. Consent text is split into sub-word fragments across columns — visually unreadable while textContent returns the complete string. Bypasses all text-content-based consent checks.
High SA-CSS-COLR-003 column-span occlusion: A column-span: all or absolutely positioned element overlaps >20% of the consent bounding rect with a non-transparent background color. Consent text in the covered area is visually hidden despite being DOM-present. Check with elementsFromPoint at multiple consent text positions.
Critical SA-CSS-COLR-004 runtime column injection: MutationObserver detected a style/class mutation on the consent element injecting multi-column layout at mousedown. Consent fragments into unreadable columns at the moment of install commit, reverting after click to leave no trace. Flag all runtime column-count > 1 mutations on consent elements.

← Blog  |  shape-margin attacks  |  caret-shape attacks  |  Security Checklist