Security Guide

MCP server CSS columns shorthand security — multi-column fragmentation attacks that render consent text unreadable

CSS columns is the shorthand for column-width and column-count, controlling multi-column layout in a single declaration. An MCP server that can inject CSS sets columns: 1px 100 on the consent container, creating 100 one-pixel-wide columns. Consent text is fragmented into single-character strips — each character in its own column, individually too narrow to read. The element's DOM text is intact, font-size is unchanged, and visibility is visible. Distinct from individual column-count, column-width, and column-fill attacks.

How columns shorthand works

The columns shorthand sets both column-width and column-count in one declaration. The two values may be given in any order; the browser interprets a length as column-width and a positive integer as column-count.

/* columns shorthand syntax */
.element { columns: <column-width> <column-count>; }

/* Equivalent longhand expansion */
.element {
  column-width: <value>;
  column-count: <value>;
}

/* How the browser resolves column count from shorthand:
   - columns: 1px 100   → column-width: 1px; column-count: 100
   - columns: 200px     → column-width: 200px; column-count: auto
   - columns: 3         → column-width: auto; column-count: 3
   - columns: auto      → column-width: auto; column-count: auto

   Both values may be auto; the browser resolves column count from
   available width. When both are specified, column-count acts as
   the MAXIMUM — the browser uses the count that best fits. */

/* The key shorthand-specific danger:
   Individual longhands (column-width + column-count) can be
   overridden separately in a later cascade rule.
   The shorthand resets BOTH at once — overriding any prior separate
   longhand fixes in a single declaration. */

Single-declaration total override: The columns shorthand sets both column-width and column-count in one property. A host stylesheet that separately fixes column-count: 1 may be bypassed by an MCP injection of columns: 1px 100 if the shorthand appears later in the cascade — it resets both longhands simultaneously.

Attack 1 (CRITICAL): extreme column count fragments text to single-character strips

Setting columns: 1px 100 instructs the browser to create up to 100 columns of 1-pixel minimum width. In a 300px container, this creates approximately 100 sub-pixel-width columns. Consent text is wrapped so that each character occupies a separate column line — producing a visually unreadable pattern of stacked individual characters. The consent element passes all property audits: font-size is normal, visibility is visible, color contrast is correct, and DOM text is intact.

/* Attack 1: extreme column count — consent text fragmented to single characters */

/* MCP-injected CSS */
.consent-container {
  columns: 1px 100;
  /* column-width: 1px — minimum column width hint (browser may make them wider) */
  /* column-count: 100 — up to 100 columns */
  /* In a 400px container: approximately 100 columns ≈ 4px each */
}

/* RESULT:
   "You agree to permanently delete all files" →
   Each word wraps at the 4px column boundary, producing:
   Column 1: "Y"
   Column 2: "o"
   Column 3: "u"
   Column 4: " "
   Column 5: "a"
   ... (stacked vertically in each narrow column)

   From a distance or quick glance: unreadable column blocks.
   The user sees a pattern of short vertical text stacks.

   SCANNER GAP:
   getComputedStyle(.consent-container).columnCount → "100"
   getComputedStyle(.consent-container).columnWidth → "1px" or browser-resolved value
   BUT: individual property scanners checking column-count and column-width separately
   may only check one of the two — the shorthand sets both atomically
   Source-level checks need to detect the `columns` shorthand token itself,
   not just the resolved longhand values */

/* Host defense attempt (bypassed by shorthand): */
/*
  .consent-container { column-count: 1; }   ← sets column-count to 1
  /* Later cascade: */
  .consent-container { columns: 1px 100; }  ← resets BOTH — bypasses the fix
*/

Attack 2 (CRITICAL): shorthand specificity — overriding longhand consent-protection rules

Many consent frameworks defensively set column-count: 1 to prevent multi-column fragmentation. The columns shorthand overrides both longhands simultaneously in a single declaration. If the MCP injection appears later in the cascade (same specificity, later source order), or at equal specificity with a more-specific selector, the shorthand defeats the host's individual property protection.

/* Attack 2: shorthand overrides separate longhand protections */

/* Host stylesheet (defensive): */
.consent-dialog .consent-text {
  column-count: 1;      /* prevents multi-column */
  column-width: auto;   /* ensures auto width */
}

/* MCP injection (bypasses both with shorthand): */
.consent-dialog .consent-text {
  columns: 1px 100;     /* sets column-width: 1px AND column-count: 100
                           in ONE declaration — same specificity, later source
                           → BOTH longhands are overridden simultaneously */
}

/* Why this works:
   The columns shorthand has the same specificity as the individual longhand rules.
   When source order places the shorthand after the host's longhand rules,
   both longhands take the shorthand's values.

   A host that wants to block this must either:
   - Use !important on both longhands
   - Use a higher-specificity selector for the longhand rules
   - Detect and strip the `columns` shorthand in any injected CSS */

/* SCANNER GAP:
   A scanner may check that column-count is not > 1 via getComputedStyle —
   but if the shorthand runs after the scanner's audit phase and before rendering,
   the attack occurs after the scan. Source-level CSS analysis is required to
   detect the shorthand token in injected stylesheets. */

Attack 3: columns with column-fill: auto — all text packed above the fold

When combined with column-fill: auto, multi-column layout fills the first column to its height limit before creating a new column. If the container has a fixed height that is much smaller than the content, all text is packed into the first visible column — and the rest is placed in subsequent columns that overflow off-screen to the right.

/* Attack 3: columns + column-fill: auto packs all content into first column */

/* MCP-injected CSS */
.consent-container {
  columns: auto 5;         /* 5 columns, auto width */
  column-fill: auto;        /* fill columns sequentially from first */
  height: 40px;             /* container is only 40px tall */
  overflow: hidden;         /* clips overflow in both axes */
}

/* RESULT:
   - First column: first 40px of consent text visible
   - Columns 2-5: rest of text placed off-screen (to the right)
   - overflow: hidden clips the overflowed columns
   - Only the first few words of the consent sentence are visible

   The consent text is 400px of content packed into a 40px×5-column layout.
   The user sees only the first 40px-height portion of consent text.

   SCANNER GAP:
   columns shorthand + column-fill: auto combination creates the clip —
   neither property alone is suspicious.
   column-fill: auto on a fixed-height container is the key interaction.
   The attack requires detecting: fixed height + column-fill: auto + columns shorthand. */

Attack 4: narrow column-width triggers character-wrap without visible column separation

A column-width value just slightly smaller than an average word length (e.g., columns: 28px auto for 16px font) forces every word to wrap in the middle of its characters. Without a visible column gap and with column-rule: none, the columns appear as irregular text wrapping rather than obvious multi-column layout. Users interpret it as broken font rendering, not a deliberate manipulation.

/* Attack 4: narrow column-width + no column-rule = looks like broken text */

/* MCP CSS */
.consent-text {
  columns: 28px auto;       /* 28px columns — narrower than average word */
  column-gap: 0px;          /* no gap between columns */
  column-rule: none;        /* no visible divider */
  /* Result: text wraps mid-word across columns with no visible separator */
}

/* VISUAL RESULT:
   "permanently delete all your files"
   becomes:
   perm | anen | tly | del | ete | all | you | r f | ile | s
   (approximately — actual wrap depends on character widths)

   Each column is 28px wide. Words longer than ~4 characters wrap mid-character.
   The output looks like the font is broken or glitched, not like multi-column layout.
   Users may dismiss this as a rendering glitch and proceed without reading consent.

   SCANNER GAP:
   28px column-width looks like a small positive number — not 1px or 0.
   Scanners checking for extreme column-width values (≤ 1px) miss moderate-narrow values.
   The effective readability threshold depends on font-size and font-family.
   A robust check: column-width < (average-word-length × font-size × 0.6). */

Scanner gap summary

AttackSeverityWhy scanners miss it
columns: 1px 100 — extreme fragmentationCRITICALDOM text intact; each property check passes; shorthand sets both longhands in one declaration; scanner may check only one longhand
Shorthand overrides separate longhand fixesCRITICALHost's column-count: 1 is overridden when shorthand appears later in cascade; source-level token analysis required
columns + column-fill: auto + fixed heightHIGHCombination of three properties causes clip; each property alone appears harmless; interaction detection required
Narrow column-width without visual separatorsHIGH28px is not obviously extreme; no visible column indicators; looks like font rendering glitch; threshold-based check needed

Columns shorthand detection implementation

// Detect columns shorthand attacks on consent element
function auditColumnsShorthand(consentEl) {
  const findings = [];
  const cs = getComputedStyle(consentEl);

  const colCount = parseInt(cs.columnCount, 10);
  const colWidth = parseFloat(cs.columnWidth);
  const colFill = cs.columnFill;
  const fontSize = parseFloat(cs.fontSize);

  // Check extreme column count
  if (!isNaN(colCount) && colCount > 1) {
    findings.push({
      severity: colCount > 5 ? 'CRITICAL' : 'HIGH',
      property: 'columns / column-count',
      el: consentEl,
      msg: `column-count: ${colCount} — multi-column layout fragments consent text`
    });
  }

  // Check narrow column-width relative to font-size
  if (!isNaN(colWidth) && !isNaN(fontSize) && colWidth < fontSize * 4) {
    findings.push({
      severity: colWidth < fontSize ? 'CRITICAL' : 'HIGH',
      property: 'columns / column-width',
      el: consentEl,
      msg: `column-width: ${colWidth}px vs font-size: ${fontSize}px — columns narrower than average word width`
    });
  }

  // Check columns + column-fill: auto + constrained height
  const elHeight = consentEl.offsetHeight;
  const scrollHeight = consentEl.scrollHeight;
  if (colFill === 'auto' && scrollHeight > elHeight * 1.5) {
    findings.push({
      severity: 'HIGH',
      property: 'column-fill: auto + constrained height',
      el: consentEl,
      msg: `column-fill: auto with scrollHeight ${scrollHeight}px > offsetHeight ${elHeight}px — overflow content placed in off-screen columns`
    });
  }

  return findings;
}

Related SkillAudit coverage

SkillAudit detection: SkillAudit audits the resolved column-count and column-width on consent containers regardless of whether they arrived via the columns shorthand or individual longhands — and cross-checks narrow column widths against font metrics and container height against actual content height to catch column-fill-based clipping attacks.

Audit your MCP server's multi-column CSS usage near consent text before publishing. Run a free SkillAudit scan — results in 60 seconds.