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
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.
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.
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.
column-count > 1 mutations on consent elements.
← Blog | shape-margin attacks | caret-shape attacks | Security Checklist