MCP server CSS contain:paint security: stacking context z-index attack renders consent behind opaque parent, positioned child overflow clip, negative margin outside contain boundary, and JS mousedown contain injection
Published 2026-08-07 — SkillAudit Research
The CSS contain: paint property applies paint containment to an element, which has three key effects: (1) it establishes a new independent formatting context, (2) it creates a new stacking context (like z-index on a positioned element), and (3) it clips the painting of descendants to the element's border box — any descendant that would be painted outside the border box is not rendered. The security relevance arises from effects (2) and (3). When contain: paint is applied to the MCP dialog container, a consent child with position: relative; z-index: -1 is now stacked within the dialog's local stacking context — it renders behind the dialog's own background layer. If the dialog has an opaque background (background: white), the consent child is completely painted over by the dialog's background, making it invisible. Display is block, visibility is visible, opacity is 1, getBoundingClientRect() reports a normal in-bounds position — all standard checks pass.
This attack is distinct from content-visibility attacks (which skip rendering entirely, making getBoundingClientRect() return zeros) and from contain:size attacks (which affect layout size calculation). The contain: paint property is supported in Chrome 52+, Firefox 69+, Safari 15.4+. See also CSS isolation attacks for the related isolation: isolate stacking context pattern.
Detection gap: The consent element's own computed styles appear normal: display: block, visibility: visible, opacity: 1, getBoundingClientRect() in-bounds. The attack is on the parent element's contain value combined with the consent child's z-index: -1. The correct audit checks getComputedStyle(ancestor).contain for 'paint' (or 'strict'/'content' which imply paint containment) and cross-references against any consent child with negative z-index.
Attack 1: Parent contain:paint + consent z-index:-1 + parent opaque background — consent painted behind background layer (SA-CSS-CPNT-001)
The dialog container has contain: paint, position: relative, and background: #ffffff. The consent element has position: relative and z-index: -1. Without contain: paint, the consent's z-index: -1 would stack it behind the root stacking context — it would appear behind the page background but in front of the dialog's own background (because the dialog doesn't create a stacking context by default). With contain: paint, the dialog becomes its own stacking context root. Now z-index: -1 on the consent child stacks it behind the dialog container's own background layer within that local context. The dialog's background: #ffffff paints over the consent. Result: consent is invisible but display: block, in-bounds BCR, and textContent returns full consent text.
/* MCP attack — on the dialog container: */
.mcp-dialog {
contain: paint; /* establishes new stacking context */
position: relative;
background: #ffffff; /* opaque — paints over z-index:-1 child */
}
/* On the consent element: */
.consent-disclosure {
position: relative;
z-index: -1; /* behind parent's stacking context background layer */
/* Without contain:paint on parent: z-index:-1 goes behind ROOT context
With contain:paint on parent: z-index:-1 goes behind PARENT's background
Parent background paints over consent → invisible
getComputedStyle(el).display: 'block' ← passes
getComputedStyle(el).visibility: 'visible' ← passes
getComputedStyle(el).opacity: '1' ← passes
getBoundingClientRect(): normal ← passes
el.textContent: consent text ← passes */
}
// Detection:
function detectContainPaintZIndex(el) {
const cs = window.getComputedStyle(el);
const zi = parseInt(cs.zIndex, 10);
if (!isNaN(zi) && zi < 0) {
let ancestor = el.parentElement;
while (ancestor) {
const acs = window.getComputedStyle(ancestor);
const contain = acs.contain || '';
const hasPaintContain = contain.includes('paint') || contain === 'strict' || contain === 'content';
if (hasPaintContain) {
const bg = acs.backgroundColor;
const isOpaque = bg && !bg.includes('rgba(0, 0, 0, 0)') && !bg.includes('transparent');
if (isOpaque) {
console.error('SA-CSS-CPNT-001: contain:paint ancestor with opaque background covers z-index:-1 consent', {
el, zIndex: zi, ancestor, contain, backgroundColor: bg
});
}
}
ancestor = ancestor.parentElement;
}
}
}
Attack 2: contain:paint clips absolute-positioned consent outside border box — painting boundary enforced (SA-CSS-CPNT-002)
contain: paint enforces that no descendant is painted outside the element's border box — unlike overflow: visible (the default without containment) which allows descendants to paint outside their parent's bounds. The MCP server sets the dialog to contain: paint and positions the consent element with position: absolute; top: -200px. Without containment, this would place the consent 200px above the dialog (outside the dialog box) and still painted in the viewport. With contain: paint, the painting is clipped at the dialog's border box — the consent 200px above the dialog's top edge is not painted. The element exists in the DOM and has a valid BCR position — it is just not visually rendered because it is outside the contain boundary.
/* MCP attack: */
.mcp-dialog {
contain: paint; /* clips painting to border box */
position: relative;
height: 300px;
}
.consent-disclosure {
position: absolute;
top: -200px; /* 200px above dialog's top border edge */
/* Without contain:paint: consent appears 200px above dialog (visible in viewport)
With contain:paint: consent's painting is clipped at dialog's border-box top
→ consent is in the DOM but not rendered
getBoundingClientRect().top: negative (above dialog top)
textContent: full consent string
getComputedStyle().display: 'block' */
}
/* Variant: right side overflow */
.consent-disclosure {
position: absolute;
left: 200%; /* 200% of dialog width to the right */
/* contain:paint clips painting at dialog right edge
Element exists in DOM and has in-DOM position
Visual painting clipped at the contain boundary */
}
// Detection:
function detectContainPaintClipping(el) {
const elRect = el.getBoundingClientRect();
let ancestor = el.parentElement;
while (ancestor) {
const acs = window.getComputedStyle(ancestor);
const contain = acs.contain || '';
if (contain.includes('paint') || contain === 'strict' || contain === 'content') {
const parentRect = ancestor.getBoundingClientRect();
// Check if consent is outside contain boundary
if (elRect.bottom < parentRect.top || elRect.top > parentRect.bottom ||
elRect.right < parentRect.left || elRect.left > parentRect.right) {
console.error('SA-CSS-CPNT-002: contain:paint ancestor clips consent outside border box', {
el, elRect, ancestor, parentRect, contain
});
}
}
ancestor = ancestor.parentElement;
}
}
Attack 3: contain:paint with negative margin moves consent outside paint boundary — textContent present, rendering absent (SA-CSS-CPNT-003)
Within a contain: paint container, the consent element uses a large negative margin to shift its position outside the container's border box: margin-top: -500px. The negative margin pulls the element's layout box upward, placing it above the contain parent's top border edge. The parent's contain: paint enforces that painting stops at the border edge — the consent is not rendered even though it occupies a layout position. This technique exploits the distinction between the CSS layout model (where the element has a valid position) and the CSS paint model (where containment clips the rendering). Unlike position: absolute with negative top (which removes the element from normal flow), margin-top: -500px in normal flow shifts the element while keeping its flow contribution — subsequent elements in the dialog may shift upward as well, which is why the MCP server typically uses this on the last element in the dialog or in a separate invisible layer.
/* MCP attack: */
.mcp-dialog {
contain: paint;
position: relative;
}
.consent-disclosure {
margin-top: -500px; /* pulls element 500px above its normal flow position */
/* If normal flow position is near the top of the dialog:
negative margin moves element above dialog top border edge
contain:paint clips painting at border edge
Element exists in flow (affects layout), not rendered visually
getBoundingClientRect().top: below viewport (computed from layout position)
scrollHeight: includes consent element's height */
}
// Detection:
function detectContainNegativeMargin(el) {
const cs = window.getComputedStyle(el);
const mt = parseFloat(cs.marginTop);
const mb = parseFloat(cs.marginBottom);
if (mt < -50 || mb < -50) {
let ancestor = el.parentElement;
while (ancestor) {
const acs = window.getComputedStyle(ancestor);
const contain = acs.contain || '';
if (contain.includes('paint') || contain === 'strict' || contain === 'content') {
const elRect = el.getBoundingClientRect();
const parentRect = ancestor.getBoundingClientRect();
if (elRect.top < parentRect.top - 5 || elRect.bottom > parentRect.bottom + 5) {
console.error('SA-CSS-CPNT-003: contain:paint + large negative margin clips consent', {
el, marginTop: mt, marginBottom: mb, ancestor, contain
});
}
}
ancestor = ancestor.parentElement;
}
}
}
Attack 4: JS mousedown injects contain:paint on parent + z-index:-1 on consent — stacking context attack at install click (SA-CSS-CPNT-004)
At page load, the dialog has no contain property and the consent element has no z-index. Everything renders normally; load-time audit passes. At mousedown on the install button, JS sets dialogEl.style.contain = 'paint' and simultaneously sets consentEl.style.zIndex = '-1'. Because contain: paint creates a new stacking context immediately, the consent child's z-index: -1 now resolves within the dialog's local context — and the dialog's opaque background paints over it. Both property changes happen before the browser repaints. The consent disappears at the moment of the install click. MutationObserver on both elements' style attributes detects the coordinated change.
/* Baseline CSS — loads normally: */
.mcp-dialog {
background: #ffffff;
/* No contain property — no stacking context */
}
.consent-disclosure {
/* No z-index — in normal stacking order */
}
// MCP JS — triggers at install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const dialog = document.querySelector('.mcp-dialog');
const consent = document.querySelector('.consent-disclosure');
if (dialog && consent) {
dialog.style.contain = 'paint'; /* establishes stacking context */
consent.style.zIndex = '-1'; /* now behind dialog background */
consent.style.position = 'relative';
/* Coordinated: both applied in same task before repaint
consent immediately rendered behind dialog's opaque background
Looks instantaneous — no transition needed */
}
}, { capture: true });
// Detection:
function detectDynamicContainPaint() {
document.querySelectorAll('.mcp-dialog, [class*="dialog"], [class*="modal"]').forEach(dialog => {
const observer = new MutationObserver(() => {
const cs = window.getComputedStyle(dialog);
const contain = cs.contain || '';
if (contain.includes('paint') || contain === 'strict' || contain === 'content') {
dialog.querySelectorAll('.consent-disclosure, [data-consent]').forEach(el => {
const elCs = window.getComputedStyle(el);
const zi = parseInt(elCs.zIndex, 10);
if (!isNaN(zi) && zi < 0) {
console.error('SA-CSS-CPNT-004: JS added contain:paint + z-index:-1 at interaction', {
dialog, consent: el, contain, zIndex: zi
});
}
});
}
});
observer.observe(dialog, { attributes: true, attributeFilter: ['style'] });
document.querySelector('#install-btn, [data-action="install"]')
?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
});
}
Root detection method for all contain:paint attacks: Walk the ancestor chain of each consent element and check getComputedStyle(ancestor).contain for values containing 'paint', 'strict', or 'content' (all three imply paint containment). When a paint-containing ancestor is found, check: (1) does the consent element have a negative z-index? (2) is the consent's BCR outside the ancestor's BCR boundary? (3) does the ancestor have an opaque background? Any positive check is a finding. Also monitor for JS-injected contain: paint via MutationObserver on dialog elements. SkillAudit checks the full ancestor chain for containment properties on every consent element audit.
Attack summary
| ID | CSS / JS technique | el.display | el.zIndex | ancestor.contain | Severity |
|---|---|---|---|---|---|
| SA-CSS-CPNT-001 | Parent contain:paint + opaque background + consent z-index:-1 | 'block' | -1 | 'paint' | High |
| SA-CSS-CPNT-002 | Parent contain:paint clips absolute-positioned consent outside border box | 'block' | auto | 'paint' | High |
| SA-CSS-CPNT-003 | Parent contain:paint + consent margin-top:-500px outside paint boundary | 'block' | auto | 'paint' | High |
| SA-CSS-CPNT-004 | JS adds contain:paint to parent + z-index:-1 to consent at mousedown | 'block' | -1 (after) | 'paint' (after) | High |
Consolidated finding blocks
contain: paint establishes an independent stacking context. Consent child with z-index: -1 falls behind the parent's background layer. Opaque background covers the consent. All consent-element checks pass: display: block, visibility: visible, opacity: 1, in-bounds BCR. Only ancestor contain check combined with negative z-index cross-reference reveals the attack.
top: -200px). Paint containment clips rendering at the parent's border edge. Element has valid DOM position and non-empty textContent; its BCR top coordinate is negative, indicating it is above the contain boundary.
CSS content-visibility security | CSS contain:size security | CSS isolation security | Security Checklist