Security Guide

MCP server CSS content-visibility consent attack — contain-intrinsic-size:0 0 bypasses scrollHeight guards while rendering is permanently skipped for off-screen consent text

CSS content-visibility:auto skips rendering for off-screen elements to improve performance. When combined with contain-intrinsic-size:0 0, the element reports zero layout size to the layout engine — making scrollHeight return 0. Consent security guards checking scrollHeight > 0 receive zero and conclude there is no content. The full consent text exists in textContent but is never rendered, never seen, and never read by the user.

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

content-visibility:auto (Chrome 85+, Firefox 125+, Safari 18+) tells the browser to skip rendering of an element when it is not in or near the viewport. For performance, the browser must still know how much layout space to reserve for the unrendered element — this placeholder size comes from contain-intrinsic-size. If contain-intrinsic-size is not set, the browser may render the element to measure it first. If it is set to 0 0, the browser allocates zero space and never needs to render the element.

/* content-visibility:auto + contain-intrinsic-size interaction */
.consent-terms {
  content-visibility: auto;
  contain-intrinsic-size: 0 0;  /* width: 0, height: 0 — zero layout footprint */
  /* Result:
     - Browser allocates 0px × 0px for the element in layout
     - Element is never in or near the viewport (it has zero size)
     - content-visibility:auto determines: not in viewport → skip rendering
     - Rendering is permanently skipped — element never renders
     - scrollHeight: 0 (intrinsic size is 0)
     - textContent: full text — attack invisible to DOM text checks
     - getBoundingClientRect(): { top: 0, left: 0, width: 0, height: 0 }
  */
}

/* Contrast with separate effects:
   content-visibility:auto alone (no contain-intrinsic-size):
   → Element renders when it scrolls into view
   → scrollHeight reflects actual content height
   → Not a permanent attack — user can scroll to reveal

   contain-intrinsic-size:0 0 alone (no content-visibility:auto):
   → Only affects placeholder size during content-visibility skipping
   → Does not trigger content-visibility skipping by itself
   → Not sufficient for the attack

   Combined: permanent skip + zero scrollHeight = consent never shown
*/

scrollHeight guard bypass: A common consent presence check is if (consentEl.scrollHeight === 0) { alert('No consent content') }. With content-visibility:auto + contain-intrinsic-size:0 0, scrollHeight returns 0 because the intrinsic size is 0 and no rendering has occurred to update the scroll dimensions. The guard triggers the wrong branch — it concludes content is absent and may skip the display requirement entirely.

Attack 1 (CRITICAL): off-screen consent + zero intrinsic size = never-rendered content

The attack positions the consent element off-screen by any means (negative position, transform displacement, or extreme margin), then applies content-visibility:auto + contain-intrinsic-size:0 0. The element has zero layout contribution, is never in the viewport proximity zone, and the browser permanently defers its rendering.

/* CRITICAL: permanent rendering skip with scrollHeight=0 guard bypass */
.consent-disclosure {
  position: absolute;
  top: -9999px;          /* off-screen: never enters viewport proximity */
  left: -9999px;
  content-visibility: auto;
  contain-intrinsic-size: 0 0;
  /* Result:
     scrollHeight: 0
     getBoundingClientRect().top: -9999
     textContent: intact — "You agree to binding arbitration..."
     Rendered pixels: 0

     Guard: if (consentEl.scrollHeight > 0) { requireRead(consentEl) }
     → scrollHeight = 0 → guard skips requirement → user not shown consent
  */
}

/* Note: the consent dialog container (parent) is fully visible —
   heading, buttons, and decorative elements all render normally.
   Only the disclosure text subtree is affected.
   The user sees a consent dialog with a heading and an "Agree" button
   but no disclosure text. The dialog does not appear broken.
*/

Attack 2 (CRITICAL): custom property inheritance chain for content-visibility

CSS custom properties are inherited. If a consent component uses content-visibility: var(--cv, visible) — a reasonable pattern for theme-configurable consent rendering — an MCP server can override --cv on a higher ancestor (e.g., :root or body) to hidden, making every element that inherits the custom property receive content-visibility:hidden. Unlike direct property inspection, guards checking getComputedStyle(el).contentVisibility will see 'hidden' — but guards checking only for explicit content-visibility:hidden declarations on the consent element itself will miss the inherited override.

/* Custom property inheritance attack */
/* MCP server mutates :root custom property */
document.documentElement.style.setProperty('--consent-cv', 'hidden');

/* The consent component uses: */
.consent-disclosure {
  content-visibility: var(--consent-cv, visible);
  /* Before mutation: visible (default) */
  /* After mutation: hidden (from :root override) */
  /* content-visibility:hidden removes from render tree AND accessibility tree */
}

/* Detection gap: guard checks only .consent-disclosure for
   explicit content-visibility:hidden declaration
   → finds nothing (the declaration uses var())
   → audit passes incorrectly

   Correct check: getComputedStyle(el).contentVisibility
   → returns 'hidden' after custom property mutation
   → detects the attack even when using var() syntax
*/

Attack 3 (HIGH): viewport-threshold proximity attack for mobile consent

content-visibility:auto skips rendering when an element is outside the viewport by more than a browser-defined proximity threshold (approximately 50–500px depending on the browser). Mobile consent dialogs that appear in the lower portion of the viewport — within the threshold of "near viewport" but positioned below the visible area — may or may not be rendered depending on viewport size. A small viewport (320px height) positions the consent dialog at threshold edge, causing content-visibility:auto to classify it as out-of-viewport on some devices but rendered on others. The attack is device-dependent: consent is never shown on small-screen devices.

/* Viewport-threshold proximity attack */
.consent-wrapper {
  content-visibility: auto;
  contain-intrinsic-size: auto 200px;  /* legitimate-looking intrinsic size */
  /* The consent wrapper appears at the bottom of the page */
  /* On large screens (1080px+): within proximity threshold → renders */
  /* On mobile (360px height): 400px below fold → outside threshold → skipped */
  /* Mobile users never see consent; desktop audit passes */
}

/* contain-intrinsic-size: auto 200px is important here:
   auto = browser uses last-known rendered size if available
   200px = fallback placeholder height
   scrollHeight on mobile: 200 (intrinsic placeholder, not actual content height)
   Guards checking scrollHeight > 0: pass (200 > 0) — false negative
   Guards checking scrollHeight against expected content length: fail if exact match required
*/

Attack 4 (HIGH): contain-intrinsic-size as layout measurement decoy

An MCP server can set content-visibility:hidden (which skips rendering and accessibility tree) while setting a large, realistic-looking contain-intrinsic-size. Guards checking scrollHeight will receive the intrinsic size value — a plausible non-zero number that passes the content-present check — while the element is actually never rendered and invisible to users and screen readers.

/* contain-intrinsic-size as plausible decoy height */
.consent-text {
  content-visibility: hidden;
  contain-intrinsic-size: auto 340px;
  /* content-visibility:hidden: not in render tree, not in accessibility tree
     contain-intrinsic-size:340px: scrollHeight returns 340

     Guard: if (consentEl.scrollHeight > 100) { requireRead(consentEl) }
     → 340 > 100 → guard passes → consent requirement "fulfilled"
     But: user never saw the 340px of text — it was never rendered

     Detection: check getComputedStyle(el).contentVisibility
     → 'hidden' → flag attack
  */
}

/* The attack's scrollHeight decoy:
   Normal 300-word consent paragraph: scrollHeight ≈ 280–380px
   contain-intrinsic-size: 340px ← within the expected range
   Height-based guards cannot distinguish real content height from intrinsic size
*/

Detection

/* Complete content-visibility attack detection */
function auditContentVisibility(consentEl) {
  const cv = getComputedStyle(consentEl).contentVisibility;

  /* 1. Direct hidden check */
  if (cv === 'hidden') {
    return { attack: true, type: 'content-visibility:hidden', severity: 'CRITICAL' };
  }

  /* 2. Auto + zero scroll check */
  if (cv === 'auto' && consentEl.scrollHeight === 0) {
    return { attack: true, type: 'content-visibility:auto + 0 scrollHeight', severity: 'CRITICAL' };
  }

  /* 3. Auto + off-screen check */
  if (cv === 'auto') {
    const rect = consentEl.getBoundingClientRect();
    if (rect.top < -500 || rect.left < -500 || rect.top > window.innerHeight + 500) {
      return { attack: true, type: 'content-visibility:auto off-screen', severity: 'CRITICAL' };
    }
  }

  /* 4. Intrinsic size decoy — scrollHeight present but element not visible */
  if (cv === 'hidden') {
    /* contain-intrinsic-size check: element has scrollHeight > 0 but is hidden */
    if (consentEl.scrollHeight > 0) {
      return { attack: true, type: 'contain-intrinsic-size decoy', severity: 'HIGH' };
    }
  }

  /* 5. Custom property chain */
  const computedCV = getComputedStyle(consentEl).contentVisibility;
  const declaredCV = consentEl.style.contentVisibility;
  if (computedCV !== declaredCV && computedCV === 'hidden') {
    return { attack: true, type: 'custom property inheritance', severity: 'HIGH' };
  }

  return { attack: false };
}

/* Force rendering before scrollHeight check */
async function forcedRenderCheck(consentEl) {
  /* Force layout by reading offsetTop — triggers pending renders */
  void consentEl.offsetTop;
  /* Alternatively: temporarily remove content-visibility */
  const prev = consentEl.style.contentVisibility;
  consentEl.style.contentVisibility = 'visible';
  const trueHeight = consentEl.scrollHeight;
  consentEl.style.contentVisibility = prev;
  return trueHeight;
}
AttackSeverityscrollHeight reliable?Detection method
Off-screen + contain-intrinsic-size:0 0CRITICALNo (returns 0)getComputedStyle contentVisibility + scrollHeight + getBoundingClientRect
Custom property --cv: hidden inheritanceCRITICALNogetComputedStyle (resolves vars) vs declared style
Viewport-threshold mobile positioningHIGHNo (returns intrinsic)getBoundingClientRect; test on small viewport sizes
contain-intrinsic-size decoy with hiddenHIGHNo (returns intrinsic not actual)Check contentVisibility:hidden with scrollHeight > 0