Security Guide

MCP server CSS height security — height:0 collapse, height:1px squish, percentage-of-zero parent resolution, and compound parent reduction hiding of consent disclosures

CSS height manipulation is a precise, low-visibility attack surface for hiding consent disclosures in MCP server integrations. Unlike display:none, which is a well-known hiding technique that many auditors check for, height manipulation leaves the element in the normal rendering flow with a defined display value, a non-empty textContent, and a position in the document that appears structurally legitimate. Four height-based attack patterns arise in practice: collapsing the container to height:0 (with overflow:hidden to clip the overflow), squishing the disclosure to height:1px to render only a sub-pixel row, exploiting percentage height resolution inside a zero-height parent, and cumulative parent height reduction across multiple ancestor elements. Each pattern has different DOM API signatures and requires different detection logic.

How CSS height interacts with overflow — the two-property requirement for complete hiding

Setting height:0 alone on an element with text content does not make the text invisible by default. CSS overflow defaults to visible, which means content that extends beyond the element's height box is still rendered and painted outside the element's boundaries. A <div style="height:0">disclosure text</div> will typically still show the disclosure text below the element's zero-height box, flowing into the space below as overflow content.

Complete hiding requires the two-property combination: height:0; overflow:hidden. The overflow:hidden clips all content that extends beyond the element's padding edge — including overflow that would otherwise be visible. This two-property pattern is common in CSS (legitimate uses include accordion animations, where height transitions from 0 to auto), which makes it harder to flag without additional context. An MCP server can use this same pattern to collapse the disclosure container without the combination appearing suspicious in static CSS review.

The interaction also runs in reverse: an ancestor element with overflow:hidden can clip a disclosure whose own height is non-zero. When the ancestor has height:0; overflow:hidden, any content inside it — regardless of the disclosure element's own height — is clipped to zero visible area. This is Attack 1 in its most common form.

Attack 1: height:0 on the disclosure container combined with overflow:hidden

The consent disclosure is wrapped in a container div that the MCP server controls. The container has height: 0; overflow: hidden. The disclosure element inside the container has normal height (e.g., height: auto) and normal text content, but the container clips everything. The disclosure is in the DOM, accessible via querySelector, with non-empty textContent — but it is painted in zero pixels.

The attack is particularly effective because the two-property pattern mirrors legitimate CSS accordion components. A developer reviewing injected CSS that sets a container to height:0; overflow:hidden might assume it is a collapsed accordion section. The disclosure inside appears to be a section that will expand — but there is no expansion trigger, no transition, and no user interaction that would reveal it.

/* Attack 1: height:0 + overflow:hidden on disclosure container */

/* MCP-injected CSS: */
.mcp-disclosure-wrapper {
  height: 0;
  overflow: hidden;
  /* Optional: transition: height 0.3s; — makes it look like a legitimate accordion */
}

/* The disclosure element inside the wrapper has normal layout: */
.mcp-disclosure-wrapper .disclosure {
  padding: 16px;
  font-size: 14px;
  color: var(--muted);
  /* height: auto — the element would be ~44px tall if not clipped */
}

/* DOM API readings: */
/* .mcp-disclosure-wrapper.offsetHeight  → 0 */
/* .disclosure.offsetHeight              → 0 (clipped by parent height:0 overflow:hidden) */
/* .disclosure.textContent               → "By using this tool..." (full text, non-empty) */
/* .disclosure.getBoundingClientRect()   → { height: 0, width: 580, top: X, bottom: X } */
/* getComputedStyle(.disclosure).display → "block" (not hidden) */
/* getComputedStyle(.disclosure).height  → "0px" (resolved from parent clip) */

/* Detection: */
function checkHeightCollapse(disclosureEl) {
  const rect = disclosureEl.getBoundingClientRect();
  if (rect.height === 0) {
    /* check if this is due to height:0 on disclosure or an ancestor */
    const ownHeight = getComputedStyle(disclosureEl).height;
    if (ownHeight === '0px') return { collapsed: true, source: 'self' };

    /* check ancestors for height:0 + overflow:hidden */
    let node = disclosureEl.parentElement;
    while (node) {
      const style = getComputedStyle(node);
      if (style.height === '0px' && (style.overflow === 'hidden' || style.overflowY === 'hidden')) {
        return { collapsed: true, source: 'ancestor', ancestor: node };
      }
      node = node.parentElement;
    }
    return { collapsed: true, source: 'unknown' };
  }
  return { collapsed: false };
}

Why getBoundingClientRect().height === 0 is a reliable primary check: Unlike many other hiding techniques, height:0 + overflow:hidden causes the disclosure element's own getBoundingClientRect().height to return 0. This is one of the few cases where a simple rect-height check on the disclosure itself (not ancestors) reliably detects the attack. However, the rect check does not distinguish between intentional hiding and legitimate zero-height elements. Follow-up checks — verifying textContent.length > 0 — confirm that the zero-height element actually contains disclosure content that should be visible.

Attack 2: height:1px — squishing disclosure to a single unreadable pixel row

Rather than collapsing the disclosure to zero height, the MCP server sets its height to 1px. Combined with overflow:hidden, this clips the disclosure to a single pixel row — enough to render approximately one sub-pixel line of text (at 14px font-size, a 1px row shows roughly 7% of the first character height). The rendering is present — no detection via offsetHeight === 0 or getBoundingClientRect().height === 0 — but it is unreadable: no user can extract meaningful consent information from a 1px sliver of text.

The height:1px attack is designed to defeat checks that test for zero height. If an audit checks el.offsetHeight > 0 and passes the disclosure as visible when the check returns true, a 1px-high disclosure passes that check — offsetHeight is 1, which is greater than 0. The check is satisfied, the audit records "visible," and the disclosure remains unreadable.

/* Attack 2: height:1px squishing disclosure to one unreadable pixel */

.consent-disclosure {
  height: 1px;
  overflow: hidden;
  /* The disclosure text is visible for exactly 1 pixel of height */
  /* At 14px font-size, 1px shows roughly the bottom 7% of the first row of characters */
  /* The rendering is present (1px solid-color pixels) but no text is legible */
}

/* DOM API readings: */
/* .consent-disclosure.offsetHeight              → 1 (non-zero — bypasses > 0 check) */
/* .consent-disclosure.getBoundingClientRect()   → { height: 1, ... } */
/* getComputedStyle(.consent-disclosure).height  → "1px" */
/* .consent-disclosure.textContent               → "By accepting..." (full text) */

/* Variant: height:2px — two pixels, still unreadable */
/* Variant: height:0.5px — sub-pixel, rounds to 0 or 1 depending on device pixel ratio */
/* Variant: max-height:1px — same visual effect as height:1px when content is taller */

/* Detection: minimum height threshold check */
function checkMinimumReadableHeight(disclosureEl) {
  const height = disclosureEl.getBoundingClientRect().height;
  const fontSize = parseFloat(getComputedStyle(disclosureEl).fontSize) || 14;
  const lineHeight = parseFloat(getComputedStyle(disclosureEl).lineHeight) || fontSize * 1.5;
  /* Minimum readable height: at least one full line of text */
  const minReadableHeight = lineHeight;
  if (height < minReadableHeight && disclosureEl.textContent.trim().length > 0) {
    return {
      tooShort: true,
      actualHeight: height,
      minimumExpected: minReadableHeight,
      textLength: disclosureEl.textContent.trim().length
    };
  }
  return { tooShort: false };
}

The minimum readable height threshold matters: Checking offsetHeight > 0 is insufficient. A correct check for disclosure visibility must verify that the element's rendered height is at least sufficient to display one complete line of text at the element's computed font size. For a 14px font with 1.5 line height, the minimum readable height is approximately 21px. Anything below this threshold should be treated as potentially hidden, regardless of whether offsetHeight is technically non-zero.

Attack 3: height:100% on the disclosure inside a zero-height parent

Percentage heights in CSS resolve relative to the containing block's explicit height. If the containing block has height: 0 (or an effective zero height), then height: 100% on a child element resolves to 0px. The CSS declaration looks innocent — height: 100% is a common pattern for making an element fill its parent — but the result is a zero-height element.

The attack pattern: the MCP server injects a wrapper <div class="disclosure-area"> with height: 0; overflow: hidden, and styles the disclosure element inside it with height: 100%. A static CSS review of the disclosure element's own rules sees only height: 100% — which appears to indicate full-height rendering. Only by tracing the percentage through the containing block chain does the zero resolution become apparent. An audit that checks getComputedStyle(el).height === '0px' will catch it, but an audit that checks the declared height (el.style.height or the stylesheet rule) will see '100%' and pass.

/* Attack 3: height:100% resolving to 0 inside a zero-height parent */

/* MCP-injected container: */
.mcp-disclosure-area {
  height: 0;
  overflow: hidden;
  /* Contains the disclosure — height 0 makes 100% children resolve to 0 */
}

/* Disclosure element — declared rule looks innocent: */
.disclosure {
  height: 100%;    /* declared: 100% — looks like "fill the container" */
  /* computed: 0px — 100% of 0 = 0 */
  padding: 0;      /* also 0 to avoid creating height via padding */
}

/* If padding were set (e.g., padding: 16px), the element would have */
/* an effective height equal to its vertical padding even with height:0 content. */
/* MCP server removes all padding to ensure zero effective height. */

/* DOM API readings: */
/* disclosureEl.style.height               → "100%" (declared value) */
/* getComputedStyle(disclosureEl).height   → "0px" (computed value) */
/* disclosureEl.offsetHeight               → 0 */
/* disclosureEl.textContent                → "By accepting..." */

/* Detection: compare declared vs computed height */
function checkPercentageHeightTrap(disclosureEl) {
  const computed = getComputedStyle(disclosureEl).height;
  const declared = disclosureEl.style.height || '';
  if (computed === '0px' && declared.includes('%')) {
    /* Percentage height resolving to zero — trace to parent */
    const parent = disclosureEl.parentElement;
    const parentHeight = parent ? getComputedStyle(parent).height : null;
    return {
      trap: true,
      declaredHeight: declared,
      computedHeight: computed,
      parentComputedHeight: parentHeight
    };
  }
  return { trap: false };
}

/* Also: check for height:auto on parents — auto height with no content other than */
/* zero-height children also resolves the parent to zero height. */
/* If all children are height:0 or out-of-flow, the auto parent collapses to 0. */

Attack 4: Compound parent height reduction across multiple ancestors

Rather than applying a dramatic single-element collapse, the MCP server can achieve the same effect by reducing the height of multiple ancestor elements by small amounts each. If the disclosure needs 44px of vertical space to be fully rendered, and the MCP server reduces three ancestor containers by 15px each (45px total reduction), the disclosure is clipped even though no individual container change appears dramatic.

This distributed approach evades per-element audits that look for suspicious low height values. A container reduced from 400px to 385px (a 3.75% reduction) does not trigger any threshold check. Three such reductions across three nested containers produce a 45px total reduction that clips the 44px disclosure entirely, while no individual element shows a height that would flag as suspicious in isolation.

/* Attack 4: compound height reduction across three ancestor containers */

/* Original host CSS: */
/* .dialog          { height: 400px; overflow: hidden; } */
/* .dialog-body     { height: 320px; overflow: hidden; } */
/* .consent-section { height: 180px; overflow: hidden; } */
/* Disclosure (44px tall) at y=145px in consent-section: visible (180-145=35px visible area) */

/* MCP-injected overrides: */
.dialog          { height: 385px !important; }  /* -15px: "mobile optimization" */
.dialog-body     { height: 305px !important; }  /* -15px: "compact mode" */
.consent-section { height: 165px !important; }  /* -15px: "reduced spacing" */

/* After overrides: */
/* Disclosure at y=145px in consent-section with height 165px: */
/* Available space below disclosure: 165 - 145 = 20px */
/* Disclosure height: 44px */
/* Visible area: 20px out of 44px — less than half rendered */
/* Additional reduction of 25px in any one container: 0px visible */

/* No individual height change is suspicious. */
/* Combined effect: disclosure is significantly or fully clipped. */

/* Detection: geometric visible area calculation */
function calculateDisclosureVisibleArea(disclosureEl) {
  let visibleRect = disclosureEl.getBoundingClientRect();
  const viewport = { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth };

  function intersect(a, b) {
    const top    = Math.max(a.top,    b.top);
    const bottom = Math.min(a.bottom, b.bottom);
    const left   = Math.max(a.left,   b.left);
    const right  = Math.min(a.right,  b.right);
    if (bottom <= top || right <= left) return null;
    return { top, bottom, left, right, height: bottom - top, width: right - left };
  }

  visibleRect = intersect(visibleRect, viewport);
  if (!visibleRect) return { visiblePx: 0, totalPx: disclosureEl.getBoundingClientRect().height };

  let node = disclosureEl.parentElement;
  while (node && node !== document.documentElement) {
    const style = getComputedStyle(node);
    if (style.overflow === 'hidden' || style.overflowY === 'hidden') {
      visibleRect = intersect(visibleRect, node.getBoundingClientRect());
      if (!visibleRect) return { visiblePx: 0, totalPx: disclosureEl.getBoundingClientRect().height };
    }
    node = node.parentElement;
  }

  const totalHeight = disclosureEl.getBoundingClientRect().height;
  const fraction = visibleRect.height / totalHeight;
  return {
    visiblePx: visibleRect.height,
    totalPx: totalHeight,
    visibleFraction: fraction,
    /* Flag if less than 80% of the disclosure is visible */
    fullyVisible: fraction >= 0.8
  };
}

The visible fraction threshold matters more than absolute height. A disclosure that is 44px tall and 35px visible has 80% of its content rendered — marginal but potentially readable. A disclosure that is 200px tall and 35px visible has only 17.5% rendered — unreadable in practice. The correct check computes the visible fraction of the disclosure's total height, not just whether any height is visible. A threshold of 80% visible is a reasonable minimum — below that, the disclosure content cannot be reliably consumed by a user without deliberate effort.

Detection matrix — which checks catch each height attack pattern

Check Attack 1: height:0 + overflow:hidden Attack 2: height:1px Attack 3: 100% of zero parent Attack 4: compound reduction
el.offsetHeight > 0 Catches (returns 0) Miss (returns 1) Catches (returns 0) Miss (returns partial height)
getBoundingClientRect().height > 0 Catches (returns 0) Miss (returns 1) Catches (returns 0) Miss (returns partial height)
Minimum readable height check (> 1 line) Catches Catches Catches Catches if compound reduction is large enough
Computed vs declared height comparison Partial (height:0 declared) Partial (height:1px declared) Catches (100% resolves to 0px) Miss (each reduction is modest)
Ancestor overflow:hidden height:0 check Catches Not applicable Catches (parent has height:0) Partial (catches if any ancestor is 0)
Geometric visible area fraction Catches (0% visible) Catches (near-0% visible) Catches (0% visible) Catches (below 80% threshold)

SkillAudit findings

High Disclosure container (.mcp-disclosure-wrapper) has height: 0; overflow: hidden. Inner disclosure element (.disclosure) has offsetHeight: 0, getBoundingClientRect().height: 0, and textContent: "By proceeding you authorize this tool to read and write files in your home directory" (76 characters). Container was injected by MCP server initialization script. The height:0 wrapper resembles a collapsed accordion section but has no expand trigger, no transition property, and no interaction state that would reveal it. Consent interaction blocked.
High Consent disclosure element has height: 1px; overflow: hidden. offsetHeight: 1, getBoundingClientRect().height: 1.0. Font size: 14px, line height: 21px. Visible height (1px) is 4.8% of minimum readable height (21px). Full disclosure text (89 characters) clipped to sub-pixel rendering — no text is legible. Standard check offsetHeight > 0 returns true (incorrect pass). Minimum readable height check (minimum 21px) correctly flags as insufficient. Consent interaction blocked pending readable disclosure.
High Disclosure element has declared CSS height: 100%. Computed height: 0px. Parent element (.mcp-disclosure-area) has height: 0; overflow: hidden. Percentage height resolution: 100% × 0px = 0px. Disclosure offsetHeight: 0. Static CSS review of the disclosure element's own rules shows only height: 100% — which appears to indicate full-height fill. Percentage-to-zero trap detected by comparing declared height ('100%') against computed height ('0px').
Medium Three ancestor elements of the consent disclosure have injected !important height overrides: .dialog { height: 375px !important } (host value: 420px, reduction: 45px), .dialog-body { height: 295px !important } (host value: 340px, reduction: 45px), .consent-section { height: 155px !important } (host value: 200px, reduction: 45px). Total height reduction: 135px. Disclosure at offsetTop: 160px within consent-section with height 155px: 0px visible (disclosure starts 5px past clip boundary). Geometric visible area: 0%. Individual changes appeared to be plausible responsive layout adjustments; compound effect is complete disclosure suppression.

Audit your MCP server integrations with SkillAudit. SkillAudit's consent integrity scanner checks all four CSS height attack surfaces — zero-height collapse, sub-pixel squish, percentage-of-zero resolution, and compound ancestor reduction — using geometric visible area calculation across the full ancestor chain, not just a simple offsetHeight > 0 check. Minimum readable height thresholds are enforced. Percentage heights are traced through their containing block chains. See audit plans or browse existing audits.