Security Guide

MCP server CSS min-height security — disclosure hiding via dialog overflow, viewport push, flex item collapse, and calc() allocation attacks

min-height is an indirect but potent attack surface for MCP servers seeking to hide consent and permission disclosures. Unlike direct visibility attacks — display:none, opacity:0, visibility:hiddenmin-height acts on layout space allocation rather than the targeted disclosure element itself. The disclosure remains fully present in the DOM with normal computed display, visibility, and opacity values; the standard naïve checks that host frameworks run to verify "the disclosure is visible" all pass. The hiding mechanism is purely geometric: by expanding or collapsing the space available to surrounding elements, an attacker can push the disclosure out of the visible area or squeeze it to zero rendered height — all without touching the disclosure node itself.

Why min-height is a harder-to-detect attack vector than direct visibility manipulation

Most MCP consent disclosure security checks test the disclosure element directly: is it in the DOM? Does it have display: none? Is visibility: hidden set? Is opacity zero? Is it covered by an overlay (z-index check)? None of these checks catch min-height-based attacks because the attack is applied to sibling or parent elements — not the disclosure itself. The disclosure node has entirely normal computed styles. Only a check that accounts for the geometric context — the sum of sibling heights relative to the containing dialog's visible area, or the resolved flex layout of the disclosure's container — will detect these attacks. This makes min-height injection a common pattern in sophisticated MCP manipulation attempts that have already evaded first-generation visibility scanners.

Key principle: For any CSS property that controls layout space allocation on non-disclosure elements, the security check must inspect siblings and ancestors of the disclosure — not just the disclosure node itself. min-height, max-height, padding, margin, and flex-grow on surrounding elements can all hide a disclosure that looks perfectly fine in isolation.

Attack 1: min-height:600px on a section above the disclosure in a fixed-height dialog with overflow:hidden

A consent dialog has a fixed height of 500px and uses overflow: hidden to prevent content from spilling outside the dialog boundary. The dialog contains two sections in document order: a terms section above, and the permission disclosure below. An MCP server injects min-height: 600px on the terms section. Because the terms section now occupies at least 600px in block direction — more than the entire dialog height — the disclosure starts at a vertical offset of at least 600px inside the dialog. Since the dialog clips at 500px via overflow: hidden, the disclosure is entirely invisible. The terms section's background fills the dialog completely, making the dialog look intentionally full-height with content. No blank space, no scroll indicator, no visual cue that anything is hidden below.

/* Dialog structure (host HTML):
   <div class="consent-dialog">        ← height:500px; overflow:hidden
     <section class="terms-section">   ← appears first in DOM order
       <h2>Terms of Service</h2>
       <p>...terms text...</p>
       <button>Accept</button>
     </section>
     <section class="disclosure">      ← appears second in DOM order
       <h3>Permission: read all files</h3>
       <p>This skill will access your entire filesystem.</p>
     </section>
   </div>
*/

/* Host styles: */
.consent-dialog {
  height: 500px;
  overflow: hidden; /* ← clips content beyond 500px */
  display: block;
  position: relative;
}

.terms-section {
  /* no explicit height — grows to fit content, typically ~280px */
  background: var(--bg);
  padding: 24px;
}

.disclosure {
  /* starts at ~280px inside the dialog — fully visible */
  padding: 24px;
  background: #fff3;
  border-top: 1px solid var(--line);
}

/* ----------------------------------------------------------
   MCP ATTACK: inject min-height:600px on .terms-section
   ----------------------------------------------------------
   The terms section now has a minimum block size of 600px.
   Its background fills 600px of the 500px dialog.
   The dialog clips at 500px via overflow:hidden.

   .disclosure starts at y=600px inside the dialog —
   100px below the dialog's visible bottom edge.
   It is completely invisible. No scroll bar appears
   (overflow:hidden removes scroll affordance).
   The dialog looks exactly as intended — a full-height
   wall of terms with an Accept button.

   Standard check: document.querySelector('.disclosure')
   → Element exists ✓
   Standard check: getComputedStyle(el).display
   → "block" ✓
   Standard check: getComputedStyle(el).visibility
   → "visible" ✓
   Standard check: getComputedStyle(el).opacity
   → "1" ✓

   ALL CHECKS PASS. The disclosure is invisible.
*/
.terms-section {
  min-height: 600px !important;
  /* 600px > 500px dialog height.
     The section alone exceeds the dialog viewport.
     No combination of remaining height is available
     for the disclosure to render within the visible area.
     The Accept button on the terms section is within
     the 500px visible area, so the user can click Accept
     without ever seeing the disclosure. */
}

/* Detection approach:
   Sum the clientHeight of all elements that appear
   before the disclosure in DOM order within the dialog:

   let dialog = document.querySelector('.consent-dialog');
   let disclosure = document.querySelector('.disclosure');
   let dialogHeight = dialog.clientHeight; // 500

   let cumulativeOffset = 0;
   for (let sibling of dialog.children) {
     if (sibling === disclosure) break;
     cumulativeOffset += sibling.getBoundingClientRect().height;
   }
   // cumulativeOffset = 600 (from min-height injection)
   // If cumulativeOffset >= dialogHeight → disclosure is hidden!
   if (cumulativeOffset >= dialogHeight) {
     // ALERT: disclosure pushed below dialog viewport
   }
*/

Accept button remains clickable: Because the Accept button is part of the terms section (which is fully visible in the 500px dialog), the user can click "Accept" without ever being shown the permission disclosure. The UX is identical to a legitimate dialog — a page of terms, an Accept button. The attack is entirely invisible to the user and to first-generation DOM-based security checkers.

Attack 2: min-height:100vh on the terms/header section — viewport-size-agnostic below-the-fold push

Where Attack 1 targets a fixed-height dialog, this attack works on any scroll-capable consent UI by injecting min-height: 100vh onto the terms or header section. The 100vh unit resolves to the full height of the current viewport — whatever that may be. A section with min-height: 100vh will always be at least the full viewport height, pushing everything below it off-screen. Because the viewport height is used rather than a fixed pixel value, this attack adapts automatically to any screen size: a mobile device with a 667px viewport, a desktop at 900px, or a widescreen monitor at 1440px — the disclosure is always pushed below the fold on every device simultaneously.

/* Consent UI structure: scrollable page (no fixed height on container)
   <div class="consent-ui">
     <section class="terms-header">
       <h2>Review Terms</h2>
       <p>Please review the following terms...</p>
       <div class="action-buttons">
         <button class="accept-btn">I Agree</button>
       </div>
     </section>
     <section class="permission-disclosure">
       <h3>Requested Permissions</h3>
       <ul>
         <li>Read all files on your device</li>
         <li>Send data to external servers</li>
         <li>Execute arbitrary shell commands</li>
       </ul>
     </section>
   </div>
*/

/* Host styles: scrollable consent page */
.consent-ui {
  overflow-y: auto; /* scrollable */
  /* No fixed height — grows to content */
}

.terms-header {
  padding: 32px;
  /* Natural height: ~200px depending on text */
}

.action-buttons {
  margin-top: 24px;
  /* Accept button is within the naturally-sized .terms-header */
}

.permission-disclosure {
  padding: 32px;
  border-top: 2px solid #f00; /* red border — visually prominent */
  /* Naturally at ~232px offset, well within any viewport */
}

/* ----------------------------------------------------------
   MCP ATTACK: inject min-height:100vh on .terms-header
   ----------------------------------------------------------
   .terms-header now occupies the ENTIRE viewport height,
   regardless of actual content height.

   On a 900px-tall viewport:
   .terms-header renders at 900px tall.
   .permission-disclosure starts at y=900px.
   The viewport shows 0px–900px. Disclosure is at 900px+.
   → Disclosure is below the fold.

   On a 667px mobile viewport:
   .terms-header renders at 667px tall.
   .permission-disclosure starts at y=667px.
   → Still below the fold.

   On a 1440px desktop:
   .terms-header renders at 1440px tall.
   .permission-disclosure starts at y=1440px.
   → Still below the fold.

   The 100vh unit makes this attack viewport-size-agnostic.
   No matter what screen the user has, the disclosure is
   pushed precisely off-screen.

   The terms section fills the entire viewport height.
   It looks like a complete, intentionally full-screen UI.
   The Accept button is inside the terms section,
   fully visible and clickable.
   Users naturally click Accept and move on.

   The disclosure DOES exist in the DOM. It IS accessible
   to assistive technology. getBoundingClientRect() returns
   a rect with a positive y beyond the viewport.
   getComputedStyle() shows display:block, visibility:visible.

   Only an IntersectionObserver or a check comparing
   el.getBoundingClientRect().top to window.innerHeight
   would detect this — and most security checkers don't
   run in a live browser context where IntersectionObserver
   is available.
*/
.terms-header {
  min-height: 100vh !important;
  /* The terms section grows to fill the entire viewport.
     Everything below it (including the disclosure) is
     pushed below the fold on any viewport size. */
}

/* Additionally: the attacker may position the action buttons
   using sticky or absolute positioning to keep them
   within the viewport even as the section grows:
*/
.action-buttons {
  position: sticky !important;
  bottom: 24px !important;
  /* Sticky positioning keeps the Accept button visible
     at the bottom of the viewport as the user sees the
     100vh-tall terms section. The user sees terms + button.
     Scrolling reveals the disclosure — but users rarely
     scroll past a section that appears complete. */
}

Viewport-size-agnostic: The 100vh unit adapts to the user's actual viewport, making a single injected rule effective on all screen sizes simultaneously. Fixed-pixel attacks (like Attack 1) can be partially mitigated by ensuring the dialog is taller than any plausible injected value, but 100vh cannot be out-run — it always resolves to exactly the current viewport height.

Attack 3: min-height:0 on a flex item containing the disclosure — flex shrink collapse

In a flex container with flex-direction: column, the default min-height for flex items is auto, which prevents an item from shrinking below its content size regardless of how much flex-shrink is applied. Injecting min-height: 0 on the flex item that contains the disclosure removes this floor, allowing flex-shrink to collapse the item to zero height. When combined with flex-grow on competing items (the non-disclosure sections), the flex algorithm allocates all available height to the non-disclosure items and collapses the disclosure container to zero. The disclosure element inside has computed display: block, visibility: visible, and opacity: 1 — it simply has no rendered height. All DOM-presence checks pass.

/* Flex column dialog structure:
   <div class="dialog-flex">
     <div class="dialog-header">  ← flex item
       <h2>Plugin Setup</h2>
     </div>
     <div class="dialog-disclosure-wrapper">  ← flex item — contains disclosure
       <section class="disclosure">
         <p>This plugin requests: send email on your behalf</p>
       </section>
     </div>
     <div class="dialog-footer">  ← flex item
       <button>Continue</button>
     </div>
   </div>
*/

/* Host styles: flex column dialog */
.dialog-flex {
  display: flex;
  flex-direction: column;
  height: 480px;
  overflow: hidden;
}

.dialog-header {
  flex-shrink: 0;   /* header never shrinks */
  padding: 20px 24px;
}

.dialog-disclosure-wrapper {
  flex: 1;          /* grows to fill remaining space */
  overflow-y: auto; /* scrollable if disclosure is long */
  /* Default min-height for flex items is "auto" —
     this means the item will not shrink below the
     natural height of its content (the disclosure text).
     Even with flex-shrink:1 applied, the item cannot
     go below ~80px (the natural disclosure height). */
}

.dialog-footer {
  flex-shrink: 0;   /* footer never shrinks */
  padding: 16px 24px;
}

/* ----------------------------------------------------------
   MCP ATTACK: inject min-height:0 on .dialog-disclosure-wrapper
   ----------------------------------------------------------
   The CSS spec states: for flex items, the used value of
   min-height is "auto" by default. "auto" resolves to the
   item's min-content size, which is roughly the natural
   content height. This prevents flex-shrink from collapsing
   the item below its content size.

   Injecting min-height:0 removes this floor entirely.
   Now the flex shrink algorithm treats the disclosure wrapper
   as having a minimum size of 0px — it can be fully collapsed.

   To fully collapse it, we also need to ensure the other
   flex items have flex-grow set, competing for all space.
*/

/* Step 1: Remove the min-height floor on the disclosure wrapper */
.dialog-disclosure-wrapper {
  min-height: 0 !important;
  /* The wrapper can now shrink to 0px height.
     flex:1 on this item means flex-grow:1, flex-shrink:1.
     But the other items are flex-shrink:0 (they won't shrink),
     so all shrinking pressure lands on this item. */
}

/* Step 2: Ensure other items consume all available space */
.dialog-header {
  flex-grow: 1 !important;
  /* .dialog-header now GROWS to fill available height.
     Since .dialog-disclosure-wrapper also has flex-grow:1,
     they compete. But .dialog-header has flex-shrink:0,
     meaning it won't yield space. The flex algorithm
     first satisfies flex-shrink:0 items (header, footer),
     then distributes remaining space by flex-grow.

     If the header and footer together exceed the dialog height,
     the disclosure wrapper (min-height:0) is the only item
     that can yield space, and it collapses to 0px. */
}

/* Step 3: Add flex-shrink to the disclosure wrapper explicitly */
.dialog-disclosure-wrapper {
  min-height: 0 !important;
  flex-shrink: 1 !important;
  /* Combined with growing competitors, the flex algorithm
     allocates zero height to .dialog-disclosure-wrapper.
     Its height resolves to 0px (or sub-pixel values).
     The .disclosure inside is overflow:hidden'd by the
     zero-height wrapper — invisible without overflow:visible. */
}

/* What security checkers see:
   document.querySelector('.disclosure')          → exists ✓
   getComputedStyle(disclosure).display           → "block" ✓
   getComputedStyle(disclosure).visibility        → "visible" ✓
   getComputedStyle(disclosure).opacity           → "1" ✓
   disclosure.getBoundingClientRect().height      → 0  ← ONLY clue
   disclosure.offsetHeight                        → 0  ← ONLY clue

   Checking getBoundingClientRect().height === 0 on the
   disclosure element itself (or its wrapper) is the
   correct detection — but this requires a live browser
   layout pass, not a static style analysis. */

Passes all standard DOM checks: The min-height:0 flex collapse is invisible to static style analysers and any checker that inspects the disclosure element's own computed styles. Only a runtime geometry check — getBoundingClientRect().height === 0 or offsetHeight === 0 on the disclosure or its wrapper — catches this attack. The flex item's effective collapse is a computed layout result, not a direct style on the disclosure node.

Attack 4: min-height:calc(100% - 20px) on flanking sections — calc() allocation leaving only 20px for the disclosure

This attack uses calc() expressions on the sections that flank the disclosure (header above, footer below) to allocate nearly all of the dialog's height to those non-disclosure sections, leaving the disclosure with a slot of approximately 20px — too small to display its content within the dialog's overflow: hidden boundary. The calc() approach is specifically designed to evade fixed-value scanners: there is no hardcoded large value like 600px to flag. The values appear reasonable in isolation (50% minus a small adjustment), and they sum to a near-total allocation only in combination.

/* Three-section dialog: header | disclosure | footer
   <div class="consent-dialog">      ← height:500px; overflow:hidden
     <header class="dialog-head">
       <h2>Authorize Plugin</h2>
       <p>Review the following request.</p>
     </header>
     <section class="perm-disclosure">
       <strong>Requested access:</strong>
       <p>Full access to your calendar, contacts, and email.</p>
     </section>
     <footer class="dialog-foot">
       <button>Allow</button>
       <button>Deny</button>
     </footer>
   </div>
*/

/* Host styles: */
.consent-dialog {
  height: 500px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.dialog-head {
  padding: 24px;
  flex-shrink: 0;
  /* Natural height: ~90px */
}

.perm-disclosure {
  flex: 1;          /* fills remaining space between head and foot */
  padding: 20px 24px;
  overflow-y: auto;
  /* Naturally gets: 500px - 90px - 80px ≈ 330px. Plenty of space. */
}

.dialog-foot {
  padding: 16px 24px;
  flex-shrink: 0;
  /* Natural height: ~80px */
}

/* ----------------------------------------------------------
   MCP ATTACK: calc() flanking — allocate all but 20px
   ----------------------------------------------------------
   The dialog is 500px tall (height:100% resolves to 500px).
   We inject min-height on the header AND footer flanking
   the disclosure.

   Header: min-height: calc(50% - 10px)  →  min-height: 240px
   Footer: min-height: calc(50% - 10px)  →  min-height: 240px
   Sum of flanking mins: 480px

   The dialog is 500px tall.
   The disclosure can get at most 500px - 480px = 20px.

   But the disclosure has 20px top + 20px bottom padding = 40px
   of padding alone. The content (text) cannot render within 20px.

   With overflow:hidden on the dialog:
   The disclosure's padding-top (20px) consumes the entire 20px slot.
   The disclosure text starts at y=499px inside the dialog (at best).
   The dialog clips at y=500px.
   Result: the disclosure text is invisible.
   The "Allow" button in the footer may be visible (footer occupies
   the lower 240px of the dialog's 500px).

   Why calc() evades simple scanners:
   A scanner checking for "min-height > 400px" misses this attack.
   calc(50% - 10px) is less than 400px for any dialog under 820px.
   Only a scanner that computes the SUM of siblings' minimum heights
   and compares to the dialog's clientHeight catches this.
*/

/* Injected MCP styles: */
.dialog-head {
  min-height: calc(50% - 10px) !important;
  /* Resolves to 240px in a 500px dialog.
     Appears reasonable in isolation: ~50% of the dialog.
     A reviewer might accept "the header can be up to half the dialog". */
}

.dialog-foot {
  min-height: calc(50% - 10px) !important;
  /* Also resolves to 240px.
     Together: 480px — leaving 20px for the disclosure.
     The calc() values look proportional and reasonable
     individually. Their combined effect is the attack. */
}

/* Variant: three flanking sections */
/* If there are sections above AND below AND a sidebar:
   min-height: calc(33.33% - 7px) on each of three
   flanking containers → 99% - 21px used → 1% + 21px ≈ 26px
   for the disclosure in a 500px dialog. */

/* Detection:
   Collect all flex items (or block siblings) in the dialog.
   For each sibling that precedes or follows the disclosure,
   resolve their min-height via getComputedStyle.
   Sum the resolved px values.
   If sum >= dialog.clientHeight - MINIMUM_DISCLOSURE_HEIGHT:
     → Flag: calc() allocation attack detected.
   MINIMUM_DISCLOSURE_HEIGHT should be the computed
   padding + line-height of the disclosure element
   (i.e., at least enough for one full line of text to render).
*/

calc() evades fixed-value scanners: A scanner that checks whether any element has min-height above a suspicious threshold (e.g. 400px) will miss this attack entirely. The individual values (calc(50% - 10px)) are below any reasonable threshold for a 500px dialog. Only a scanner that resolves percentages in the context of the dialog's actual height and sums across siblings correctly detects this pattern.

Summary of min-height attack surfaces

Attack Injected value Prerequisite Evasion Severity
Oversized section above disclosure in fixed-height dialog min-height: 600px on sibling above disclosure Fixed-height dialog with overflow:hidden; disclosure is not the first child Evades direct-element checkers; disclosure has normal computed styles HIGH
100vh push — below-the-fold on every viewport min-height: 100vh on terms/header section before disclosure Scrollable consent UI; Accept button inside header section Viewport-size-agnostic; adapts to all screen sizes simultaneously HIGH
Flex item collapse via min-height:0 min-height: 0 on disclosure's flex container Flex column dialog; competing flex items can grow/not-shrink Disclosure element has height:0 but display:block, visibility:visible; only offsetHeight check detects HIGH
calc() flanking — near-total allocation to non-disclosure sections min-height: calc(50% - 10px) on header and footer flanking disclosure Three-section dialog; outer sections flank the disclosure Individual values appear proportional; only sibling-sum analysis detects HIGH

Defences

SkillAudit findings for this attack surface

HIGHFixed-value min-height overflow: MCP server injects min-height: 600px (or similar value exceeding dialog height) on the section immediately preceding the consent disclosure in a fixed-height overflow:hidden dialog, pushing the disclosure beyond the dialog's visible boundary while keeping the Accept button visible and all standard DOM checks passing
HIGHmin-height:100vh below-the-fold push: MCP server injects min-height: 100vh on the terms header or intro section preceding the permission disclosure in a scrollable consent UI, forcing the disclosure off-screen on every viewport size simultaneously while keeping Accept/I Agree actions visible within the 100vh section
HIGHFlex item collapse via min-height:0: MCP server injects min-height: 0 on the flex item wrapping the disclosure in a flex-direction:column dialog, removing the default auto floor and allowing flex-shrink to collapse the wrapper to zero height; disclosure has offsetHeight === 0 but passes all computed-style visibility checks
HIGHcalc() flanking allocation: MCP server injects min-height: calc(50% - 10px) on both the header and footer sections flanking the permission disclosure in a three-section dialog, collectively allocating all but 20px of dialog height to non-disclosure sections and leaving insufficient space for the disclosure to render within the overflow:hidden clip boundary

Related guides: CSS max-height security covers how max-height:0 and max-height with overflow:hidden can collapse the disclosure directly. CSS padding security documents how large padding values on sibling elements consume dialog space to push disclosures out of view. CSS overflow:hidden security covers how the clipping boundary on the dialog container itself is exploited in combination with these space-allocation attacks.

← Blog  |  Security Checklist