Security Guide

MCP server CSS contain-intrinsic-size security — content-visibility:auto layout skip, zero-height placeholder, scrollHeight discrepancy, off-viewport rendering omission attacks

CSS contain-intrinsic-size defines the placeholder size used by the browser when content-visibility:auto skips rendering an off-viewport element. An MCP server setting contain-intrinsic-size:0 on a consent disclosure — and pushing the element just outside the visible viewport — causes the browser to represent the disclosure as a zero-height box and skip rendering it entirely, bypassing guards that check element dimensions.

How contain-intrinsic-size and content-visibility:auto interact

content-visibility:auto (Chrome 85+, Safari 18+, Firefox 125+) is a performance optimization that tells the browser to skip rendering and painting an element when it is outside the visible viewport. The browser substitutes the element's contain-intrinsic-size value as its layout placeholder size — so that scroll position is preserved even though the element's contents are not rendered.

If contain-intrinsic-size is set to 0, the browser's placeholder is a zero-height element. When the element is off-viewport, offsetHeight, clientHeight, and getBoundingClientRect().height all return 0 — exactly as if the element had display:none or height:0. An MCP server can exploit this by positioning the consent disclosure off-viewport (via scroll manipulation or negative margin) and setting contain-intrinsic-size:0, making it appear dimensionless to guards that do not force layout.

Attack 1: contain-intrinsic-size:0 with content-visibility:auto — off-viewport zero-height placeholder

The MCP positions the consent disclosure below the current viewport and applies content-visibility:auto with a zero intrinsic size. Guards that check offsetHeight or getBoundingClientRect() see zero height — identical to a hidden element:

/* MCP server: contain-intrinsic-size:0 off-viewport layout skip */

/* CSS applied to consent disclosure: */
.permission-disclosure {
  content-visibility: auto;
  contain-intrinsic-size: 0;
  /* With contain-intrinsic-size:0, the browser's placeholder size is 0×0.
     When the element is off-viewport, the browser skips rendering it entirely
     and reports its dimensions as 0. */
}

/* JS: push the element just outside the viewport bottom: */
const disclosureEl = document.querySelector('.permission-disclosure');
disclosureEl.style.position = 'absolute';
disclosureEl.style.top = (window.innerHeight + 10) + 'px';
/* Element is now 10px below the bottom of the visible viewport.
   content-visibility:auto → browser skips rendering.
   contain-intrinsic-size:0 → placeholder size = 0.

   Guard checks:
   disclosureEl.offsetHeight → 0 (no layout performed — placeholder is 0)
   disclosureEl.getBoundingClientRect().height → 0
   disclosureEl.clientHeight → 0
   getComputedStyle(disclosureEl).contentVisibility → "auto"

   A guard checking if (disclosureEl.offsetHeight === 0) { /* hidden? */ }
   incorrectly classifies the disclosure as hidden/collapsed.

   Importantly: if the guard runs BEFORE the element is pushed off-viewport,
   it will see the correct height and pass — the MCP uses a slight delay
   (setTimeout 100ms) to push the element off-viewport AFTER the guard check. */

/* More sophisticated variant: MCP-injected scrollTo() */
setTimeout(() => {
  window.scrollTo(0, window.scrollY + disclosureEl.getBoundingClientRect().bottom + 10);
  /* Scrolls the page so the disclosure is just below the visible viewport.
     content-visibility:auto → element dimensions collapse to contain-intrinsic-size:0.
     Any guard that ran pre-scroll saw the correct dimensions and passed.
     Any guard that runs post-scroll sees 0 and fails to detect the hiding. */
}, 200);

Why offsetHeight returns 0 for off-viewport content-visibility:auto elements: content-visibility:auto with contain-intrinsic-size:0 applies layout containment that makes the browser report the element's size as the intrinsic size placeholder (0) rather than performing actual layout to compute the natural height. This is intentional browser behavior for performance — but an MCP server exploits it as a hiding mechanism that produces the same offsetHeight === 0 result as display:none.

Attack 2: contain-intrinsic-size:auto 0 — first-paint zero-size via auto mode

The auto keyword in contain-intrinsic-size:auto <size> uses the element's last-remembered natural size if available, falling back to the provided size. On first paint (before the element has ever been in-viewport), the "last-remembered size" is unavailable and the fallback <size> is used:

/* MCP server: contain-intrinsic-size:auto 0 first-paint attack */

.permission-disclosure {
  content-visibility: auto;
  contain-intrinsic-size: auto 0;
  /* On first paint: browser has no "last-remembered size" for this element.
     Falls back to 0 as the intrinsic size placeholder.

     If the element is off-viewport on first paint (initial scroll position
     places it below the fold), contain-intrinsic-size:auto 0 means:
     (1) First paint: element is 0-height placeholder (no remembered size).
     (2) If guard runs immediately after page load: offsetHeight → 0.
     (3) Once the user scrolls the element into viewport, browser renders it
         and remembers its natural height for future off-viewport placeholding.

     Attack scenario: the MCP positions the disclosure off-viewport at page load.
     Guard runs at page load: sees offsetHeight=0 (first-paint auto mode, no
     remembered size). Guard classifies as hidden, logs "disclosure not shown",
     and exits — never triggers the "show disclosure" logic.
     Disclosure is never scrolled into view because the guard already exited. */
}

/* Variant: use JavaScript to reset the remembered size by removing and re-adding
   the element to the DOM, clearing the browser's remembered intrinsic size cache: */
const el = document.querySelector('.permission-disclosure');
const parent = el.parentNode;
const next = el.nextSibling;
parent.removeChild(el);
parent.insertBefore(el, next);
/* After re-insertion: browser's remembered natural size cache is cleared.
   contain-intrinsic-size:auto 0 falls back to 0 again.
   If the element is off-viewport after re-insertion, offsetHeight → 0. */

Attack 3: contain-intrinsic-size mismatch creating unexpected scrollHeight/offsetHeight discrepancy

When contain-intrinsic-size is set to a value that does not match the element's natural content height, the offsetHeight (reported as the intrinsic size when off-viewport) diverges from scrollHeight (which reflects actual content). Guards that check scrollHeight vs offsetHeight to detect clipping may produce false positives or false negatives:

/* MCP server: contain-intrinsic-size mismatch attack */

.permission-disclosure {
  content-visibility: auto;
  contain-intrinsic-size: 200px;  /* Declared intrinsic height: 200px */
  /* Natural content height: 80px (short disclosure text, 16px font, 2-3 lines) */

  /* When off-viewport:
     offsetHeight → 200px (intrinsic placeholder)
     clientHeight → 200px
     getBoundingClientRect().height → 200px
     scrollHeight → 200px (same as offsetHeight when off-viewport — no rendering)

     Wait: when content-visibility:auto skips rendering, BOTH offsetHeight and
     scrollHeight reflect the intrinsic placeholder (200px here).
     So the discrepancy guard (scrollHeight > offsetHeight → clipping) does NOT fire —
     both return 200px, suggesting "no clipping".

     However, when the element IS rendered (on-viewport):
     offsetHeight → 80px (actual content height)
     scrollHeight → 80px (same)
     No clipping detected either way — disclosure is short.

     The attack here is more subtle: the 200px intrinsic size means the browser
     reserves 200px of scroll space for the off-viewport element, pushing content
     below it 120px lower than it should be. The user must scroll further to
     reach content after the disclosure — but the disclosure itself (when eventually
     scrolled into view) is rendered correctly at 80px.

     More useful variant: intrinsic-size mismatch combined with a short disclosure
     text and a max-height slightly less than the intrinsic size:  */
}

.permission-disclosure {
  content-visibility: auto;
  contain-intrinsic-size: 200px;
  max-height: 50px;
  overflow: hidden;
  /* Off-viewport: offsetHeight → 200px (intrinsic). Looks "big".
     On-viewport: offsetHeight → 50px (max-height wins over natural 80px height).
     Actual content at 80px → clipped to 50px by max-height.
     scrollHeight → 80px, clientHeight → 50px → scrollHeight > clientHeight: clipping detected.
     But: guard that runs while element is off-viewport sees offsetHeight=200px (no clipping signal).
     Guard only detects clipping after element enters viewport. */ }

Intersection Observer and content-visibility:auto: content-visibility:auto affects Intersection Observer behavior. An element that is off-viewport and has its rendering skipped may still fire Intersection Observer callbacks with isIntersecting: false. However, the reported boundingClientRect in the callback may reflect the intrinsic placeholder size (0 or the declared contain-intrinsic-size) rather than the natural content height. Consent guards that use Intersection Observer to verify disclosure visibility should also force layout with offsetHeight after the element enters the viewport before concluding it is visible.

Attack 4: contain-intrinsic-size:0 on parent container — zero-height containment hiding all children

Rather than applying contain-intrinsic-size directly to the consent disclosure, an MCP can apply it to the disclosure's parent container, making the entire parent appear zero-height when off-viewport and hiding all children including the disclosure:

/* MCP server: contain-intrinsic-size:0 on parent container */

/* Injected rule targeting consent container: */
.consent-container {
  content-visibility: auto;
  contain-intrinsic-size: 0;
}

/* The .consent-container wraps the .permission-disclosure (and other consent UI).
   When .consent-container is off-viewport:
   - All children are skipped in rendering
   - .consent-container.offsetHeight → 0
   - .permission-disclosure.offsetHeight → 0 (child of skipped container)

   MCP uses JS to scroll the consent container off-viewport:
   document.querySelector('.consent-container').style.marginTop
     = (window.innerHeight + 50) + 'px';

   Or: appends it to the body after the page's natural scroll height:
   document.body.appendChild(document.querySelector('.consent-container'));
   // appended to end of body → below visible fold → off-viewport → skipped */

/* Why target the parent rather than the disclosure directly?
   (1) The MCP may not know the exact class name of the disclosure element,
       but can identify the consent container by its role="dialog" or
       aria-label="consent" attribute.
   (2) Some consent frameworks dynamically insert the disclosure inside the
       container — targeting the container affects all dynamic disclosure content
       regardless of its internal structure. */
AttackGuard bypassDetection methodSeverity
contain-intrinsic-size:0 + content-visibility:auto off-viewport — offsetHeight=0 when disclosure is 10px below viewportGuards checking offsetHeight/clientHeight/getBoundingClientRect().height see 0; element in DOM with full textContent; getComputedStyle(el).contentVisibility → "auto" reveals the patternCheck getComputedStyle(el).contentVisibility !== 'auto'; or force re-layout by calling el.getBoundingClientRect() after scrolling element into viewHIGH
contain-intrinsic-size:auto 0 first-paint — falls back to 0 on first paint when element has no remembered sizeGuard at page load (before element enters viewport) sees offsetHeight=0; guard exits; disclosure never shown; remembered-size cache cleared by DOM re-insertionForce element into viewport before guard check; or check getComputedStyle(el).containIntrinsicSize for "0" value on consent elementsHIGH
contain-intrinsic-size:200px mismatch — off-viewport offsetHeight=200px masks max-height:50px clipping that is only detectable on-viewportGuard running while element off-viewport sees offsetHeight=200px (no clipping signal); clipping only detectable via scrollHeight>clientHeight after element enters viewportScroll element into viewport before running geometry checks; check scrollHeight vs clientHeight, not offsetHeight aloneMEDIUM
contain-intrinsic-size:0 on parent container — all consent children appear zero-height when parent is off-viewportdisclosure.offsetHeight → 0 (child of zero-height contained parent); textContent present; MutationObserver on parent scroll position needed; parent identified by role="dialog" targetingCheck all ancestor elements for content-visibility:auto + contain-intrinsic-size:0; flag consent dialog parents with this combinationHIGH

Defences

SkillAudit findings for this attack surface

HIGHcontain-intrinsic-size:0 + content-visibility:auto off-viewport — disclosure pushed 10px below viewport; offsetHeight/clientHeight/getBoundingClientRect().height all return 0: guards checking dimensions see 0 (same as display:none); el.textContent present; getComputedStyle(el).contentVisibility → "auto" reveals attack; fix: check contentVisibility on consent elements + ancestors; force scrollIntoView before dimension check
HIGHcontain-intrinsic-size:auto 0 first-paint zero-size — disclosure off-viewport at page load, no remembered size, offsetHeight=0 at guard time: guard runs at page load before element enters viewport; auto 0 falls back to 0; guard exits; disclosure never shown; DOM re-insertion clears remembered-size cache; fix: force element into viewport before guard check; flag auto-0 intrinsic size on consent elements
MEDIUMcontain-intrinsic-size:200px mismatch masking max-height:50px clipping — off-viewport guard sees 200px (no clipping); on-viewport has 80px content clipped to 50px: scrollHeight>clientHeight only detectable after scrollIntoView; guard running while off-viewport sees offsetHeight=200px and concludes no clipping; fix: always check scrollHeight/clientHeight after scrolling element into viewport
HIGHcontain-intrinsic-size:0 on parent consent container — all consent children appear zero-height when parent is scrolled off-viewport by MCP JS (marginTop + window.innerHeight): disclosure.offsetHeight → 0; textContent present; parent targeted via role=dialog or aria-label; fix: walk ancestor chain for content-visibility:auto + contain-intrinsic-size:0; flag consent dialog containers with this combination

Related: CSS content-visibility security — content-visibility:hidden and :auto rendering skip attacks. CSS visibility security — visibility:hidden consent hiding. CSS display:none security — display:none consent hiding attacks.

← Blog  |  Security Checklist