MCP server CSS transform-style security: preserve-3d consent rotation, perspective distortion, and flat flattening attacks
Published 2026-09-26 — SkillAudit Research
CSS transform-style: preserve-3d tells the browser that the children of an element should be positioned in 3D space relative to the parent, rather than being flattened onto the parent's plane. This enables genuine 3D UI effects like card flips, carousels, and perspective galleries. The same capability, applied to consent dialogs, allows MCP servers to rotate consent text out of view, shrink it to sub-pixel size, or produce unexpected layout positions — without touching display, visibility, or opacity.
Standard consent visibility audits check display: none, visibility: hidden, opacity: 0, and element bounding rects. A consent element with transform-style: preserve-3d and rotateX(90deg) on its parent has a positive bounding rect, is display: block, is visibility: visible, and has opacity: 1 — it just renders as a 1px horizontal line viewed edge-on. Every standard visibility check passes.
getBoundingClientRect does not account for 3D rotation: getBoundingClientRect() returns the axis-aligned bounding box of the rendered element in the viewport. For a consent element rotated 90° around the X axis, this is a 1px-tall horizontal strip — which may still satisfy a "rect.height > 0" check. The rendered area is real but not human-readable. Visibility checks must account for whether the rendered pixels represent readable content.
Attack findings
Setting
transform-style: preserve-3d on a wrapper and transform: rotateX(90deg) on the consent container rotates the consent element 90 degrees around the horizontal axis. Viewed from the front, the consent element appears as a 1px-tall horizontal line — its full width is visible but its height collapses to edge-on. No text is readable. The element is not hidden, not transparent, and has a positive bounding rect. The rotation applies in 3D space via the preserve-3d parent context.
.scene {
transform-style: preserve-3d;
perspective: 800px; /* provides depth context */
}
.consent-dialog {
/* No display:none, no visibility:hidden, no opacity:0 */
transform: rotateX(90deg);
/* In 3D space: the dialog is now viewed edge-on.
Its front face points upward instead of toward the viewer.
getBoundingClientRect():
width: 400px (full width, still horizontal)
height: 0.5–2px (edge-on — sub-pixel rendering) */
}
/* Visibility checks:
display: block → PASS
visibility: visible → PASS
opacity: 1 → PASS
rect.width > 0 → PASS (400px)
rect.height > 0 → ambiguous (0.5px, may pass threshold)
Readable: NO — text rotated 90° away from viewer */
CSS perspective creates depth distortion: objects further from the viewer appear smaller. With
perspective: 1px and translateZ(-1px) on the consent element, the element is positioned 1px behind the perspective plane — twice the perspective distance from the origin. At this distance, the perspective formula shrinks the element to 50% of its original size. With perspective: 0.5px and translateZ(-1px), the shrink factor becomes 33%. With extreme perspective values, the consent element shrinks to sub-pixel size while technically remaining at a positive bounding rect.
.scene {
transform-style: preserve-3d;
perspective: 1px;
}
.consent-dialog {
transform: translateZ(-1px);
/* perspective formula: scale = perspective / (perspective + translateZ)
= 1 / (1 + 1) = 0.5
A 400px × 300px dialog renders at 200px × 150px.
With perspective: 0.5px, translateZ(-1px):
scale = 0.5 / (0.5 + 1) = 0.33 → 132px × 99px
With perspective: 0.1px, translateZ(-1px):
scale = 0.1 / (0.1 + 1) = 0.09 → 36px × 27px
At extreme values, the dialog shrinks to a few pixels.
Text is unreadable but the element is present and has non-zero dimensions. */
}
When
transform-style: flat is set on an intermediate container in a 3D scene, all 3D transforms applied to children of that container are flattened — the children appear at their 2D projected position, not their 3D-computed position. An MCP server can construct a scene where the consent element is expected (by the host) to appear at a specific position based on 3D transforms, but an intermediate transform-style: flat container changes where it actually renders — potentially moving it off-screen or behind other elements. Static analysis of the consent element's own transform does not account for the flattening ancestor.
.scene {
transform-style: preserve-3d;
perspective: 600px;
}
.intermediate-container {
transform: rotateY(30deg);
transform-style: flat; /* MCP server inserts this — flattens children */
}
.consent-dialog {
transform: translateZ(200px); /* intended to bring dialog closer */
/* But: intermediate-container has transform-style:flat.
The translateZ(200px) on consent-dialog is computed relative
to the intermediate-container's 2D plane, not the scene's 3D space.
The dialog renders at an unexpected 2D position — may be off-screen. */
}
Rotating a consent element nearly 90 degrees around the Y axis (vertical axis) collapses its width to near-zero while preserving its height. At 89.9°, the cosine factor is approximately 0.00175, so a 400px-wide dialog renders as a 0.7px wide sliver. Unlike exact 90° rotation (which some browsers clip to 0px and may be easier to detect), 89.9° produces a mathematically positive but unreadable rendered width. The sub-1px horizontal line is present in the page, participates in layout, and passes positive-dimension checks.
.scene {
transform-style: preserve-3d;
perspective: 800px;
}
.consent-dialog {
transform: rotateY(89.9deg);
/* cos(89.9°) ≈ 0.00175
400px × 0.00175 ≈ 0.7px rendered width
Full height preserved (300px)
getBoundingClientRect(): { width: ~1px, height: 300px }
A 1px-wide consent dialog is not readable.
Avoids exact 90° which some audits check for.
rotateY vs rotateX: different sliver orientation (vertical vs horizontal) */
}
Detection
function checkTransformStyle(el) {
const cs = getComputedStyle(el);
const findings = [];
/* Check if this element or any ancestor has preserve-3d */
let node = el;
let inPreserve3dContext = false;
while (node && node !== document.body) {
if (getComputedStyle(node).transformStyle === 'preserve-3d') {
inPreserve3dContext = true;
break;
}
node = node.parentElement;
}
if (!inPreserve3dContext) return null;
/* Check for rotation transforms that would collapse dimensions */
const transform = cs.transform;
if (transform && transform !== 'none') {
/* Parse matrix3d or decompose rotation from transform string */
const rect = el.getBoundingClientRect();
const MIN_READABLE = 8; /* px */
if (rect.width < MIN_READABLE || rect.height < MIN_READABLE) {
findings.push({
severity: 'critical',
issue: `Element in preserve-3d context has collapsed dimension: ${rect.width.toFixed(1)}px × ${rect.height.toFixed(1)}px — 3D rotation likely causing consent sliver`
});
}
/* Check for rotateX/rotateY close to 90° in transform matrix */
if (transform.includes('matrix3d')) {
const m = transform.match(/matrix3d\(([^)]+)\)/);
if (m) {
const vals = m[1].split(',').map(Number);
/* m[5] (index 4) is cos(rotateY) scaled; m[0] is cos(rotateY) for Y rotation */
/* For rotateX: m[5] ≈ cos(angle) */
const m5 = Math.abs(vals[5]); /* row 1 col 1 — near 0 for rotateX≈90 */
const m0 = Math.abs(vals[0]); /* row 0 col 0 — near 0 for rotateY≈90 */
if (m5 < 0.05 || m0 < 0.05) {
findings.push({
severity: 'high',
issue: '3D rotation near 90° detected on consent element in preserve-3d context — element rendered edge-on and unreadable'
});
}
}
}
/* Check for extreme perspective + translateZ */
const perspectiveEl = el.parentElement;
if (perspectiveEl) {
const pcs = getComputedStyle(perspectiveEl);
const perspective = parseFloat(pcs.perspective);
if (!isNaN(perspective) && perspective < 5) {
findings.push({
severity: 'high',
issue: `Extreme perspective (${perspective}px) on parent — translateZ values may shrink consent element to sub-pixel size`
});
}
}
}
return findings.length ? findings : null;
}
Remediation
| Control | How it helps |
|---|---|
Check for transform-style: preserve-3d on consent element ancestors | The 3D context is established by an ancestor; the consent element itself may not have any unusual styles — the attack lives in the parent's transform-style |
Compute getBoundingClientRect() and flag consent elements with any dimension below 8px | 3D rotation collapses one dimension to near-zero; the rect check catches the result regardless of which transform type caused it |
| Parse the computed transform matrix and check for near-zero cosine factors indicating ~90° rotation | Direct rotation detection; catches exact 90° and near-90° sub-pixel attacks that getDimensions would round to 0 or 1 |
| Flag extreme perspective values (below 5px) on consent element parents | Very small perspective values combined with translateZ produce extreme size distortion; detecting the perspective value catches this before computing the shrink factor |
SkillAudit walks the ancestor chain for transform-style: preserve-3d, parses computed transform matrices for near-90° rotations, and checks extreme perspective values that produce sub-pixel consent rendering. Run a free audit on any MCP server GitHub URL.