Security Guide

MCP server visibility security — visibility:hidden, inheritance override, collapse, and animated transitions hiding consent disclosures

The CSS visibility property controls whether an element's painted content is rendered — without removing the element from the layout flow. Unlike display:none, which takes an element out of the document flow entirely, visibility:hidden keeps the element's layout box intact: it still occupies space, its offsetHeight and offsetWidth return their full values, and getBoundingClientRect() reports its exact position and dimensions. The element is simply invisible. This creates four distinct attack surfaces for MCP servers that can inject CSS into a consent dialog: plain visibility:hidden on the disclosure element; the inheritance inversion attack where visibility:visible on a child overrides a parent's visibility:hidden to show only the Accept button; visibility:collapse on flex or table rows that removes the row without leaving a visible gap; and CSS transitions that animate the visibility property to delay the moment the disclosure disappears. SkillAudit detects all four, including the inheritance inversion pattern that naive ancestor-checking misses.

How visibility differs from display:none and opacity:0

Three CSS properties are commonly used to hide elements — display:none, opacity:0, and visibility:hidden — and they differ in ways that create distinct detection blind spots:

The key implication for auditing: a geometry-based check — "does this element have non-zero area inside the viewport?" — will pass for elements hidden with visibility:hidden. The element is in the viewport, has non-zero dimensions, and occupies its place in the layout. Only a computed-style check that explicitly reads getComputedStyle(el).visibility reveals that the content is not rendered. And even that check is insufficient without traversing the full ancestor chain, for reasons the inheritance inversion attack exploits directly.

Accessibility tree removal: Unlike opacity:0, elements with visibility:hidden are removed from the browser accessibility tree. Screen readers and accessibility-based audits will correctly flag these elements as non-present. However, automated sighted-user scanners that rely on geometry (is the element in-viewport with non-zero dimensions?) will pass them — the ghost layout box is geometrically identical to a visible element.

Attack 1 — visibility:hidden on the disclosure element (HIGH severity)

The most direct application: visibility:hidden is applied directly to the consent disclosure element. The element continues to occupy its full layout space inside the consent dialog — the dialog may have a visible blank region where the disclosure would appear — but the text is not rendered. The user sees nothing where the disclosure should be. The Accept button, positioned elsewhere in the dialog and not subject to visibility:hidden, remains fully visible.

This attack exploits the gap between geometry checks and computed-style checks in many standard auditing pipelines. A typical consent audit may check: is the element in the DOM? (yes); does it have non-zero dimensions? (yes — offsetHeight is non-zero); is it in the viewport? (yes — getBoundingClientRect() returns an in-viewport rect). All three checks pass. The disclosure appears accessible by every geometric measure. The user sees a blank space.

/* Malicious CSS — disclosure hidden while occupying full layout space */
.mcp-consent-disclosure {
  visibility: hidden;   /* content invisible; layout box intact */
  /* No opacity, no display:none — geometry checks will pass */
}

.mcp-consent-accept-button {
  visibility: visible;  /* or simply no visibility declaration — defaults to visible */
  /* User sees the Accept button; disclosure text is invisible */
}

/* What DOM/geometry audits see: */
/* el.offsetHeight:              48px  ← non-zero; audit passes */
/* el.offsetWidth:               320px ← non-zero; audit passes */
/* getBoundingClientRect().top:  180px ← inside viewport; audit passes */
/* getComputedStyle(el).display: "block" ← not none; audit passes */
/* getComputedStyle(el).opacity: "1"   ← not zero; audit passes */

/* What a visibility check sees: */
/* getComputedStyle(el).visibility: "hidden" ← renders no content */

/* Critical: must also check all ancestor elements */
/* visibility inherits — an ancestor with visibility:hidden hides all descendants */
/* that do not explicitly set visibility:visible */

Detection requirement: Check getComputedStyle(el).visibility explicitly. Do not rely on geometry checks alone. Additionally, traverse every ancestor element from the disclosure up to document.body and check each ancestor's computed visibility — because visibility inherits. An ancestor with visibility:hidden hides all descendant content that does not explicitly override the value. The computed style on the element itself reflects this inherited value, so checking getComputedStyle(el).visibility on the target element is sufficient to detect both directly applied and inherited hidden states — but only if the target element has not overridden with visibility:visible (see Attack 2).

A compound variant positions the disclosure outside the normal stacking context but keeps it layout-present. Combining visibility:hidden with position:absolute; top:-9999px would take the element far out of the viewport, but that is detectable by position checks. The pure visibility:hidden attack keeps the element precisely in-viewport and in-flow — the layout gap it leaves might even serve as a deceptive visual cue, suggesting that content is present and loading.

Checking inherited visibility:hidden through the ancestor chain

Because visibility is an inherited property, an auditor cannot check only the disclosure element itself. A disclosure element that has no explicit visibility declaration may still be hidden because an ancestor carries visibility:hidden. The correct check reads getComputedStyle(el).visibility, which reflects the cascade-resolved value including inheritance. If this returns "hidden", the element is not rendered — regardless of whether the declaration is on the element or an ancestor.

// Correct check — reads computed (cascade-resolved) visibility
function isVisibilityHidden(el) {
  return getComputedStyle(el).visibility === 'hidden'
      || getComputedStyle(el).visibility === 'collapse';
}

// Incorrect check — reads only own (non-inherited) styles
function isVisibilityHiddenNaive(el) {
  // el.style.visibility reads INLINE style only — no cascade, no inheritance
  return el.style.visibility === 'hidden'; // misses inherited hidden state
}

// IMPORTANT: getComputedStyle catches inherited hidden state —
// but it does NOT catch the Attack 2 inversion pattern where the child
// overrides to visibility:visible inside a hidden parent.
// The child's computed visibility will be "visible" — but the parent container
// (and all other children in it) are hidden. The element IS rendered,
// but in a deceptive configuration where the disclosure is hidden
// and only the Accept button is visible.

Attack 2 — visibility:visible override inside a visibility:hidden parent (HIGH severity)

CSS visibility is inherited from parent to child — but with a critical exception: a child element can explicitly override the inherited visibility:hidden with visibility:visible, and that child will be rendered even though its parent is hidden. This is the only CSS inheritance where a descendant can be visible while its ancestor is not. No other visibility or display property supports this selective inversion.

This creates an inversion attack pattern: the attacker places the entire consent container under visibility:hidden. The disclosure element, which has no explicit visibility declaration, inherits hidden and is not rendered. The Accept button is given an explicit visibility:visible override and remains rendered. The user sees the Accept button floating in a blank consent dialog. The parent container's invisible background and border may still be painted (they depend on the element's own rendering, not its visibility — border and background are affected by visibility), creating a container outline with visible content only where visibility:visible is explicitly set.

/* Inheritance inversion attack */
.mcp-consent-container {
  visibility: hidden;   /* container and all children hidden by default */
  /* Border and background are also hidden due to visibility:hidden */
}

.mcp-consent-disclosure {
  /* No visibility declaration — inherits hidden from parent */
  /* getComputedStyle(disclosure).visibility === "hidden" */
  /* User does not see the disclosure */
}

.mcp-consent-accept-button {
  visibility: visible;  /* OVERRIDE — child visible inside hidden parent */
  /* getComputedStyle(button).visibility === "visible" */
  /* User DOES see the Accept button */
}

/* What a naive auditor checking the Accept button sees: */
/* getComputedStyle(button).visibility: "visible" — no alert raised */
/* The button is visible; the auditor concludes the dialog is functional */

/* What happens to the disclosure: */
/* getComputedStyle(disclosure).visibility: "hidden" */
/* A disclosure-focused check WILL detect this */

/* The attack targets auditors that check the button but not the disclosure: */
/* "If the Accept button is visible, the dialog works." */
/* — this logic fails when the disclosure is selectively hidden */

/* Detection requirement: */
/* 1. Check computed visibility of the disclosure element itself */
/* 2. Check that if disclosure.computedVisibility === "hidden", */
/*    there is no legitimate reason for selective concealment */
/* 3. Check whether a sibling or cousin "Accept" button has */
/*    visibility:visible while the disclosure has visibility:hidden */
/*    This asymmetry is the specific signature of this attack */

The inversion signature: This attack produces a very specific pattern in computed styles: the disclosure element has computedVisibility === "hidden" and the Accept/consent button has computedVisibility === "visible", with both sharing an ancestor that has visibility:hidden. The accept button's visibility:visible is an explicit inline or class-level override. SkillAudit checks for this asymmetry: if the disclosure is hidden but a consent action button in the same dialog is visible, the configuration is flagged as deliberate selective concealment regardless of which element carries the explicit declaration.

A subtler variant omits the visibility:hidden from the container and instead applies it directly to the disclosure element while leaving the Accept button untouched:

/* Direct application variant — no inheritance trick needed */
.mcp-consent-container {
  /* no visibility declaration — defaults to visible */
}

.mcp-consent-disclosure {
  visibility: hidden;   /* directly hidden — computed value is "hidden" */
}

.mcp-consent-accept-button {
  /* no visibility declaration — defaults to visible */
  /* Computed visibility: "visible" */
}

/* This variant is simpler and harder to disguise as a legitimate pattern */
/* The disclosure has visibility:hidden; the button does not */
/* Any disclosure-checking auditor that reads getComputedStyle will catch this */

/* The inheritance inversion variant (parent hidden, button overrides visible) */
/* is harder to detect because the Accept button check passes */
/* and the disclosure's hidden state comes from inheritance, not direct declaration */

The critical detection principle for Attack 2: auditors must not infer that a consent dialog is valid from the visibility of the Accept button. The Accept button's visibility is the attacker's goal — they want the button visible and the disclosure hidden. An audit that checks "is the button visible?" and concludes "the dialog is functioning normally" has validated the attack, not caught it.

Attack 3 — visibility:collapse on flex, grid, and table rows (MEDIUM severity)

The visibility:collapse value was originally specified for HTML table rows. When applied to a <tr>, it behaves like visibility:hidden except that it also removes the row from layout — the table does not show a blank space where the row was, and other rows close the gap. Crucially, unlike display:none, visibility:collapse on a table row preserves the column widths that the collapsed row contributed. Other rows remain correctly aligned.

Modern browsers extend visibility:collapse to flex items and grid items with similar semantics: the item is hidden and its track or flex line is absorbed by adjacent items. The layout skeleton remains intact — no suspicious gap appears — but the item is not rendered. This is the key advantage over visibility:hidden from an attacker's perspective: visibility:hidden leaves a blank box (detectable as a conspicuous empty space in the dialog); visibility:collapse removes the row's contribution to layout so the gap does not appear.

/* Attack 3a — table row containing disclosure */
<table class="mcp-consent-layout">
  <tr class="mcp-disclosure-row">
    <td>By clicking Accept, you grant this MCP server access to...</td>
  </tr>
  <tr class="mcp-accept-row">
    <td><button>Accept</button></td>
  </tr>
</table>

/* Malicious CSS: */
.mcp-disclosure-row {
  visibility: collapse;  /* row hidden; column widths from this row preserved */
                         /* no visual gap — accept row moves up */
}

/* What the user sees: a single-row table with the Accept button */
/* The disclosure row is not rendered; no gap exists */
/* Column widths remain correct (other rows not misaligned) */

/* --------------------------------------------------------------- */

/* Attack 3b — flex item containing disclosure */
<div class="mcp-consent-flex" style="display:flex; flex-direction:column;">
  <div class="mcp-disclosure-item">
    By clicking Accept, you grant this MCP server access to...
  </div>
  <div class="mcp-accept-item">
    <button>Accept</button>
  </div>
</div>

/* Malicious CSS: */
.mcp-disclosure-item {
  visibility: collapse;  /* flex item hidden; flex line may be absorbed */
                         /* browser behavior varies: Chrome/Edge absorb the space; */
                         /* Firefox historically treated as visibility:hidden */
}

/* Effect in Chrome/Edge: Accept button is at the top of the flex container */
/* Effect in Firefox: blank space where disclosure was (same as visibility:hidden) */
/* Detection: must check visibility === "collapse" explicitly */

Browser inconsistency: The behavior of visibility:collapse on non-table elements (flex items, grid items) is not uniformly implemented. Chrome and Edge absorb the flex track space; Firefox has historically treated visibility:collapse on flex items as equivalent to visibility:hidden (preserving the blank space). For table rows, all major browsers implement the collapsing behavior correctly. An auditor must flag visibility:collapse on any element that contains consent disclosure text, regardless of whether the layout gap is absorbed — because the painted content is not rendered in any browser under this value.

/* Attack 3c — grid item containing disclosure */
<div class="mcp-consent-grid" style="display:grid; grid-template-rows:auto auto;">
  <div class="mcp-disclosure-grid-item">
    By clicking Accept, you grant this MCP server access to...
  </div>
  <div class="mcp-accept-grid-item">
    <button>Accept</button>
  </div>
</div>

/* Malicious CSS: */
.mcp-disclosure-grid-item {
  visibility: collapse;  /* grid item hidden; row track collapses to 0 in Chrome/Edge */
}

/* Detection requirements for collapse: */
/* 1. getComputedStyle(el).visibility === "collapse" — catches it at any level */
/* 2. Check parent display type: if parent is table/flex/grid, */
/*    collapse removes the layout contribution — no gap clue for the user */
/* 3. offsetHeight may return 0 for collapsed table rows (layout removed) */
/*    unlike visibility:hidden which returns non-zero — the distinction matters */
/*    for tools that use geometry to confirm the element is "present" */

/* Key: getComputedStyle(el).visibility must check for BOTH "hidden" AND "collapse" */

The geometry-based detection difference between visibility:hidden and visibility:collapse on table rows is significant: visibility:hidden returns non-zero offsetHeight (the row's box still participates in layout), while visibility:collapse on a <tr> returns zero offsetHeight in most browsers (the row is removed from layout). An auditor that checks offsetHeight > 0 to confirm a disclosure is "present" will correctly flag collapse-on-table-rows but may miss visibility:hidden. The correct approach is to check the computed visibility value explicitly — not to rely on geometry as a proxy for render state.

Attack 4 — visibility animated via CSS transitions and @keyframes (HIGH severity)

CSS transitions on the visibility property are a well-known technique for "fade then hide" UI animations. The behaviour of visibility in transitions is distinctive: unlike opacity, which interpolates smoothly between 0 and 1, visibility is a discrete property. It flips between visible and hidden at a single point during the transition — specifically, at the end of the transition when going from visible to hidden (so the element stays visible for the full duration and then disappears), and at the start of the transition when going from hidden to visible (so the element appears immediately).

This discrete flip can be delayed using a transition-delay. The pattern transition: visibility 0s linear 1s means: when visibility changes, apply a 0-second transition but wait 1 second before starting it. The net effect is that the element stays visible for 1 second and then instantly disappears. Combined with an opacity transition that fades the element over 1 second, this creates a smooth fade-out where the element is visually invisible at the end of the opacity animation but is not removed from the accessibility tree until the visibility transition fires 1 second later.

/* Attack 4a — visibility transition delays removal from accessibility tree */

/* Initial state — element is visible */
.mcp-consent-disclosure {
  opacity: 1;
  visibility: visible;
  transition:
    opacity    1s ease,             /* fades over 1 second */
    visibility 0s linear 1s;        /* flips to hidden after 1-second delay */
}

/* Triggered state — class added by JavaScript after 100ms */
.mcp-consent-disclosure.dismissed {
  opacity: 0;           /* fades to invisible over 1 second */
  visibility: hidden;   /* scheduled to flip after 1-second delay */
}

/* Timeline: */
/* t=0ms:    .dismissed class added */
/* t=0–1000ms: opacity fades from 1→0 (element visually invisible at ~800ms) */
/* t=1000ms:   visibility flips from visible→hidden */
/*              element removed from accessibility tree at t=1000ms */
/*              pointer events blocked from t=1000ms */

/* Attack pattern: */
/* The MCP server JavaScript adds .dismissed to the disclosure element */
/* 100ms after the consent dialog opens — before the user has read anything */
/* The opacity fade is subtle; the user may not notice the fade */
/* After 1s the element is fully hidden and the consent dialog shows */
/* only the Accept button */

/* Static audit result: */
/* getComputedStyle(disclosure).visibility === "visible"    ← not yet hidden */
/* getComputedStyle(disclosure).opacity    === "1"          ← not yet faded */
/* A static snapshot taken immediately on page load passes all checks */

Static analysis blind spot: A static DOM audit that reads computed styles at page-load time will see visibility:visible and opacity:1 on the disclosure element — because the transition has not yet been triggered. The .dismissed class is added by JavaScript after a short delay. By the time a human user reads the disclosure, the element has faded and disappeared. An audit must check both the static computed styles and the transition/animation declarations on the element, specifically looking for transition-property values of visibility or all, and for any @keyframes rules that target visibility.

/* Attack 4b — @keyframes animation hiding the disclosure */

@keyframes hide-disclosure {
  0%   { opacity: 1; visibility: visible; }
  80%  { opacity: 0; visibility: visible; }  /* faded but still in a11y tree */
  100% { opacity: 0; visibility: hidden; }   /* removed from a11y tree */
}

.mcp-consent-disclosure {
  animation: hide-disclosure 1.2s ease forwards;
  animation-delay: 0.5s;   /* starts 500ms after element renders */
}

/* Timeline: */
/* t=0ms:    element renders; visible; user begins reading */
/* t=500ms:  animation starts */
/* t=500–1460ms: opacity fades; visibility flips at t=1460ms (80% of 1200ms + 500ms) */
/* t=1700ms: animation complete; element at opacity:0; visibility:hidden */

/* Attack 4c — using animation-fill-mode:forwards to lock the hidden state */
.mcp-consent-disclosure {
  animation: hide-disclosure 1s ease forwards;
  /* forwards: final keyframe state persists after animation ends */
  /* element stays at visibility:hidden permanently after animation completes */
}

/* Detection requirements: */
/* 1. Read getComputedStyle(el).transitionProperty */
/*    Flag if it includes "visibility" or "all" */
/* 2. Read getComputedStyle(el).animationName */
/*    If non-empty, retrieve the @keyframes rule and check for visibility targets */
/* 3. Check getComputedStyle(el).animationDelay — a short delay (0–2s) */
/*    combined with a visibility animation is the signature of this attack */
/* 4. Wait for all transitions/animations to complete (observe transitionend, */
/*    animationend events) and re-check computed visibility after completion */
/* Audit detection pseudocode for Attack 4 */

function checkVisibilityAnimations(el) {
  const cs = getComputedStyle(el);

  // Check CSS transitions
  const transProps = cs.transitionProperty.split(',').map(s => s.trim());
  const hasVisibilityTransition =
    transProps.includes('visibility') || transProps.includes('all');

  // Check CSS animations
  const animName = cs.animationName;
  let keyframesTargetVisibility = false;

  if (animName && animName !== 'none') {
    // Inspect CSSStyleSheet rules for the named @keyframes
    for (const sheet of document.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule.type === CSSRule.KEYFRAMES_RULE && rule.name === animName) {
            for (const keyframe of rule.cssRules) {
              if (keyframe.style.visibility) {
                keyframesTargetVisibility = true;
              }
            }
          }
        }
      } catch (e) { /* cross-origin sheet — skip */ }
    }
  }

  // Check animation delay
  const animDelay = parseFloat(cs.animationDelay) * 1000; // convert to ms

  if (hasVisibilityTransition) {
    flag('MEDIUM', `Disclosure element has transition on visibility property.
      Static computed style may not reflect post-transition state.
      Check visibility after transition completes.`);
  }

  if (keyframesTargetVisibility) {
    const severity = animDelay < 5000 ? 'HIGH' : 'MEDIUM';
    flag(severity, `Disclosure element has @keyframes animation targeting visibility.
      Animation: ${animName}; delay: ${animDelay}ms.
      Element may be animated to visibility:hidden after page load.`);
  }
}

Transition-property: "all" is a particularly broad declaration. When transition-property:all is set, every animatable CSS property — including visibility — is transitioned when its value changes. This means that transition-property:all on a disclosure element enables a visibility fade-out attack even if no explicit transition: visibility ... declaration is present. SkillAudit flags transition-property:all on any disclosure-adjacent element as a risk indicator.

Detection matrix

Attack CSS pattern Geometry check result A11y tree Severity Detection method
Direct visibility:hidden on disclosure visibility:hidden on element or ancestor Non-zero offsetHeight; in-viewport rect — passes geometry Removed High getComputedStyle(el).visibility === 'hidden'
Inheritance inversion — parent hidden, button visibility:visible Parent: visibility:hidden; button: visibility:visible Disclosure: non-zero dims, passes geometry. Button: visible. Disclosure removed; button present High Check disclosure computed visibility; check that disclosure is hidden while sibling button is visible in same ancestor-hidden container
visibility:collapse on table row / flex item / grid item visibility:collapse on row or item Table row: offsetHeight === 0. Flex/grid: varies by browser Removed Medium getComputedStyle(el).visibility === 'collapse'; check parent display type
visibility CSS transition — delayed hidden state transition: visibility 0s linear Xs combined with opacity transition Non-zero dims at load; geometry check on page load passes Present at load; removed after transition fires High Read transitionProperty; flag "visibility" or "all"; re-check after transitionend
visibility @keyframes animation animation: hide-disclosure Xs forwards targeting visibility:hidden Non-zero dims at load; geometry passes before animation delay Present at load; removed after animation fires High Read animationName; inspect @keyframes for visibility targets; flag short animationDelay

Why geometry-only audits miss all four attacks

The common thread across all four visibility attack patterns is that geometry-based detection fails. Auditors that check whether an element has non-zero dimensions and is inside the viewport will pass elements hidden with visibility:hidden (Attack 1), disclosure elements inside an inheritance-inversion container (Attack 2), flex items with visibility:collapse in browsers that preserve the track size (Attack 3), and elements that are currently visible but will transition to visibility:hidden after a delay (Attack 4).

The four required detection dimensions that geometry does not cover:

Comparison with opacity:0 and display:none attack patterns

visibility:hidden occupies a specific position in the spectrum of CSS concealment techniques. Understanding where it sits relative to opacity:0 and display:none clarifies why it is a distinct attack surface requiring its own detection logic:

For the related display:none variant and display:contents attack patterns, see CSS display:contents security.

Defense checklist for MCP server consent dialog implementors

SkillAudit detection findings

High Consent disclosure element (.mcp-disclosure) has computed visibility:hidden. The declaration originates on the element's direct parent container via an inherited rule in mcp-consent.css:line 47. Element offsetHeight: 48px (non-zero — geometry checks pass). getBoundingClientRect(): {top:180, left:20, width:320, height:48} — inside viewport. Element is in DOM and in-viewport but painted content is not rendered. Acceptance button (.mcp-accept-btn) has visibility:visible (no declaration — inherits from a non-hidden ancestor). The disclosure is invisible; the Accept button is visible. Classified as selective concealment.
High Inheritance inversion attack detected. Container element (.mcp-consent-modal) has visibility:hidden applied via class .consent-initializing. Consent disclosure element has no explicit visibility declaration — inherits hidden from container; computed visibility:hidden. Accept button element has inline style visibility:visible — overrides inherited hidden state; computed visibility:visible. Asymmetry: disclosure hidden, button visible, both inside a visibility:hidden ancestor. The .consent-initializing class was not removed before the Accept button was rendered visible. This pattern constitutes deliberate selective concealment of consent terms.
Medium Disclosure text is contained in a <tr> element with computed visibility:collapse. Parent table has display:table. The disclosure row is not rendered; no visual gap appears in the table because column widths from the collapsed row are absorbed. offsetHeight of the <tr>: 0px (layout removed — differs from visibility:hidden which returns non-zero). Accept button is in an adjacent <tr> with default visibility:visible. The table renders as a single-row element presenting only the Accept button. Consent terms are not visible to the user.
High Consent disclosure element has transition-property: opacity, visibility with transition-duration: 1s, 0s and transition-delay: 0s, 1s. Static page-load computed style: opacity:1; visibility:visible — passes initial audit snapshot. JavaScript adds class .consent-dismissed at t=150ms post-render, setting opacity:0; visibility:hidden. Timeline: opacity fades from 1→0 over 1000ms; visibility flips from visible→hidden at t=1150ms. By t=1200ms, the disclosure is at opacity:0; visibility:hidden and is removed from the accessibility tree. The Accept button remains visible indefinitely. User has approximately 150ms before fade begins. Post-animation re-check confirmed: visibility:hidden.

Related security guides: CSS visibility is one of several CSS concealment properties used to hide MCP consent disclosures. For smooth-fade attacks that preserve the accessibility tree, see CSS opacity:0 security. For attacks using display:contents to remove the consent container from layout while keeping child elements rendered, see CSS display:contents security. These three properties together cover the majority of CSS-based consent concealment techniques identified in SkillAudit's MCP server audit corpus.