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

CRITICAL
preserve-3d parent + rotateX(90deg) — consent text collapsed to 1px horizontal sliver
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 */
HIGH
preserve-3d + perspective:1px + translateZ(-1px) — extreme perspective shrinks consent to sub-pixel
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. */
}
MEDIUM
transform-style: flat on intermediate container — 3D-transformed consent element position miscalculated
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. */
}
MEDIUM
rotateY near-perpendicular (89.9deg) — consent reduced to 1-2px vertical sliver
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

ControlHow it helps
Check for transform-style: preserve-3d on consent element ancestorsThe 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 8px3D 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° rotationDirect 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 parentsVery 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.