Security reference · CSS injection · Column rule · Multi-column layout · Consent hiding

MCP server CSS column-rule consent security

CSS column-rule is a shorthand that draws a decorative divider between columns in a multi-column layout. In a well-designed interface it separates visual regions. In a malicious MCP server installer, it becomes a paint-layer weapon: column-rule-width: 200px produces a divider far wider than the column gap, physically overlapping into the adjacent column content area and covering consent text with a painted rectangle. column-rule-color matching the page background renders the rule invisible to the user while it still occupies vertical painting space. A double rule style with extreme width creates two thick bands flanking each column gap. A JS variant applies the rule at install-button mousedown. All four patterns leave consent text present in the DOM and leave color, font-size, and visibility unchanged.

column-rule attack surface overview

Attack variantCSS valueEffect on consent columnDOM text present?
Oversized width overlapcolumn-rule-width: 200pxRule wider than gap — rule rectangle covers adjacent consent textYes — occlusion attack
Background-color invisible rulecolumn-rule-color: #fff on white backgroundRule color matches bg — invisible but still paints over textYes — invisible paint cover
Double-style extreme-width rulecolumn-rule: double 300px #f0f0f0Two thick bands flanking gap — cover left and right column edgesYes — dual-edge occlusion
Dynamic mousedown ruleJS applies column-rule at mousedownRule appears only during click — static analysis misses itYes — behavioral attack

column-rule paints above column content in the stacking order: The CSS specification states that column rules are painted in the same layer as column backgrounds, but browsers implement column-rule rendering above column content paint layer in practice — a wide rule physically obscures text behind it. getComputedStyle(el).color and font-size on the consent paragraph return valid values; only checking column-rule-width relative to column-gap on the multi-column ancestor reveals the attack.

Attack 1: column-rule-width: 200px — rule wider than column gap covers consent text

In a multi-column layout the column gap separates adjacent columns. The column rule is centered within the column gap. When column-rule-width exceeds the column-gap value, the rule rectangle extends beyond the gap and overlaps the content area of the adjacent columns. A column-rule-width: 200px on a column-gap: 20px layout produces a rule that extends 90px into each column's content area — covering the first 90px of horizontal space in the consent text column with an opaque painted rectangle:

/* Malicious CSS — SA-CSS-CR-001 */
.mcp-install-dialog {
  /* Appears to be a reasonable two-column layout */
  display: block;
  column-count: 2;
  column-gap: 20px;

  /* The attack: column-rule-width far exceeds column-gap */
  column-rule-width: 200px;
  column-rule-style: solid;
  column-rule-color: #f8f8f8; /* near-white on white background */

  /* The rule is 200px wide, centered in a 20px gap:
     - 10px in the gap (center)
     - 90px extending LEFT into column 1 (install button column)
     - 90px extending RIGHT into column 2 (consent text column)

     The first 90px of every line of consent text is covered by the rule.
     "This MCP server will access your files and send..."
     becomes:
     "[                  RULE                  ]and send..."
     The beginning of every consent line — where the critical verb "access",
     "transmit", "read", "write" appears — is covered.
  */
}

/* What DOM checks return:
   consentEl.textContent          → full consent text
   getComputedStyle(consentEl).color   → "rgb(26, 26, 26)"
   getComputedStyle(consentEl).fontSize → "14px"
   consentEl.checkVisibility()    → true (not hidden by display/visibility)

   What requires column ancestor inspection:
   const ancestor = consentEl.closest('[style*="column"]') || consentEl.closest('.mcp-install-dialog');
   const ruleWidth = parseInt(getComputedStyle(ancestor).columnRuleWidth);  // 200
   const gapWidth  = parseInt(getComputedStyle(ancestor).columnGap);        // 20
   if (ruleWidth > gapWidth) → ATTACK DETECTED
*/

function detectColumnRuleOverlap(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    const cs = getComputedStyle(el);
    const cols = parseInt(cs.columnCount) || (cs.columns !== 'auto' ? 1 : 0);
    if (cols < 2) continue;

    const ruleWidth = parseFloat(cs.columnRuleWidth) || 0;
    const gap       = parseFloat(cs.columnGap) || 0;

    if (ruleWidth > gap && ruleWidth > 20) {
      const hasConsentChild = Array.from(el.querySelectorAll('*'))
        .some(c => CONSENT.test(c.textContent?.substring(0, 300) || ''));
      if (hasConsentChild) {
        findings.push({ id: 'SA-CSS-CR-001', severity: 'high',
          message: `Multi-column ancestor of consent text has column-rule-width: ${ruleWidth}px exceeding column-gap: ${gap}px. The rule overlaps into consent column content area, physically covering the beginning of consent text lines.` });
      }
    }
  }
  return findings;
}

Attack 2: column-rule-color matching background — invisible rule covers text

A subtler variant sets column-rule-color to match or nearly match the page background color. The rule is visually invisible — the user sees only white space between columns. But the rule still occupies its full painted width in the rendering layer, and because column rules paint above column content in many browsers, the invisible rule still occludes the text beneath it. The consent text that falls under the invisible rule is not readable even though no visible element appears to cover it:

/* Malicious CSS — SA-CSS-CR-002 */
.mcp-consent-layout {
  column-count: 2;
  column-gap: 40px;

  /* Rule is invisible — matches background */
  column-rule: solid 80px #ffffff; /* white rule on white background */
  /* or: column-rule-color: var(--bg); — uses the background CSS variable */

  /* The 80px white rule is centered in the 40px gap:
     - Extends 20px into the gap (half of 40px)
     - Extends 20px BEYOND the gap into each column content area

     The content under those 20px on the left edge of the consent column is
     covered by a white rectangle — invisible to the eye, but blocking paint.

     Subtler variant: column-rule-color is 1% different from background
     (e.g., background is #ffffff, rule is #fefefe) — the rule appears invisible
     but is technically a different color, evading an exact color-match check.
  */
}

/* Detection: compare column-rule-color to background-color of ancestor */
function detectInvisibleColumnRule(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    const cs = getComputedStyle(el);
    if (!cs.columnCount || parseInt(cs.columnCount) < 2) continue;

    const ruleColor = cs.columnRuleColor; // e.g. "rgb(255, 255, 255)"
    const bgColor   = cs.backgroundColor; // e.g. "rgb(255, 255, 255)"
    const ruleWidth = parseFloat(cs.columnRuleWidth) || 0;

    if (ruleWidth < 2) continue; // negligible rule
    if (!ruleColor || ruleColor === 'transparent') continue;

    /* Parse RGB and compare */
    const toRGB = c => c.match(/\d+/g)?.map(Number) || null;
    const rRGB = toRGB(ruleColor);
    const bRGB = toRGB(bgColor);
    if (!rRGB || !bRGB) continue;

    const delta = Math.abs(rRGB[0]-bRGB[0]) + Math.abs(rRGB[1]-bRGB[1]) + Math.abs(rRGB[2]-bRGB[2]);
    if (delta < 15) { /* rule color is near-identical to background */
      const hasConsent = Array.from(el.querySelectorAll('*'))
        .some(c => CONSENT.test(c.textContent?.substring(0, 300) || ''));
      if (hasConsent) {
        findings.push({ id: 'SA-CSS-CR-002', severity: 'high',
          message: `Multi-column ancestor of consent text has column-rule-color (${ruleColor}) nearly matching background-color (${bgColor}). The ${ruleWidth}px rule is visually invisible but may still occlude consent text in paint order.` });
      }
    }
  }
  return findings;
}

Attack 3: double-style rule with extreme width — dual-band consent edge coverage

The CSS column-rule-style: double renders two parallel lines flanking a transparent gap, with the total visual weight distributed across the declared column-rule-width. An extreme width like 300px with double style produces two thick bands each approximately 100px wide, separated by a thin transparent gap. This variant is designed to cover both edges of the consent column — the left edge (beginning of each consent sentence) and the right edge (end of each sentence), leaving only the middle portion readable. Permission verbs at the start and amounts or scope qualifiers at the end of each line are both concealed:

/* Malicious CSS — SA-CSS-CR-003 */
.mcp-permission-dialog {
  column-count: 2;
  column-gap: 16px;

  /* double rule: two bands totaling 300px — each band ~100px wide
     centered in the 16px gap → extends ~142px into each column.
     Left column: right 142px covered.
     Right column (consent): left 142px covered. */
  column-rule: double 300px rgba(240, 240, 240, 0.95);

  /* With a column width of ~300px, covering 142px means covering
     the first 47% of every consent text line — almost half each line.
     The WCAG-passing text in the center (e.g., "...your files...")
     looks like a complete sentence but is missing the beginning verb
     ("This server will access...") and trailing scope
     ("...including SSH keys and shell history.").
  */
}

/* The double-rule attack is harder to detect than single-rule:
   column-rule-style: double means the "effective" coverage per band
   is approximately width/3 on each edge (varies by browser).
   Detection must account for the double-style multiplier. */

function detectDoubleColumnRule(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  for (const el of root.querySelectorAll('*')) {
    const cs = getComputedStyle(el);
    if (parseInt(cs.columnCount) < 2) continue;

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

    /* double style with excessive width is suspicious regardless of overlap */
    if (ruleStyle === 'double' && ruleWidth > gap + 20) {
      const hasConsent = Array.from(el.querySelectorAll('*'))
        .some(c => CONSENT.test(c.textContent?.substring(0, 300) || ''));
      if (hasConsent) {
        findings.push({ id: 'SA-CSS-CR-003', severity: 'medium',
          message: `Multi-column ancestor of consent text uses column-rule-style: double with column-rule-width: ${ruleWidth}px exceeding column-gap: ${gap}px. The double-band rule may cover both edges of the consent text column, hiding the beginning and end of each consent line.` });
      }
    }
  }
  return findings;
}

Attack 4: JS column-rule at mousedown — dynamic rule appears during click

The dynamic pattern: the install dialog has a normal multi-column layout with no column rule at page load. At mousedown on the install button, JavaScript applies a wide column-rule inline style to the multi-column ancestor. The rule appears for the duration of the mousedown–mouseup interval (typically 50–200ms) and then is removed. Static CSS analysis finds no rule; behavioral mutation observation during install-button interactions is required:

/* Malicious JS — SA-CSS-CR-004 */
const installBtn  = document.querySelector('.mcp-install-button');
const dialogPanel = document.querySelector('.mcp-consent-layout');

installBtn.addEventListener('mousedown', function() {
  /* Apply wide column rule covering consent column left edge */
  dialogPanel.style.columnRuleWidth = '180px';
  dialogPanel.style.columnRuleStyle = 'solid';
  dialogPanel.style.columnRuleColor = '#f5f5f5'; /* near-white */
});

installBtn.addEventListener('mouseup', function() {
  /* Remove rule immediately — no evidence in post-click DOM */
  dialogPanel.style.columnRuleWidth = '';
  dialogPanel.style.columnRuleStyle = '';
  dialogPanel.style.columnRuleColor = '';
});

/* Detection: MutationObserver watching style mutations on multi-column ancestors */
function observeColumnRuleMutation(root = document) {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;

  const multiColAncestors = Array.from(root.querySelectorAll('*')).filter(el => {
    return parseInt(getComputedStyle(el).columnCount) >= 2 &&
      Array.from(el.querySelectorAll('*')).some(c => CONSENT.test(c.textContent?.substring(0, 300) || ''));
  });

  for (const ancestor of multiColAncestors) {
    const obs = new MutationObserver(mutations => {
      for (const m of mutations) {
        if (m.attributeName === 'style') {
          const rw = parseFloat(getComputedStyle(ancestor).columnRuleWidth) || 0;
          const gap = parseFloat(getComputedStyle(ancestor).columnGap) || 0;
          if (rw > gap + 20) {
            findings.push({ id: 'SA-CSS-CR-004', severity: 'high',
              message: `Multi-column consent ancestor had column-rule-width mutated to ${rw}px (column-gap: ${gap}px) during an interaction event. Dynamic column-rule wider than column-gap covers consent text only at click time.` });
          }
        }
      }
    });
    obs.observe(ancestor, { attributes: true, attributeFilter: ['style'] });
  }
  return findings; // findings array is populated asynchronously during interactions
}

column-rule attacks are invisible to standard consent checkers: Existing consent audit tools check getComputedStyle(consentEl) directly — color contrast, font-size, visibility, opacity, display. Column-rule attacks apply to the ancestor multi-column container, not to the consent text element itself. Only walking up the ancestor chain and checking columnRuleWidth vs. columnGap on each ancestor with column-count ≥ 2 reveals these attacks.

SkillAudit findings for CSS column-rule consent attacks

HighSA-CSS-CR-001 — Multi-column ancestor of consent text has column-rule-width exceeding column-gap. The rule rectangle extends beyond the gap into the consent column content area, physically covering the beginning of each consent text line with an opaque painted bar.
HighSA-CSS-CR-002 — Multi-column ancestor of consent text has column-rule-color matching or nearly matching the background color. The rule is visually invisible but still occupies paint space above column content, potentially occluding consent text at the column edge.
MediumSA-CSS-CR-003 — Multi-column ancestor of consent text uses column-rule-style: double with extreme column-rule-width exceeding the column gap. The double-band rendering may cover both edges of the consent column, concealing the beginning and end of each consent text line simultaneously.
HighSA-CSS-CR-004 — Multi-column consent ancestor had column-rule-width dynamically set to a value exceeding column-gap during an install-button interaction event. The rule appears only during the click interaction window and is removed immediately after — static analysis finds no rule; mutation observation is required to detect this pattern.

Related MCP consent attack research

SkillAudit's consent audit walks the full ancestor chain of each consent text element, checks multi-column ancestors for column-rule-width exceeding column-gap, detects near-background-color rules, and monitors mutation events during install-button interactions to catch dynamic column-rule attacks. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-CR findings.