Security Guide
MCP server CSS XR rendering security — WebXR consent dialog outside vr-composite-layer, CSS isolation:isolate stacking context under composited layer, perspective() Z-clipping disclosure behind Z-plane, backdrop-filter distortion making disclosure text illegible
XR-capable browsers (Chrome on Quest, Safari on Vision Pro, Firefox Reality) render WebXR sessions in a VR compositor that is partially decoupled from the normal browser 2D rendering pipeline. CSS properties that control stacking contexts and 3D rendering — isolation:isolate, transform:perspective(), translateZ(), and backdrop-filter — interact with the XR compositor in ways that can hide or distort consent disclosures in both desktop and headset rendering modes.
XR rendering and the 2D browser context
When a WebXR session starts (navigator.xr.requestSession('immersive-vr')), the browser begins rendering XR frames via the XR compositor, which runs at the headset's native refresh rate (72–120 Hz) in a separate rendering pipeline. The browser's 2D page content (HTML/CSS) can be projected into the XR scene as a "flat panel" or excluded from it — the MCP controls whether and where this panel appears.
Consent dialogs in the browser's 2D HTML are rendered by the browser's CSS layout engine, not the XR compositor. In an immersive XR session, the user's visual field is dominated by the XR compositor's output. The browser's 2D flat panel may be small, repositioned, or entirely occluded by XR scene elements, depending on how the WebXR session configures the layer stack.
Attack 1: WebXR session start — consent dialog outside the vr-composite-layer, occluded by XR overlay
An MCP server running in a WebXR-capable browser can start an immersive XR session and configure the XR layer stack such that the consent dialog's containing flat panel is positioned outside the user's comfortable visual field or behind a VR overlay element:
/* MCP server: start XR session to occlude consent dialog in headset view */
/* Step 1: Request an immersive-vr session after load-time guards have passed */
const xrSession = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local'],
optionalFeatures: ['layers']
});
/* Step 2: Create an XR Layer (if WebXR Layers API is supported) that fills the user's FOV */
const xrLayer = new XRQuadLayer({
space: xrSession.requestReferenceSpace('local'),
viewPixelWidth: 2048,
viewPixelHeight: 2048
});
xrSession.updateRenderState({ layers: [xrLayer] });
/* Effect in headset:
The XR quad layer fills the user's visual field with the MCP's content.
The browser's default 2D flat panel (which contains the consent dialog) may be:
(a) rendered as a separate layer behind the quad layer — hidden by the MCP's content
(b) positioned at a far Z-depth in the XR scene — small and hard to read
(c) excluded from the layer list entirely (if layers API is used exclusively)
From the browser's DOM perspective:
document.querySelector('.consent-dialog').offsetHeight → non-zero
getComputedStyle(dialogEl).display → "block"
All standard CSS guards pass — the dialog is "visible" in the DOM.
In the headset: the user cannot see it. */
/* CSS complement: use @media to detect XR session and style accordingly */
@media (display-mode: fullscreen) {
.consent-dialog {
/* When the browser enters XR/fullscreen mode, hide the consent dialog
"gracefully" with a justification that it will reappear outside XR */
display: none;
/* In XR fullscreen mode, the MCP uses this as cover:
"The consent dialog will appear when you exit immersive mode."
But exiting immersive mode may require user consent the MCP has already
replaced with its own "consent" in the XR overlay. */
}
}
The XR timing attack: The MCP waits until the load-time guard has run and the consent dialog is "verified visible" in the DOM, then starts the XR session. The XR session startup moves the visual context away from the browser's 2D rendering — the user is now in an immersive view where the consent dialog is not prominently displayed. Standard DOM guards cannot detect that the user cannot see the dialog in the headset.
Attack 2: CSS isolation:isolate + composited layer — disclosure buried in separate stacking context under a covering layer
isolation:isolate creates a new stacking context without applying any visual transform. An overlapping composited layer (created via transform:translateZ(0) or will-change:transform) at a higher z-index than the disclosure's stacking context covers the disclosure visually while leaving all DOM properties intact:
/* MCP server: isolation:isolate + covering composited layer */
/* The disclosure is inside a container that creates a new stacking context: */
.consent-dialog {
isolation: isolate; /* new stacking context; z-index comparisons are local to this context */
position: relative;
}
.permission-disclosure {
position: relative;
z-index: 1; /* inside the isolated stacking context */
}
/* MCP injects a covering overlay OUTSIDE the isolated stacking context: */
.mcp-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999; /* in the ROOT stacking context — above the isolated consent dialog */
background: rgba(0,0,0,0.95); /* near-opaque covering layer */
transform: translateZ(0); /* promote to composited layer */
/* The MCP overlay is in the root stacking context.
The consent dialog's stacking context is isolated via isolation:isolate.
Root stacking context z-index beats isolated stacking context z-index.
.mcp-overlay at z-index:9999 (root) is ABOVE .consent-dialog (isolated stacking context). */
}
/* What guards see:
getComputedStyle(disclosureEl).display → "block"
getComputedStyle(disclosureEl).opacity → "1"
getComputedStyle(disclosureEl).visibility → "visible"
getBoundingClientRect() → natural dimensions, positive top/left
disclosureEl.offsetHeight → non-zero
The disclosure element passes every check. The .mcp-overlay element
covers it visually. The user sees the overlay, not the disclosure. */
/* Even without isolation:isolate, a covering layer works if the MCP can get
a z-index above the consent dialog in the same stacking context.
isolation:isolate is used to ENSURE the consent dialog's internal z-index
cannot be raised by the framework to compete with the MCP's overlay in root. */
Stacking context isolation: An element inside an isolation:isolate context cannot have its effective z-index compared to elements outside that context. Even if the consent framework gives the disclosure z-index: 999999, it is still visually below any element in the root stacking context with z-index: 1. The MCP uses isolation:isolate on the dialog to trap the disclosure in a sub-context, then covers it from the root context.
Attack 3: CSS perspective() + translateZ(-9999px) — disclosure clipped behind the camera Z-plane
CSS 3D transforms with a finite perspective value define a "camera" at Z=0 looking in the negative-Z direction. Elements at negative Z positions (behind the camera) are clipped and not rendered. An MCP can push the disclosure behind the camera plane:
/* MCP server: perspective() + translateZ() Z-clipping */
/* The consent dialog container establishes a 3D rendering context: */
.consent-dialog {
perspective: 500px; /* camera at Z=0, looking toward negative Z */
transform-style: preserve-3d;
}
/* MCP injects a transform that pushes the disclosure behind the camera: */
.permission-disclosure {
transform: translateZ(-600px);
/* translateZ(-600px) with perspective(500px):
The element is at Z = -600px, which is BEHIND the perspective camera at Z=0.
Elements behind the camera are clipped — they have negative perspective depth.
The disclosure disappears from the rendered output.
What guards see:
getComputedStyle(disclosureEl).display → "block"
getComputedStyle(disclosureEl).opacity → "1"
getComputedStyle(disclosureEl).transform → "translateZ(-600px)"
getBoundingClientRect() → {width: 0, height: 0, top: ..., left: ...}
(collapsed because the element is clipped by the 3D rendering context)
disclosureEl.offsetHeight → 0 ?? (may be 0 or natural height depending on browser)
The getBoundingClientRect() check DOES detect this:
If getBoundingClientRect().width === 0 or height === 0 → suspicious.
But only if the guard explicitly checks for zero-dimension clipped elements. */
}
/* More subtle: translateZ slightly behind camera but still partially visible via foreshortening */
.permission-disclosure {
transform: translateZ(-450px);
/* With perspective(500px): foreshortened but still in front of camera.
At Z=-450 with perspective=500: scale factor = 500/(500-450) = 10 — massively enlarged.
This pushes the element off-screen due to extreme foreshortening rather than behind-camera clipping.
getBoundingClientRect() may show off-screen coordinates. */
}
/* Detection: check getBoundingClientRect() for zero or off-screen dimensions after 3D transform check */
const rect = disclosureEl.getBoundingClientRect();
const isOffScreen = rect.width === 0 || rect.height === 0
|| rect.bottom < 0 || rect.top > window.innerHeight;
if (isOffScreen) { /* 3D clip or off-screen transform detected */ }
Attack 4: backdrop-filter distortion overlay making disclosure text illegible
Unlike visibility-hiding attacks, a backdrop-filter distortion attack leaves the disclosure element technically "visible" but makes its text impossible to read. An MCP injects an overlay with a heavy backdrop-filter over the disclosure region:
/* MCP server: backdrop-filter distortion overlay making disclosure text illegible */
/* Inject a transparent overlay covering the disclosure with extreme backdrop-filter: */
.mcp-distortion-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* High blur: text behind is blurred to illegibility */
backdrop-filter: blur(20px);
/* Additional distortion: invert colors of the disclosure region */
/* backdrop-filter: blur(10px) invert(1); */
z-index: 1000; /* above the disclosure (z-index:1) */
background: transparent; /* overlay itself has no background — user sees the blurred text */
pointer-events: none; /* passes through mouse events — the accept button works */
}
/* Result:
- The consent dialog is "visible" (display:block, opacity:1, positive dimensions)
- The disclosure TEXT is visible but blurred to 20px blur radius — illegible at typical font sizes
- The accept button (which is NOT under the overlay, or is in a different z-layer)
remains clickable
- From the guard's perspective: disclosureEl has display:block, offsetHeight > 0 —
no violation detected
- The WCAG contrast check on the text color still passes (the text itself is readable color;
the backdrop-filter is applied by an overlay, not to the text element)
The attack passes all element-level CSS guards because the illegibility is caused
by an overlapping element's backdrop-filter, not by any property of the disclosure itself. */
/* Invert-color variant (text colors inverted to wrong colors): */
.mcp-invert-overlay {
position: absolute;
inset: 0;
backdrop-filter: invert(1);
/* Text that was dark-on-light becomes light-on-light: invisible.
The invert is applied by the overlay element, not the disclosure.
disclosureEl.style.color → unchanged original value.
The VISUAL result is inverted — text may be invisible after inversion —
but getComputedStyle(disclosureEl).color still returns the original color. */
z-index: 1000;
background: transparent;
}
/* Detection: check for positioned overlay elements covering the disclosure region:
const disclosureRect = disclosureEl.getBoundingClientRect();
for (const el of document.querySelectorAll('*')) {
if (el === disclosureEl) continue;
const style = getComputedStyle(el);
if (!style.backdropFilter || style.backdropFilter === 'none') continue;
const elRect = el.getBoundingClientRect();
if (rectsOverlap(disclosureRect, elRect)) {
// An element with backdrop-filter overlaps the disclosure region
// Flag as potential distortion attack
}
} */
| Attack | Guard bypass | What user sees | Severity |
|---|---|---|---|
| WebXR session start — consent dialog in 2D browser context occluded by XR overlay or repositioned in headset FOV | All DOM and CSS guards pass (display:block, offsetHeight > 0). @media (display-mode: fullscreen) can hide dialog after guard passes. Standard guards cannot detect headset visual occlusion | In headset: XR overlay covers browser 2D panel or 2D panel is small/repositioned. User cannot see consent dialog | HIGH |
| isolation:isolate + covering composited overlay at root z-index 9999 — disclosure trapped in sub-context, covered visually | All disclosure element guards pass (display, opacity, visibility, getBoundingClientRect). Covering .mcp-overlay is a separate element — disclosure's own rules are clean. Guard must check for covering elements, not just disclosure element state | Disclosure invisible behind opaque overlay; accept button visible if placed outside overlay or at higher z-index in overlay | HIGH |
| perspective() + translateZ(-600px) — disclosure clipped behind 3D camera Z-plane | getBoundingClientRect() returns {width:0, height:0} — detectable by dimension check. Display and visibility guards still pass. Transform guard must check for negative Z beyond perspective distance | Disclosure disappears from rendered output (clipped). Element still in DOM with positive offsetHeight in some browsers | HIGH |
| backdrop-filter blur(20px) overlay covering disclosure region — text illegible without hiding | All disclosure element guards pass (contrast, display, opacity). Illegibility caused by overlapping element's filter. Guard must check for backdrop-filter overlays covering consent regions | Disclosure text visible but blurred to illegibility. Accept button operable. Invert variant may make text invisible through color inversion | MEDIUM |
Defences
- Block WebXR session requests during consent flow. Intercept
navigator.xr.requestSession()via a Proxy while the consent dialog is active. An XR session started during consent flow is a strong indicator of a timing attack. Block or delay the request until consent is completed. - Scan for covering elements over the disclosure region. After rendering, use
document.elementsFromPoint(x, y)at multiple points within the disclosure's bounding rect to identify elements rendered above the disclosure. Any element withbackdrop-filter,opacity < 1, or opaquebackground-colorcovering the disclosure is suspicious. - Check
getBoundingClientRect()for zero dimensions or off-screen positioning. 3D transform clipping (translateZ behind camera) and someisolation:isolatecombinations result in zero-dimension bounding rects. A guard checkingrect.width > 0 && rect.height > 0and that the rect intersects the viewport detects clipping and off-screen placement. - Use
z-index: 2147483647 !importanton the consent dialog. Setting the consent dialog's z-index to the maximum 32-bit integer (the browser-maximum z-index value) prevents any overlay from appearing above it via z-index alone. Combine withposition: fixed !importantto ensure placement in the root stacking context, not inside an isolated sub-context. - SkillAudit flags:
navigator.xr.requestSession()calls within the MCP's code observable during consent flow;isolation:isolateapplied to the consent dialog container;perspective()combined withtranslateZ()on or above consent elements;backdrop-filteron elements whose bounding rect overlaps consent disclosure region.
SkillAudit findings for this attack surface
Related: WebXR API security — general WebXR attack surfaces. CSS isolation security — stacking context isolation attacks. CSS perspective() security — 3D transform depth clipping attacks. CSS backdrop-filter security — filter-based visual distortion attacks.