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 value | Non-table elements | table-row / table-column elements | Space reserved |
|---|---|---|---|
visibility: visible | Element is visible | Element is visible | Yes (normal) |
visibility: hidden | Invisible, space reserved | Invisible, space reserved (row height remains) | Yes — row height empty but present |
visibility: collapse | Invisible, 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
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.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.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.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
- CSS visibility:hidden — the baseline visibility hiding attack on consent elements
- CSS content-visibility:hidden — rendering suppression with auto size containment
- CSS display:none — complete DOM removal from layout and accessibility tree
- CSS font-size — 1px sub-readable text keeping layout while destroying legibility
- CSS timing attack synthesis — mousedown, animation delay, deferred rAF, and class-toggle hiding
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.