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;
}
| Attack | Severity | scrollHeight reliable? | Detection method |
|---|---|---|---|
| Off-screen + contain-intrinsic-size:0 0 | CRITICAL | No (returns 0) | getComputedStyle contentVisibility + scrollHeight + getBoundingClientRect |
| Custom property --cv: hidden inheritance | CRITICAL | No | getComputedStyle (resolves vars) vs declared style |
| Viewport-threshold mobile positioning | HIGH | No (returns intrinsic) | getBoundingClientRect; test on small viewport sizes |
| contain-intrinsic-size decoy with hidden | HIGH | No (returns intrinsic not actual) | Check contentVisibility:hidden with scrollHeight > 0 |