MCP server CSS contain: size security: zero-box sizing, contain: strict overflow clip, content-visibility: hidden invisibility, and contain: size IntersectionObserver bypass

Published 2026-07-24 — SkillAudit Research

CSS Containment (contain) is a performance optimization mechanism: it allows a browser to skip layout, paint, and style calculations for subtrees that are explicitly isolated from the rest of the page. The contain: size value introduces a critical behavior for MCP security: it makes an element act as if it has no intrinsic size from its content. Without an explicit width and height, an element with contain: size renders at 0×0 pixels — its content overflows, but the element box itself has zero area.

This is the attack primitive: an MCP server sets contain: size on a consent disclosure element that has no explicit width/height set. The consent text content exists in the DOM and technically "overflows" the element, but the element's box is 0×0. Depending on the overflow behavior of the parent, the overflow either: (1) is clipped at the 0×0 boundary if the parent has overflow: hidden, or (2) is painted outside the element box but in an arbitrary screen region if the parent has overflow: visible. Both cases hide the effective consent from the user in different ways.

Browser support: contain is supported in Chrome 52+, Firefox 69+, Safari 15.4+. content-visibility is supported in Chrome 85+, Firefox 125+, Safari 18+.

Attack 1: contain: size with no explicit width/height collapses element to 0×0

When contain: size is applied without explicit dimensions, the browser treats the element as if it has no content for sizing purposes. The element renders at 0×0. Its content overflows invisibly if the parent clips overflow.

/* Attack 1: contain: size zero-box sizing */

/* Container with overflow clipping (common in UI components): */
.permissions-panel {
  overflow: hidden;
  /* Any other styles — height is determined by children */
}

/* Consent disclosure: */
.consent-disclosure {
  contain: size;
  /* No width, no height explicitly set.
     Result: element is 0×0.
     Content (the consent text paragraphs) technically overflows the 0×0 box.
     Parent overflow: hidden clips at the 0×0 boundary.
     The consent text is entirely invisible. The element is in the DOM.

     What checks PASS:
     - visibility: visible
     - opacity: 1
     - display: block (or default)
     - getBoundingClientRect(): returns {width: 0, height: 0} — detectable!
     - getComputedStyle().contain: 'size' — detectable but rarely checked

     What checks FAIL (miss the attack):
     - IntersectionObserver with 0px margin: threshold at any non-zero value
       — intersectionRatio is 0 when the box is 0×0, callback may not fire
       at a threshold of 0.5 (50% visible)
     - jQuery :visible selector: checks offsetWidth and offsetHeight > 0
       — 0×0 element returns false for :visible, but auditors may not check this
  */
}

// Detection: check for zero or near-zero getBoundingClientRect dimensions
function detectZeroBoxContain(el) {
  const rect = el.getBoundingClientRect();
  const cs = window.getComputedStyle(el);
  const contain = cs.contain;

  if (rect.width === 0 && rect.height === 0 && el.textContent.trim().length > 0) {
    if (contain && (contain.includes('size') || contain.includes('strict') || contain.includes('inline-size'))) {
      console.error('SECURITY: consent element is 0×0 due to CSS contain: size', {
        element: el, contain, contentLength: el.textContent.trim().length
      });
      return true;
    }
  }
  return false;
}

Attack 2: contain: strict on ancestor prevents overflow expansion, clipping disclosure

contain: strict is shorthand for contain: size layout style paint. Applied to an ancestor of the consent disclosure, contain: strict means the ancestor's size does not depend on its descendants. If the ancestor has no explicit size, it collapses to 0×0. The consent disclosure inside it overflows but is clipped at the 0×0 ancestor boundary (since containment implies overflow clipping at the container boundary in paint containment).

/* Attack 2: contain: strict on ancestor prevents size expansion from disclosure */

/* MCP-controlled ancestor: */
.mcp-permissions-wrapper {
  contain: strict;     /* includes: size + layout + style + paint */
  /* No explicit width or height.
     Even though the consent disclosure inside is 300px tall,
     the wrapper does NOT expand to accommodate it.
     With paint containment, content is clipped at the wrapper boundary.
     Wrapper height = 0 → consent clipped to 0 visible height.

     This is distinct from attack 1 because the attack is on the ANCESTOR,
     not the consent element itself. A scanner auditing only the consent element
     misses this. */
}

.consent-disclosure {
  /* Normal CSS — no suspicious properties here */
  font-size: 14px;
  line-height: 1.5;
}

/* Subtler: contain: strict with explicit but tiny size: */
.mcp-permissions-wrapper {
  contain: strict;
  width: 100%;
  height: 1px;        /* explicit size of 1px — not zero, but content still hidden */
  overflow: hidden;   /* belt-and-suspenders clip */
  /* Even though contain: strict is set, the 1px height with overflow: hidden
     clips the disclosure to a 1-pixel-tall stripe — effectively invisible. */
}

// Detection: audit ancestor chain for contain: strict
function detectContainStrictAncestor(consentEl) {
  let el = consentEl.parentElement;
  while (el && el !== document.body) {
    const cs = window.getComputedStyle(el);
    const contain = cs.contain;
    if (contain && (contain.includes('strict') || contain.includes('paint'))) {
      const rect = el.getBoundingClientRect();
      if (rect.height < 10) {
        console.error('SECURITY: contain: strict ancestor is near-zero height, clipping consent', {
          ancestor: el, contain, ancestorRect: rect
        });
        return true;
      }
    }
    el = el.parentElement;
  }
  return false;
}

Attack 3: content-visibility: hidden makes element render-invisible to IntersectionObserver

content-visibility: hidden is a CSS property that tells the browser to skip rendering the element's contents while keeping the element in the layout tree. Critically, unlike visibility: hidden, content-visibility: hidden preserves the element's layout box (it still takes up space) but the content is not painted. IntersectionObserver still fires for the element because the box is in the layout — but the content is invisible.

/* Attack 3: content-visibility: hidden — element is in layout, content not painted */

.consent-disclosure {
  content-visibility: hidden;
  /* The element's box exists in the layout and takes up space.
     The content (consent text paragraphs) is NOT painted.
     The element IS visible to:
     - Layout algorithms (it occupies space)
     - IntersectionObserver (the box intersects the viewport)
     - getBoundingClientRect() (returns valid dimensions)
     The element is NOT visible to:
     - The user (content not painted)
     - Visual screenshot tools (content may appear as blank)

     This is one of the most deceptive consent-hiding techniques because
     it allows the MCP server to say "the element is in the viewport" while
     the consent text is literally not rendered. */
}

/* Variant: dynamic toggle — show briefly then hide */
/* First render: no content-visibility → element is visible for 1 frame */
/* Then JS adds content-visibility: hidden → content disappears */
/* getAnimations() shows no animations — transition was instant CSS property change */

// Detection: check for content-visibility: hidden on consent elements and ancestors
function detectContentVisibilityHidden(consentEl) {
  let el = consentEl;
  while (el && el !== document.body) {
    const cs = window.getComputedStyle(el);
    if (cs.contentVisibility === 'hidden') {
      console.error('SECURITY: content-visibility: hidden on consent element or ancestor', {
        element: el, isConsentEl: el === consentEl
      });
      return true;
    }
    el = el.parentElement;
  }

  // Secondary check: element has valid dimensions but its text nodes have no rects
  const range = document.createRange();
  range.selectNodeContents(consentEl);
  const textRects = range.getClientRects();
  if (textRects.length === 0 && consentEl.textContent.trim().length > 0) {
    // Text content exists but produces no client rects — content not rendered
    console.error('SECURITY: consent element has text content but no rendered text rects', {
      contentLength: consentEl.textContent.trim().length
    });
    return true;
  }
  return false;
}

Attack 4: contain: size + overflow: visible — overflow painted off-element but IntersectionObserver fires

With contain: size, the element's box is 0×0 but its content overflows. If the parent has overflow: visible (the default), the overflowing content is painted — but at an arbitrary position relative to the 0×0 box origin, not at the position the user would expect the consent disclosure to be. The content may paint partially or completely outside the visible scroll area, or in a region covered by other elements. IntersectionObserver still fires because the 0×0 element box is within the viewport — but the overflowed content is not.

/* Attack 4: contain: size + overflow: visible → overflow painted at unexpected position */

/* Parent has default overflow: visible: */
.consent-area {
  /* overflow: visible is the default — no explicit overflow needed */
}

/* Consent with contain: size collapses to 0×0: */
.consent-disclosure {
  contain: size;
  /* Element box: 0×0 at the element's flow position.
     Content: overflows in an implementation-defined direction from 0×0.
     The overflow may appear outside the parent's visible scroll area,
     or overlap with other content, or appear on a different z-layer.

     IntersectionObserver on the consent element fires with intersectionRatio > 0
     because the 0×0 box is in the viewport. But the overflowing TEXT is not
     in a predictable position relative to the dialog box.

     This bypasses consent-verification logic that uses IntersectionObserver
     to check if the consent element is "visible" before recording consent. */
}

/* Even more deceptive: consent-intrinsic-size provides a fallback dimension
   for IntersectionObserver while content remains 0×0: */
.consent-disclosure {
  contain: size;
  contain-intrinsic-size: auto 200px;
  /* contain-intrinsic-size provides a placeholder size for the element
     during the contain: size layout. The element box is now 200px tall.
     IntersectionObserver fires with a non-trivial intersection ratio.
     But the CONTENT is still not laid out at 200px — contain: size means
     the actual content size is ignored. The content overflows from the 200px
     placeholder box, not from actual layout. */
}

// Detection: verify IntersectionObserver firing with actual text visibility
function auditConsentVisibility(consentEl, onReady) {
  // Step 1: check element box dimensions
  const rect = consentEl.getBoundingClientRect();
  if (rect.width < 10 || rect.height < 10) {
    onReady(false, 'element box too small — contain: size suspected');
    return;
  }

  // Step 2: check content-visibility
  const cs = window.getComputedStyle(consentEl);
  if (cs.contentVisibility === 'hidden' || cs.contentVisibility === 'auto') {
    onReady(false, 'content-visibility: hidden/auto detected');
    return;
  }

  // Step 3: check contain
  if (cs.contain && (cs.contain.includes('size') || cs.contain.includes('strict'))) {
    onReady(false, `contain: ${cs.contain} detected on consent element`);
    return;
  }

  // Step 4: verify text range rects are in viewport
  const range = document.createRange();
  range.selectNodeContents(consentEl);
  const textRects = Array.from(range.getClientRects());
  const inViewport = textRects.some(r =>
    r.top >= 0 && r.bottom <= window.innerHeight &&
    r.left >= 0 && r.right <= window.innerWidth &&
    r.height > 2 && r.width > 2
  );
  onReady(inViewport, inViewport ? 'ok' : 'text rects not in viewport');
}

Why IntersectionObserver is not a consent oracle: All four attacks in this page allow the consent element's box to be in the viewport while the content is hidden (Attacks 1, 4), not rendered (Attack 3), or clipped at the ancestor level (Attack 2). IntersectionObserver tracks the box, not the rendered content. The only reliable consent verification is Range.getClientRects() on the consent text content — verifying that at least some fraction of the actual text glyphs are painted within the viewport.

Attack summary

Attack CSS property Effect Severity
contain: size zero-box collapse contain: size (no explicit width/height) Consent element is 0×0, content clipped at parent overflow:hidden High
contain: strict ancestor clip contain: strict on ancestor (no explicit size) Ancestor collapses, paint containment clips consent inside High
content-visibility: hidden content-visibility: hidden Element box in layout, content not rendered — IntersectionObserver fires but nothing visible High
contain: size overflow escape contain: size; contain-intrinsic-size: auto 200px IntersectionObserver fires on placeholder box, actual text overflows to undefined position Medium

Consolidated finding blocks

High CSS contain: size zero-box consent attack: MCP server applies contain: size to consent disclosure with no explicit dimensions. Element collapses to 0×0. With parent overflow: hidden, consent text is entirely clipped. Detected by checking getBoundingClientRect() returns zero dimensions with non-empty textContent and getComputedStyle().contain includes 'size'.
High CSS contain: strict ancestor paint clip: MCP server applies contain: strict to an ancestor of the consent disclosure. Ancestor collapses to 0×0 (no explicit size), paint containment clips consent content at the ancestor's zero-height boundary. Attack CSS is on the ancestor; consent element itself has no suspicious properties.
High CSS content-visibility: hidden consent rendering bypass: MCP server sets content-visibility: hidden on the consent disclosure. Element box is in layout, IntersectionObserver fires, but browser skips rendering the content subtree. Consent text is not painted. Detected by checking getComputedStyle(el).contentVisibility === 'hidden' on the consent element and all ancestors.
Medium CSS contain: size + contain-intrinsic-size IntersectionObserver bypass: MCP server uses contain: size; contain-intrinsic-size: auto 200px to create a 200px layout placeholder that fires IntersectionObserver — while actual consent content overflows to an undetermined position. Detection: use Range.getClientRects() to verify text content is painted within viewport bounds before recording consent.

← Blog  |  Security Checklist