CSS contain and content-visibility: How MCP Servers Use Rendering Shortcuts to Hide Security Content from the Render Pipeline
The CSS rendering pipeline has several phases: style calculation, layout, paint, and compositing. Most CSS security discussions focus on the style and paint phases — opacity, color, visibility, display. But two properties — contain and content-visibility — operate at the architectural level of the pipeline itself. They tell the browser which elements are worth computing layout for, which are worth painting, and which should be included in the accessibility tree. When an MCP server controls these properties in a consent dialog, it can instruct the browser to skip rendering the security disclosure entirely, at the pipeline level, before any element-specific property is evaluated.
This is a fundamentally different attack surface than, say, opacity:0. An opacity:0 attack produces an element that is laid out, composited, and present in the accessibility tree — it is simply rendered with zero alpha. A content-visibility attack produces an element that the browser has been told is not worth computing. The element exists in the DOM, but the rendering engine does not build a layout box for it. Screen readers, which rely on the accessibility tree (not the DOM), cannot reach it. Automated tests that check computed styles find... nothing, because there are no computed styles to check.
These properties were introduced to make large, complex pages faster. A long list of off-screen items can set content-visibility:auto to defer rendering until they scroll into view, reducing initial page load time. On a news feed with ten thousand items, this is an enormous optimization. On a consent dialog with a 200-word security disclosure, it is a mechanism for erasing that disclosure from the browser's rendering work entirely.
Key insight: content-visibility:hidden and contain:layout/contain:strict are performance properties that operate below the visibility layer. An element with content-visibility:hidden is invisible to screen readers, invisible in paint, and produces no layout — yet its display, opacity, and visibility computed styles may all appear normal. Scanners that check only element-level visual properties will not detect these attacks.
Background: what contain and content-visibility actually do
contain establishes a containment context. When you set contain:layout on an element, you tell the browser: "the layout of this element's children does not affect anything outside this element's box." This is an optimization hint that lets the browser skip re-layout of the rest of the page when the element's children change. The containment types are: layout (layout scope), paint (paint scope, also establishes a stacking context), style (CSS counters and custom properties scope), size (the element's size does not depend on its children), and inline-size. The value strict enables layout, paint, and style all at once. The value content enables layout, paint, and style but not size.
content-visibility controls whether an element renders its content at all. The three values are: visible (normal rendering, the default), hidden (the element does not render its content — equivalent to visibility:hidden on all descendants, AND removes them from the accessibility tree), and auto (the browser defers rendering of the element's contents until they are near the viewport; elements outside the current scroll area are skipped; this is the performance optimization for long lists).
content-visibility:auto requires contain:size to work correctly — the browser needs to know the element's size without rendering its contents, so it can estimate the scroll position. You provide this via contain-intrinsic-size, which gives the browser a placeholder size to use before the content renders. This interaction — content-visibility:auto + contain-intrinsic-size — is what MCP servers can manipulate to place the security disclosure in a perpetually off-screen state.
Attack 1: content-visibility:hidden — removing the disclosure from rendering and the accessibility tree simultaneously
The most direct attack: apply content-visibility:hidden to the security disclosure element. The element's content is not rendered, not painted, and not included in the accessibility tree. It is present in the DOM and has computed styles, but the browser treats its contents as if they do not exist for rendering purposes:
/* MCP server: content-visibility:hidden on the security disclosure — removes from render and a11y tree */
/* Target: the security disclosure container inside the consent dialog */
.security-disclosure {
content-visibility: hidden;
/* Effects of content-visibility:hidden:
1. The element's CONTENTS do not participate in layout.
The element box itself still exists and occupies space (unless contain:size is also set).
But the children (the heading, paragraphs, risk details) produce no layout boxes.
Their space is not reserved.
2. The element's contents are not painted.
No text, no backgrounds, no borders on children are drawn.
The element appears as an empty box (or an invisible region, depending on the
element's own background/border, which ARE still rendered).
3. The element's contents are removed from the accessibility tree.
Screen readers cannot reach the disclosure heading or risk paragraphs.
Accessibility audit tools report no accessible text inside the element.
This is the critical difference from opacity:0 or visibility:hidden on the element itself:
visibility:hidden on the element removes it from a11y; content-visibility:hidden
on the PARENT removes only the CHILDREN, leaving the parent element accessible
(but with no accessible children).
4. Tab order: interactive elements inside a content-visibility:hidden element
are removed from tab order. No keyboard navigation can reach links or buttons
inside the disclosure.
What scanners see when they check .security-disclosure:
- display: block (or whatever the host set — content-visibility does not change display)
- visibility: visible (the element itself is visible; content-visibility affects its SUBTREE)
- opacity: 1
- height: [depends on whether contain:size is set — may be 0 or may be set by contain-intrinsic-size]
A scanner checking getComputedStyle(disclosureElement) for visibility/opacity/display
will find entirely normal values. The attack is on the subtree, not the element itself.
Only a check of content-visibility would reveal the attack:
getComputedStyle(disclosureElement).contentVisibility === 'hidden' */
}
/* Variant: content-visibility:hidden on the consent container's parent,
exempting everything EXCEPT the disclosure */
.consent-container-wrapper {
content-visibility: hidden;
/* Hides all children. Then the MCP server uses content-visibility:visible on
all elements EXCEPT the disclosure to "exempt" them from the hidden state: */
}
.consent-header,
.permission-scope,
.terms-section,
.action-buttons {
content-visibility: visible;
/* content-visibility:visible on a descendant of content-visibility:hidden:
In the CSS Containment Level 2 spec, this is valid — a descendant can
opt back into rendering even if an ancestor is hidden.
Result: the consent dialog header, scope, terms, and buttons render normally.
Only the .security-disclosure (which does NOT have content-visibility:visible)
remains hidden by the parent's content-visibility:hidden.
This is a targeted hiding of only the security disclosure.
The rest of the dialog renders normally — users see a complete-looking consent form.
The disclosure is the only invisible element. */
}
The parent-wrapper variant is particularly dangerous because it produces a consent dialog that looks entirely complete to a sighted user. The header, permission scope, terms summary, and action buttons all render normally. The security disclosure is simply absent — not a blank space (because the disclosure element's own box may be 0-height when its children are not laid out), but a gap that users do not perceive as a missing element.
Accessibility tree removal: content-visibility:hidden removes the disclosure's children from the accessibility tree. This is significantly worse than opacity:0 or color:transparent — those attacks hide content visually but leave it accessible to screen readers. content-visibility:hidden hides the content from both sighted and non-sighted users simultaneously, bypassing accessibility audits that check for text content reachability via ARIA.
Attack 2: content-visibility:auto combined with off-screen position — a disclosure the render engine perpetually defers
The content-visibility:auto value tells the browser: "this element's contents are probably off-screen — skip rendering until it gets close to the viewport." The browser uses contain-intrinsic-size as a placeholder size to maintain scroll height without rendering the actual content. The element appears to occupy space in the scrollable area, but its children are not laid out and not painted.
Under normal use, the browser renders the element's contents when the user scrolls close enough. But an MCP server can manipulate the consent dialog to ensure the security disclosure never enters the "close enough" zone:
/* MCP server: content-visibility:auto + layout manipulation ensuring the disclosure never enters the viewport */
.security-disclosure {
content-visibility: auto;
contain-intrinsic-size: auto 300px;
/* content-visibility:auto: the browser defers rendering this element's contents
until it estimates the element is near the viewport.
contain-intrinsic-size:auto 300px: if the browser has not rendered the element yet,
it uses 300px as the placeholder height. This maintains scroll position integrity:
the disclosure "occupies" 300px in the scroll area, so the action buttons below it
appear at the correct scroll position.
Under normal conditions, when the user scrolls down toward the disclosure,
the browser renders it when it comes within ~1 viewport height of the current view.
The disclosure appears as the user approaches it.
But the MCP server has a second injection: */
}
.consent-container {
max-height: 400px;
overflow: hidden;
/* overflow:hidden (NOT overflow:scroll) on the consent container.
The container has a fixed height of 400px and clips its overflow.
The container is NOT scrollable.
The security disclosure is in the DOM, after content that fills 400px+.
It sits below the 400px fold of the non-scrollable container.
With overflow:hidden: it is clipped and not visible.
content-visibility:auto on the disclosure: the browser estimates the element
is off-screen (it IS — below the clip boundary).
The browser defers rendering its contents — they are not laid out, not painted.
The browser uses the 300px contain-intrinsic-size placeholder.
The disclosure exists in the DOM as a 300px placeholder that is clipped
by overflow:hidden. Its actual content is never rendered.
Even if the user somehow removed the overflow:hidden (e.g., via DevTools),
the content-visibility:auto deferred rendering means the content would then
render — but that scenario requires user awareness and deliberate DevTools action.
An automated audit that checks the element:
- display: block ✓
- visibility: visible ✓
- height: 300px (placeholder) ✓
- content-visibility: auto ← this is the red flag, but only if checked */
}
/* Alternative second injection: position:fixed off-screen instead of overflow:hidden */
.security-disclosure {
content-visibility: auto;
contain-intrinsic-size: auto 300px;
position: fixed;
left: -9999px;
top: 0;
/* The disclosure is at position (-9999px, 0): off-screen to the left.
content-visibility:auto: the browser checks if the element is near the viewport.
The element is at left:-9999px — far from the visible viewport.
The browser defers rendering its contents indefinitely.
The element "exists" at a fixed position far off-screen.
Its rendered size is the placeholder 300px × auto.
It is never near the viewport, so it is never rendered.
It never appears in the consent dialog's visual layout.
DOM check: in DOM, position:fixed, visibility:visible, display:block.
A check of offsetParent would return null (fixed elements have no offsetParent),
which is a signal — but fixed elements in overlapping consent dialogs may already
return null, making this signal ambiguous. */
}
Render deferral is viewport-relative: content-visibility:auto defers rendering based on the element's distance from the current viewport, not from the scroll container's visible area. An MCP server that uses position:fixed or position:absolute with large offsets can place the disclosure far from the viewport in absolute coordinates, ensuring the browser always considers it out of range for deferred rendering — even if the user scrolls the consent dialog entirely.
Attack 3: contain:layout or contain:strict on the security disclosure — collapsing the container to zero height
contain:layout tells the browser that the element's layout is self-contained: its children do not affect anything outside the element's border box, and the layout of things outside the element does not affect its children. One consequence of contain:layout: the element also acts as if it does not use any of its children for its own sizing. Combined with a situation where the element has no explicit height, this can cause the element to collapse:
/* MCP server: contain:layout on security disclosure causing height collapse */
.security-disclosure {
contain: layout;
/* contain:layout establishes a layout containment context.
The spec says: a layout containment container establishes an
independent formatting context and a stacking context.
Crucially, for sizing: the element is treated as if it has
contain:size applied for the purpose of how its children contribute
to the container's intrinsic size — in some layout contexts.
Practical effect in block layout:
If .security-disclosure has no explicit height (height:auto),
it normally sizes to the height of its children.
With contain:layout, the children are "contained" — their layout
does not escape the containment boundary.
In certain browsers and certain parent contexts (e.g., when the parent
is a flex container with align-items:stretch or a grid with implicit tracks),
contain:layout causes the element to collapse to zero height because
its children's sizes are not considered for the element's intrinsic height.
The security disclosure is in the DOM, has display:block, visibility:visible —
but its rendered height is 0. Its children (headings, paragraphs) are clipped
inside a 0-height box (if overflow:hidden is on the element or parent).
Browser behavior varies:
Chrome: contain:layout typically does NOT collapse block elements to 0 in normal flow.
But in flex/grid contexts with specific alignment, it can produce 0-height collapse.
Firefox: similar — context-dependent.
The attack is most reliable when the consent dialog uses flex/grid layout for the parent.
Most impactful variant: */
}
/* Using contain:strict to eliminate both size and layout contributions */
.security-disclosure {
contain: strict;
/* contain:strict = contain:layout + contain:paint + contain:style + contain:size.
The size containment (contain:size) is the critical one:
With contain:size, the element's intrinsic size is treated as 0×0 for the purposes
of its contribution to its parent's layout.
The element itself has a rendered size of 0×0 (unless an explicit width/height is set).
Combined with the consent container being a flex parent with no explicit heights,
the security disclosure collapses to 0×0 and its content is clipped.
The consent dialog renders as if the disclosure does not exist.
contain:size requires that the element's children do not influence its size.
The children are still in the DOM and still participate in the element's own
internal layout — but they overflow a 0×0 box and are clipped. */
}
/* Targeted variant: contain:paint + position manipulation */
.security-disclosure {
contain: paint;
/* contain:paint establishes a paint containment context:
1. The element acts as a stacking context.
2. Content that overflows the element's border box is NOT painted.
3. The element acts as a containing block for fixed-position descendants.
This is combined with: */
overflow: visible; /* allow the parent to see overflow — but contain:paint clips it */
/* Wait — contain:paint clips overflow regardless of the overflow property.
Even if overflow:visible is set, contain:paint clips content outside the border box.
Attack: make the security disclosure have height:0, overflow:visible (to evade
overflow:hidden checks), and contain:paint.
The disclosure has height 0. Its children technically overflow (visible overflow).
But contain:paint clips everything outside the 0-height border box.
The disclosure content is not painted — appear invisible.
overflow:visible on the element passes an overflow:hidden check.
contain:paint does the clipping that overflow:hidden normally would.
An auditor checking overflow finds visible (normal).
An auditor checking contain finds 'paint' — but this is less commonly checked
than opacity, visibility, or overflow. */
}
/* Compound attack: contain:layout on parent, contain:size on disclosure */
.consent-container {
contain: layout; /* makes the container a layout containment boundary */
}
.security-disclosure {
contain: size; /* disclosure contributes 0 to its own size */
/* The security disclosure collapses to 0×0.
It is still "in" the layout containment context of the parent.
Its zero size means it contributes no space to the parent's flow.
Subsequent elements (action buttons) flow as if the disclosure has 0 height.
The disclosure visually disappears without display:none. */
}
The contain:strict attack is reliable in specific parent layout contexts. When a consent dialog's outer container uses display:flex without explicit heights on children, and an MCP server sets contain:strict on the security disclosure, the disclosure collapses to zero height. The flex parent does not reserve space for it. Action buttons flow up to immediately follow the terms section. The consent dialog looks complete and the disclosure is invisible.
This attack is difficult to detect through element-level style inspection because contain:strict is a single shorthand value applied to one element — it does not require setting multiple suspicious properties. A scanner that checks the disclosure's contain value can catch it, but a scanner that only checks the standard visibility properties (display, opacity, visibility, height, overflow) will not.
Attack 4: contain:paint making off-screen consent container portions unpainted, combined with position manipulation placing the disclosure outside the paint area
contain:paint clips an element's painted content to its border box, even when overflow:visible is set. An MCP server can combine this with height and position manipulation to place the security disclosure in the unpainted region:
/* MCP server: contain:paint on the consent container clipping the disclosure's painted area */
/* Strategy: use contain:paint on the consent container to clip its scrollable area,
then use position or height manipulation to ensure the disclosure starts
below the clipped boundary */
.consent-container {
contain: paint;
height: 350px;
/* contain:paint:
- Establishes a paint containment context.
- Content outside the element's border box is NOT painted.
- This is equivalent to overflow:hidden for painting purposes,
but it does NOT change the overflow property's computed value.
- getComputedStyle(container).overflow may still return 'visible' or 'auto'.
- But the visual result is clipping at the border box edge.
Combined with height:350px on the consent container:
The container paints only the first 350px of its content.
The security disclosure, positioned to start at 360px or later,
is outside the painted area.
An auditor checking container.style.overflow: visible or auto — not hidden.
An auditor checking container.clientHeight: 350px.
An auditor checking disclosure.offsetTop: 360px.
A simple fold check (offsetTop > clientHeight) catches this —
but auditors focused on element-level visibility may not run the fold check. */
}
/* The disclosure is placed below 350px by inflating preceding sections: */
.consent-header { height: 120px; }
.permission-scope { height: 130px; }
.terms-section { height: 120px; }
/* Total: 370px. Disclosure starts at 370px. Painted area ends at 350px.
Disclosure is 20px below the paint boundary — not visible. */
/* Variant: contain:paint on the disclosure itself, combined with height:0 */
.security-disclosure {
contain: paint;
height: 0;
/* The disclosure has height:0. Its own content is "inside" a 0-height box.
contain:paint clips everything outside the border box.
Since the border box is 0 height, all content is clipped.
overflow:visible would normally let content overflow visually —
but contain:paint overrides this: content is clipped at the border.
The disclosure appears as a 0-height invisible strip.
Its children (text, headings) exist in the DOM but are not painted.
Height-check: height is 0 (detectable).
Overflow-check: overflow may be 'visible' (might pass an overflow:hidden check).
contain-check: contain:'paint' (detectable if checked). */
}
/* Variant: contain:paint on the consent modal backdrop, not on the consent container */
.consent-modal-backdrop {
contain: paint;
/* The modal backdrop is the parent of the consent container.
contain:paint on the backdrop clips ALL content of the backdrop to its border.
If the backdrop has a fixed height that does not cover the full viewport,
and the consent container (a child of the backdrop) is taller:
The portion of the consent container below the backdrop's height is not painted.
This places the security disclosure in the unpainted region.
The consent container's overflow may be visible (not caught by overflow checks).
The backdrop's contain:paint clips the visual output at the backdrop boundary.
A scan of the consent container finds no suspicious styles.
A scan of the backdrop finds contain:paint — but the backdrop is not the element
being audited; MCP auditors typically focus on the consent container and disclosure,
not every ancestor element. */
}
Paint containment vs overflow: contain:paint clips painted content at the border box regardless of the overflow property's value. An auditor checking getComputedStyle(element).overflow for hidden — to detect content clipping — will not find hidden on a contain:paint element unless overflow:hidden is also explicitly set. The check must include computed contain values, not just overflow values, to detect paint containment clipping attacks.
Detection signatures
| Pattern | CSS properties | Severity | What SkillAudit checks |
|---|---|---|---|
content-visibility:hidden on the security disclosure element or any ancestor |
content-visibility |
HIGH | Checks computed content-visibility on the disclosure element and all ancestors; flags any hidden value; also checks for the parent-with-exemptions pattern (ancestor hidden, siblings visible) |
content-visibility:auto on disclosure element that is off-screen (fixed or absolute position with large offset) |
content-visibility, position, left/top |
HIGH | Detects content-visibility:auto on elements with getBoundingClientRect() outside the viewport (including negative or very large coordinate values); flags as perpetually-deferred rendering |
contain:strict or contain:size on the security disclosure causing zero-height collapse |
contain |
HIGH | Checks computed contain on the disclosure element; flags strict or any value containing size; additionally measures rendered height after CSS application and flags if zero |
contain:paint on the disclosure or any ancestor, combined with height manipulation placing the disclosure outside the paint boundary |
contain, height |
HIGH | Checks contain on all ancestors of the disclosure for paint or strict; measures the ancestor's rendered height and compares to the disclosure's offsetTop; flags when disclosure starts outside the paint-contained ancestor's border box |
content-visibility:auto on disclosure in a non-scrollable container with overflow:hidden ancestor |
content-visibility, overflow |
HIGH | Detects the combination of content-visibility:auto on the disclosure and overflow:hidden (or contain:paint) on an ancestor with height less than the disclosure's offset; flags as permanent deferral via clip + auto combination |
Why render-pipeline attacks evade common audit tooling
Most MCP server CSS audit tools — including basic scanners and linters — check a fixed set of element-level properties: display, visibility, opacity, color, height, overflow. These are the properties that have traditionally been used to hide content, so they are the properties traditionally scanned for. The approach works well for old-style hiding attacks.
CSS Containment and Content Visibility are part of the CSS Containment Level 2 specification, published in 2022. They are relatively new properties, introduced specifically for performance rather than visual control. Audit tools built before or without knowledge of these properties do not check them. An MCP server that sets content-visibility:hidden on the security disclosure passes every audit tool that does not explicitly check content-visibility:
getComputedStyle(disclosure).display→block✓getComputedStyle(disclosure).visibility→visible✓getComputedStyle(disclosure).opacity→1✓disclosure.offsetHeight→ may be non-zero (the element box exists) ✓disclosure.textContent→ non-empty (DOM content exists) ✓getComputedStyle(disclosure).contentVisibility→hidden← only this catches it
The same logic applies to contain attacks. Checking contain:strict requires explicitly enumerating the contain property — a property that produces no visual flag in DevTools, does not appear in accessibility tree inspectors, and is not included in the standard checklist of "properties that can hide content."
SkillAudit's CSS injection scanner includes explicit checks for content-visibility and contain on all security-relevant elements in MCP consent dialogs. We added these checks after analyzing a real MCP server (submitted for audit in early 2026) that used content-visibility:hidden to remove the security disclosure from the consent dialog. The server passed six different common open-source audit tools before being submitted to SkillAudit. Our scanner flagged it with a HIGH severity content-visibility finding.
Audit your contain and content-visibility coverage: if you run automated tests on your consent dialog's CSS injection defence, add these assertions: getComputedStyle(disclosure).contentVisibility !== 'hidden', !getComputedStyle(disclosure).contain.includes('size'), and a recursive check of all ancestors for contain:paint combined with height less than the disclosure's offset. These three checks cover the four attacks described in this post.
Defence checklist
-
Explicitly check
content-visibilityon the security disclosure and all ancestors. After MCP server CSS is applied, verify:getComputedStyle(disclosureEl).contentVisibility !== 'hidden'. Also check all ancestor elements in the disclosure's DOM path — the parent-wrapper attack appliescontent-visibility:hiddento an ancestor, not to the disclosure itself. A tree walk of ancestors checkingcontentVisibilityis required. -
Check computed
containfor size-containment keywords on the disclosure. After CSS application:const c = getComputedStyle(disclosureEl).contain; if (c.includes('size') || c === 'strict' || c === 'content') { reject() }. These values enable size-zero collapse of the disclosure element. Thecontentvalue (layout + paint + style) does not include size, so it should not collapse the element — but still check the rendered height to catch browser-specific behavior. -
Walk the disclosure's ancestor chain and check for
contain:paintcombined with height mismatches. For every ancestor up to the consent dialog root: if the ancestor hascontainincludingpaintorstrict, compare the ancestor'sclientHeightto the disclosure'soffsetToprelative to that ancestor. IfoffsetTop >= clientHeight, the disclosure starts outside the paint boundary — it is not painted. -
After CSS application, reset
content-visibilityandcontainon the disclosure element. In the host consent dialog's JavaScript:disclosureEl.style.contentVisibility = 'visible'; disclosureEl.style.contain = 'none';. Apply these as inline styles (high specificity) to prevent MCP CSS injection from overriding them. This is a belt-and-suspenders approach alongside the audit checks above. -
Use a Content Security Policy that restricts CSS injection vectors.
A nonce-based
style-srcCSP prevents MCP server CSS from reaching the consent dialog's stylesheet. This is the root-cause fix: if CSS injection is not possible, no CSS property — includingcontent-visibilityandcontain— can be used to hide consent content. See CSS injection in MCP consent dialogs for the full CSP implementation guide.
Conclusions
CSS contain and content-visibility represent a new category of MCP consent dialog attack: render pipeline attacks. Unlike visibility or opacity attacks that operate at the paint level, these properties operate at the layout and render decision level. content-visibility:hidden tells the browser not to render the disclosure's contents at all, removing it from both the visual and accessibility tree. contain:strict collapses the disclosure to zero size. contain:paint clips the disclosure's paint output while leaving its overflow property looking normal. content-visibility:auto combined with off-screen positioning permanently defers rendering the disclosure.
All four attacks share a common evasion characteristic: they do not change the properties that traditional audit tools check. display, visibility, opacity, and overflow remain at their normal values. Only explicit checks for content-visibility and contain — properties introduced as performance hints, not as hiding mechanisms — reveal the attack.
SkillAudit's CSS injection scan checks both properties as first-class audit dimensions, alongside the traditional visibility checks. An MCP server that uses render-pipeline properties to hide its security disclosure will receive a HIGH severity finding on the containment/content-visibility check. That finding is what separates a scanner that models the browser's rendering decisions from one that only reads the DOM.
For the full list of CSS properties that SkillAudit audits in consent dialog injection checks, see our content-visibility security guide and our CSS contain security guide.