Security reference · CSS injection · Visibility · Layout collapse · Consent hiding

MCP server CSS visibility: collapse security

CSS visibility: collapse is the third visibility keyword after visible and hidden, with a behavior specific to table elements. On a display: table-row element, collapse removes the row from the table layout entirely — no height is reserved, adjacent rows fill the gap, and the row is invisible. This is visually equivalent to display: none, but the element remains in the DOM and auditors checking for display: none or visibility: hidden will miss it. Four attack surfaces: table-row layout removal, non-table hidden-equivalent, inheritance difference from hidden, and JS-triggered collapse at install click time.

visibility:collapse vs visibility:hidden — the key behavioral difference

Property valueNon-table elementstable-row / table-column elementsSpace reserved
visibility: visibleElement is visibleElement is visibleYes (normal)
visibility: hiddenInvisible, space reservedInvisible, space reserved (row height remains)Yes — row height empty but present
visibility: collapseInvisible, space reserved (behaves like hidden)Invisible, space NOT reserved (row height = 0)No for table rows — equivalent to display:none

Browser variance for non-table elements: For elements that are not part of a table layout (display: block, flex, grid, inline), the CSS specification says visibility: collapse should behave like hidden. Firefox follows the spec precisely. Chrome (Blink) and Safari also treat collapse as hidden for non-table elements. The unique table-row behavior is consistent across all major browsers.

Attack 1: visibility:collapse on display:table-row — consent row removed from layout

An MCP consent dialog built with a table layout (display:table, display:table-row, display:table-cell) has a specific row containing the consent disclosure text. Setting visibility: collapse on that row removes it from the table layout: zero height, adjacent rows close the gap, and the element is invisible — while remaining in the DOM:

/* Malicious CSS — SA-CSS-VISCOL-001 */

/* MCP consent dialog using table layout */
.mcp-consent-dialog {
  display: table;
  width: 100%;
}

.mcp-consent-header-row { display: table-row; } /* "Install MCP Server" */
.mcp-consent-disclosure-row { display: table-row; } /* ← target */
.mcp-consent-actions-row  { display: table-row; } /* "Install" button row */

/* Attack: collapse the consent disclosure row */
.mcp-consent-disclosure-row {
  visibility: collapse;
  /* Effect:
     - The row is invisible (content not rendered)
     - The row occupies ZERO height in the table
     - The header row and actions row are adjacent with no gap
     - The DOM element is still present: el.parentElement.contains(el) → true
     - el.textContent → full consent text (unchanged)
     - getComputedStyle(el).display → "table-row" (not "none")
     - el.offsetHeight → 0 (differs from hidden, where offsetHeight = row height)
     - el.getBoundingClientRect() → {height: 0, ...} */
}

/* Why this evades common checks:
   Auditor check: el.style.display === 'none'              → false (display is "table-row")
   Auditor check: getComputedStyle(el).visibility === 'hidden' → false (it's "collapse")
   Auditor check: el.offsetHeight > 0                       → false (BUT: hidden-check expects true)
   Auditor check: el.textContent.length > 0                 → true  (DOM text is intact) */

/* Detection: check for visibility:collapse explicitly */
function detectVisibilityCollapse(el) {
  const vis = getComputedStyle(el).visibility;
  if (vis === 'collapse') {
    return { id: 'SA-CSS-VISCOL-001', severity: 'critical',
      message: `Consent element has visibility:collapse. For table-row elements this removes the row from layout entirely (no height reserved). Element: ${el.tagName}, display: ${getComputedStyle(el).display}, offsetHeight: ${el.offsetHeight}px.` };
  }
}

Attack 2: visibility:collapse on non-table elements — hidden-equivalent scanner bypass

On a display: block consent element, visibility: collapse behaves identically to visibility: hidden — the element is invisible but still occupies its normal space in layout. The attack advantage is purely scanner evasion: security auditors and automated scanners that check for visibility === 'hidden' as the invisible-check will miss visibility: collapse:

/* Malicious CSS — SA-CSS-VISCOL-002 */
.mcp-consent-disclosure {
  display: block;        /* non-table element */
  visibility: collapse;  /* behaves like hidden for non-table elements */

  /* What the scanner sees:
     getComputedStyle(el).visibility → "collapse" (NOT "hidden")
     el.offsetHeight → normal (space reserved, unlike table-row collapse)
     el.getBoundingClientRect().height → normal height (element takes up space)
     el.textContent → full consent text (unchanged)

     A scanner checking for visibility === 'hidden' returns false.
     A scanner checking for display === 'none' returns false.
     A scanner checking for opacity === '0' returns false.
     The element IS invisible (nothing rendered), but all common checks pass. */
}

/* Evasion is simple: rename the checked value.
   Attackers know that security auditors check for 'hidden' — using 'collapse'
   is a trivially different value that achieves the same visual result. */

/* Detection: check for ALL values that produce an invisible element */
const INVISIBLE_VISIBILITY = new Set(['hidden', 'collapse']);
function detectInvisibleVisibility(el) {
  const vis = getComputedStyle(el).visibility;
  if (INVISIBLE_VISIBILITY.has(vis)) {
    return { id: 'SA-CSS-VISCOL-002', severity: 'high',
      message: `Consent element has visibility:${vis}. Both 'hidden' and 'collapse' produce invisible elements. Scanners checking only for 'hidden' miss 'collapse'.` };
  }
}

Attack 3: inheritance difference — children with visibility:visible cannot override table collapse

A key behavioral difference between visibility: collapse and visibility: hidden in the table context is child override behavior. With visibility: hidden on a parent, a child element can restore its own visibility with visibility: visible. With visibility: collapse on a table row, this override does not work for the table-row slot itself — the row is still removed from layout even if a cell within it has visibility: visible:

/* Malicious CSS — SA-CSS-VISCOL-003 */

/* Setup: auditors may check for the child cell's visibility separately */
.mcp-consent-disclosure-row {
  display: table-row;
  visibility: collapse; /* row removed from table layout */
}

/* Attacker adds visible child to confuse element-level visibility checks */
.mcp-consent-disclosure-row .consent-cell {
  display: table-cell;
  visibility: visible; /* child is "visible" — but the row is still collapsed */
  /* The cell's visibility:visible overrides the parent's collapse for the cell's
     content rendering — but since the ROW is collapsed, the row height is 0
     and the cell is not positioned in the table.
     Result: cell is technically "visibility:visible" but has zero height and
     is not in the table layout. */
}

/* Attack effect:
   Auditor checks the cell (not the row): getComputedStyle(cell).visibility → "visible"
   Auditor concludes: consent is visible (WRONG — the row is collapsed)
   The cell has height 0 because it's in a collapsed row.

   The auditor must check the ENTIRE ANCESTOR CHAIN for visibility:collapse,
   not just the immediate consent element. */

/* Detection: walk the ancestor chain */
function detectAncestorCollapse(el) {
  let node = el.parentElement;
  while (node && node !== document.body) {
    const vis = getComputedStyle(node).visibility;
    if (vis === 'collapse') {
      return { id: 'SA-CSS-VISCOL-003', severity: 'high',
        message: `Consent element ancestor ${node.tagName}.${node.className} has visibility:collapse. The consent element itself may show visibility:visible, but the collapsed ancestor removes it from table layout. Ancestor offsetHeight: ${node.offsetHeight}px.` };
    }
    node = node.parentElement;
  }
}

Attack 4: JS-triggered collapse — visible at load, collapsed at install click

The consent table row renders normally at page load (visibility: visible). At install click or mousedown, JS adds a class that applies visibility: collapse, removing the consent row from the table layout in one frame — faster than a user can register the change:

/* Malicious CSS — SA-CSS-VISCOL-004 */
.mcp-consent-disclosure-row {
  display: table-row;
  visibility: visible; /* normal at load — audit PASSES */
}

.mcp-consent-disclosure-row.installing {
  visibility: collapse; /* collapse triggered at mousedown */
}

/* JS: */
document.querySelector('#mcp-install-btn').addEventListener('mousedown', () => {
  const row = document.querySelector('.mcp-consent-disclosure-row');
  row.classList.add('installing');
  /* Row removed from table layout: height collapses from (e.g.) 80px to 0px */
  /* Dialog height shrinks by 80px instantly — action buttons move up */
  /* User pressing install sees the consent text vanish and the button shift up */
});

/* From the dialog's perspective:
   Before mousedown: header-row [40px] + disclosure-row [80px] + actions-row [60px] = 180px
   After  mousedown: header-row [40px] +                         actions-row [60px] = 100px
   The dialog collapses from 180px to 100px — the button appears to "jump" up. */

/* Detection: MutationObserver on class list of table-row elements near consent text */
function watchConsentTableRows() {
  document.querySelectorAll('tr, [role=row], [style*="table-row"]').forEach(row => {
    if (row.textContent?.match(/consent|disclosure|terms|grant|permission/i)) {
      new MutationObserver(() => {
        if (getComputedStyle(row).visibility === 'collapse') {
          reportFinding({ id: 'SA-CSS-VISCOL-004', severity: 'critical',
            message: `Consent table-row visibility changed to 'collapse' during interaction. Row height collapsed from visible to 0. Load-time visibility was normal.` });
        }
      }).observe(row, { attributes: true, attributeFilter: ['class', 'style'] });
    }
  });
}

offsetHeight is the reliable collapse detector: For a display: table-row element with visibility: collapse, el.offsetHeight returns 0. For the same element with visibility: hidden, el.offsetHeight returns the row's height. Checking offsetHeight === 0 on a table-row that has non-empty textContent is a reliable signal — a non-empty row with zero height is either collapsed or display:none. Both indicate hidden consent.

SkillAudit findings for CSS visibility:collapse attacks

CriticalSA-CSS-VISCOL-001 — Consent-bearing table-row element has visibility: collapse. The row is removed from table layout (height = 0, space not reserved). Visually equivalent to display: none but scanners checking for "hidden" or "display:none" will miss it. DOM element and text are intact.
HighSA-CSS-VISCOL-002 — Consent-bearing non-table element has visibility: collapse. On non-table elements, collapse behaves like hidden. Scanner bypass: auditors checking for visibility === 'hidden' will not match. Element is invisible but space-reserving.
HighSA-CSS-VISCOL-003 — Consent element has visibility: visible but an ancestor table-row has visibility: collapse. Child visibility:visible does not restore layout for a collapsed table-row. Ancestor chain must be checked, not just the immediate element.
CriticalSA-CSS-VISCOL-004 — Consent table-row transitions from visibility: visible at page load to visibility: collapse at install-click time via class addition. Row is removed from layout at interaction time; load-time audit sees normal row height. MutationObserver on class/style attributes of table-row elements detects this at interaction time.

Related MCP consent attack research

SkillAudit checks getComputedStyle().visibility for all three values — hidden, collapse, and visible — on consent-bearing elements and their entire ancestor chain. Table-row elements with collapse are flagged at offsetHeight === 0. A MutationObserver monitors visibility changes through the install flow. Paste your MCP server URL at skillaudit.dev to scan for SA-CSS-VISCOL findings.