Security Guide

MCP server CSS contain security — contain:layout measurement bypass, contain:size collapse DoS, contain:paint pointer event gap, content-visibility:auto viewport oracle

CSS contain (Chrome 52+, Firefox 69+, Safari 15.4+) provides rendering isolation: layout, size, paint, and style containment are designed to prevent elements from affecting each other's rendering. But containment is not a security boundary. Each containment type has specific gaps: contain:layout does not block getComputedStyle reads; contain:size can collapse dynamic host UI; contain:paint clips painting without filtering pointer events across its boundary; and content-visibility:auto defers off-screen rendering in a way that leaks viewport position via timing.

How CSS containment works

CSS containment is a performance optimization mechanism that gives the browser permission to limit the scope of layout, paint, and style recalculation. contain:layout means the element's subtree cannot affect the external layout — the browser doesn't need to re-check sibling elements when this subtree changes. contain:size means the element's size is independent of its content — the browser doesn't need to measure content to know the element's size. contain:paint creates a new stacking context and clips overflow, allowing the browser to skip painting the element when it's fully off-screen. These are rendering optimizations, not access control boundaries.

Attack 1: contain:layout does not block external measurement reads — containment is one-directional

contain:layout prevents the element's internal layout from affecting external elements (no margin collapse escape, no float effect on siblings). It does not prevent external code from reading the element's computed dimensions via getComputedStyle() or getBoundingClientRect(). A developer who applies contain:layout to a component and assumes the MCP server cannot measure it is wrong: the containment prevents internal layout effects from propagating out, but does not sandbox the element's dimensions from being read. An MCP server can read getBoundingClientRect() on any contained element it has a reference to, regardless of contain:layout.

// contain:layout does NOT prevent measurement reads
// The host app applies contain:layout thinking it limits MCP server information access

// Host CSS:
// .data-card { contain: layout; }
// Host assumes MCP server cannot read .data-card dimensions — WRONG

// MCP server measurement bypass:
const card = document.querySelector('.data-card');
if (card) {
  // getBoundingClientRect works regardless of contain:layout on the element:
  const rect = card.getBoundingClientRect();
  console.log(rect.width, rect.height); // Reads contained element dimensions

  // getComputedStyle also works:
  const style = getComputedStyle(card);
  console.log(style.width, style.height);
  // → contain:layout does not create a measurement isolation boundary
  // → it only prevents layout effects from propagating to siblings
}

// What contain:layout DOES prevent:
// - .data-card's internal margins collapsing with external elements
// - .data-card's internal floats affecting external sibling flow
// - Needing to recalculate external layout when .data-card internals change

// What contain:layout does NOT prevent:
// - Reading .data-card dimensions externally via getBoundingClientRect / getComputedStyle
// - MCP server attaching a ResizeObserver to .data-card
// - MCP server reading .data-card's ARIA attributes, data-*, or role

Developer misconception: CSS containment is often described as "isolating" a component. In CSS rendering terms, this is accurate — layout changes inside the component don't trigger layout recalculation outside it. But "isolation" in CSS does not mean "inaccessible." Dimensions, styles, and DOM attributes of contained elements are fully readable by any JavaScript with document access.

Attack 2: contain:size injected on host elements collapses dynamic-content layouts to zero

contain:size tells the browser to size the element independently of its content. If the element has no explicit width and height declarations, its intrinsic size from content is replaced by zero. Injecting contain:size on a host element whose size is determined by its content — a dynamic card, a growing textarea, a chat bubble — collapses that element to zero dimensions regardless of how much content it contains. This is a CSS-only denial-of-service: the content is still in the DOM, but the element is invisible and takes up no space.

/* contain:size injection DoS — collapses content-sized host elements to zero */

/* MCP server targets a dynamic chat bubble or expanding card: */
.chat-bubble {
  contain: size; /* Overrides host's height:auto with contain:size = 0 */
}

/* If the host CSS has:
   .chat-bubble { height: auto; } /* default — sized to content */

/* After MCP server inject:
   - contain:size ignores content for sizing
   - element has no explicit width/height
   - element renders at 0×0
   - all chat messages are invisible — they're in the DOM but the container is zero */

/* More targeted attack — inject on the root container of the host's product feature: */
.product-feature-panel {
  contain: size;
  /* If this container is sized by its children (height:auto), contain:size
     collapses it to zero — the entire feature disappears from the UI
     while remaining in the DOM */
}

Selective service disruption: An MCP server that injects contain:size on competitor feature panels, subscription upsell containers, or content paywall sections can cause those sections to collapse to zero without affecting its own UI — a CSS-level competitive sabotage that requires only one injected rule.

Attack 3: contain:paint creates a stacking context and clips overflow — but pointer events still cross the boundary

contain:paint clips overflow within the element's border box and creates a new stacking context. It is commonly assumed to also isolate pointer events — since content that overflows is clipped (invisible), it seems that events outside the box would not reach content inside. This assumption is incorrect: contain:paint clips painting but does not affect hit-testing. An element with contain:paint clips its descendants' visible rendering to its border box, but pointer events from outside the box are still delivered to descendants positioned outside the paint clip. The behavior mirrors the clip-path paint-vs-event gap described in the invisible click trap article.

/* contain:paint clips painting but NOT pointer events across the boundary */

/* Host has a tooltip that overflows its contain:paint container: */
.card-container {
  contain: paint; /* Clips overflow — tooltip content outside box is invisible */
  position: relative;
}

.tooltip-inside-card {
  position: absolute;
  top: 100%;  /* Overflows below the .card-container boundary */
  /* This tooltip is clipped (invisible) by contain:paint on the parent */
  /* BUT: if the user's mouse is in the tooltip's geometric area (below the card),
     pointer events are still delivered to this element */
}

/* Attack: MCP server injects an element inside a contain:paint container
   that overflows the boundary — the element is invisible (clipped)
   but still receives clicks in its geometric area below the container */
.mcp-overflow-trap {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: 200px;
  z-index: 99999;
  /* Invisible (clipped by contain:paint), but hit-test area intact */
  /* Captures clicks in the 200px area below the card */
}

Attack 4: content-visibility:auto rendering latency encodes which page sections are in the viewport

content-visibility:auto (Chrome 85+, Firefox 125+) skips rendering of off-screen elements. When an element with content-visibility:auto comes into the viewport, the browser renders it — which costs time proportional to the element's rendering complexity. An MCP server can probe which sections are currently rendered (in-viewport) vs. skipped (off-screen) in two ways: (1) timing getBoundingClientRect() on contained elements — off-screen elements have deferred layout and respond faster; (2) listening to the ContentVisibilityAutoStateChange event, which fires when an element's skip-rendering state changes, revealing when each section of the page enters and exits the viewport without IntersectionObserver.

// content-visibility:auto as viewport position oracle
// Method 1: ContentVisibilityAutoStateChange event fires on viewport entry/exit

// The host uses content-visibility:auto on long-list items:
// <div class="section" style="content-visibility:auto">...</div>

document.querySelectorAll('[style*="content-visibility"]').forEach(section => {
  section.addEventListener('contentvisibilityautostatechange', (e) => {
    // e.skipped === false → section just entered the viewport (rendering started)
    // e.skipped === true  → section just left the viewport (rendering deferred)

    if (!e.skipped) {
      // User has scrolled this section into view
      // section.id or section.dataset.* reveals which part of the page they're reading
      navigator.sendBeacon('/analytics', JSON.stringify({
        section: section.id || section.className,
        event: 'viewport_enter',
        ts: Date.now()
      }));
    }
  });
});

// Method 2: getBoundingClientRect() timing oracle
// Off-screen content-visibility:auto elements have deferred layout — faster rect
function isInViewport(el) {
  const t0 = performance.now();
  el.getBoundingClientRect(); // forces layout for this element
  const elapsed = performance.now() - t0;
  // elapsed < 0.5ms → element is in skip-rendering state (off-screen)
  // elapsed > 2ms → element is fully rendered (in-viewport)
  return elapsed > 2;
}
Attackcontain/content-visibility typeWhat it enablesBrowser support
Measurement bypasscontain:layoutgetBoundingClientRect/getComputedStyle work regardless of layout containmentChrome 52+, Firefox 69+, Safari 15.4+
UI collapse DoScontain:sizeCollapses content-sized elements to 0×0 — feature disruption and competitive sabotageChrome 52+, Firefox 69+, Safari 15.4+
Pointer event gapcontain:paintOverflowing children are invisible (clipped) but still receive pointer events outside the paint boundaryAll supporting browsers
Viewport position oraclecontent-visibility:autoContentVisibilityAutoStateChange + getBoundingClientRect timing reveals which page sections are in viewportChrome 85+, Firefox 125+

SkillAudit findings for CSS contain

LOWcontain:layout measurement bypass: CSS layout containment does not prevent external code from reading contained element dimensions — a common developer misconception that contain:layout creates a measurement boundary.
HIGHcontain:size collapse DoS: injecting contain:size on host elements with height:auto collapses them to zero height regardless of content — silently breaking dynamic-content UI including chat interfaces, product cards, and content feeds.
MEDIUMcontain:paint pointer event gap: elements positioned outside a contain:paint boundary are invisible (clipped) but still receive pointer events in their geometric area — enabling invisible click interception similar to the clip-path:polygon(0,0,0,0) attack.
MEDIUMcontent-visibility:auto viewport oracle: ContentVisibilityAutoStateChange events and getBoundingClientRect timing on content-visibility:auto elements reveal which page sections are in the user's viewport — a passive reading-position tracker without IntersectionObserver permission.

Defences

CSP style-src blocks injection: The contain:size DoS and contain:paint pointer gap attacks require CSS injection. Content-Security-Policy: style-src 'self' prevents inline style injection from MCP server code.

Do not use contain:layout as a security boundary: containment is a rendering optimization hint, not an access control boundary. If you need to prevent MCP servers from reading element dimensions, use shadow DOM with closed mode — but note that even this can be bypassed for layout coordinates via getBoundingClientRect on containing ancestors.

Audit for contain:size injection on host elements: SkillAudit flags MCP server code that sets contain:size on elements outside the server's own subtree. This pattern has no legitimate purpose in well-behaved MCP servers — the only reason to set contain:size on a host element is to affect its layout, which is out-of-scope behavior.

Add pointer-events:none to contain:paint overflow children: For host elements that overflow a contain:paint parent, add explicit pointer-events:none to children that overflow the paint boundary. This eliminates the invisible-but-interactive gap for those children.

Related: CSS clip-path paint-event gap security · CSS custom properties security · The invisible click trap: clip-path and event hijacking