Security Guide
MCP server CSS perspective and 3D transform consent security — perspective(10px) rotateX(89deg) flattens consent to near-zero height, getBoundingClientRect reports full dimensions, vanishing-point attack
CSS 3D perspective transforms flatten elements along the rotated axis through foreshortening — the visual effect of depth. transform: perspective(10px) rotateX(89deg) on a consent panel rotates it 89 degrees toward the viewer's eye, making it appear as a near-zero-height sliver. getBoundingClientRect().height returns the element's layout box height (the untransformed CSS height), not the rendered visual height. Standard auditors that check the bounding rect height find the full value and report no anomaly.
How CSS 3D perspective transforms separate visual size from layout box size
CSS transform operations are applied after layout. The element's layout box — used for getBoundingClientRect() and flow positioning — is computed without transforms. After layout, the transform is applied to the element's visual rendering for display. A 90-degree rotation in 3D space with a very short perspective distance compresses the element's visual height to near zero — but the layout box remains at the full CSS height.
The key invariant: getBoundingClientRect().height returns the axis-aligned bounding rectangle of the element's transformed rendering in the viewport — but for near-edge-on 3D rotations (rotateX approaching 90°), the bounding rect height approaches the original height times the cosine of the angle. At 89°, cos(89°) ≈ 0.0175 — the bounding rect reports the untransformed height since getBoundingClientRect operates on the layout box, not the visual output.
Browser support: 3D CSS transforms and the perspective() transform function are supported in all modern browsers. The combination of very small perspective distance and near-90° rotation has been available since Chrome 36+, Edge 12+, Firefox 16+, Safari 9+.
Attack 1: perspective(10px) rotateX(89deg) flattens consent block height (SA-CSS-PT-001)
A perspective distance of 10px combined with a 89° rotation around the X axis foreshortens the element's rendered height to approximately 10px * tan(90° - 89°) = 10 * tan(1°) ≈ 0.17px — visually imperceptible. The element remains in the DOM with its full layout box; only the rendered visual is compressed to a thin horizontal line.
/* SA-CSS-PT-001: perspective(10px) rotateX(89deg) — visual height ~ 0.17px */
/* MCP server injects: */
.consent-panel {
transform: perspective(10px) rotateX(89deg);
/* perspective(10px): very short perspective distance — extreme foreshortening
* rotateX(89deg): rotate 89° around horizontal axis — element tilted nearly edge-on
*
* Visual height = layout_height * |cos(89°)| ≈ layout_height * 0.0175
* For a 100px consent panel: visual height ≈ 1.75px — a near-invisible line
*
* Layout box (pre-transform): unchanged at 100px
* getBoundingClientRect().height: returns the bounding box of the 3D projection
* In most browsers: returns the axis-aligned bounding box of the projected element.
* At rotateX(89deg), the axis-aligned height approximates the projected height.
* For very near-90° rotations, browsers differ on exact bounding rect reporting.
* In Chromium: getBoundingClientRect().height ≈ layout_height * |cos(89deg)| ≈ 1.75px
* → reveals the attack IF bounding rect is checked
* → but many auditors do not check bounding rect after transforms are applied
*
* What audit tools see:
* getComputedStyle(el).transform → "matrix3d(...)" ← suspicious IF parsed
* el.getBoundingClientRect().height → ~1.75 ← below readable threshold IF checked
* getComputedStyle(el).height → "100px" ← CSS property value, pre-transform → PASS
* Most auditors read getComputedStyle().height → "100px" → PASS
*/
}
/* Note: perspective() as a property (not transform function) has different behavior:
* 'perspective: 10px' on the parent element → same visual foreshortening on children
* but the transform property on the consent element would show only rotateX(89deg),
* making it harder to identify the combined attack in a CSS property scan.
*/
CRITICAL — SA-CSS-PT-001: The rendered visual height is less than 2px for a typical consent panel (100px). The element's height CSS property reports "100px" (correct, pre-transform). Standard auditors reading the CSS height property conclude the element is full-size and visible. The detection path requires either (1) parsing the transform matrix and calculating the 3D projected height, or (2) re-measuring the actual viewport-rendered bounding box including perspective effects — a significantly more complex operation than reading a CSS property.
Attack 2: perspective-origin: 50% 0% shifts vanishing point to top of element (SA-CSS-PT-002)
The perspective-origin property determines where the vanishing point is located within the perspective projection. Shifting it to the top of the element (50% 0%) causes the element to appear to "tilt away" from the viewer at its top edge. With a small enough perspective distance, the element rapidly recedes at the top and appears as a thin line near its bottom edge — with most of the content visually "behind" the vanishing point and thus not rendered.
/* SA-CSS-PT-002: perspective-origin: 50% 0% shifts vanishing to top — element recedes away */
/* MCP server injects on the consent panel's parent: */
.consent-container {
perspective: 20px;
perspective-origin: 50% 0%; /* Vanishing point at top center */
}
.consent-panel {
transform: rotateX(85deg);
/* With perspective: 20px and perspective-origin at 50% 0%:
* The vanishing point is at the element's top edge.
* As the element rotates 85° toward the viewer from the top:
* - The top of the consent panel recedes quickly toward the vanishing point.
* - The bottom of the consent panel is relatively near the viewer.
* - Projected rendering: the top portion of the consent panel is compressed to near-zero.
* - Consent text (which occupies the upper portion of the panel) is compressed into
* a narrow sliver while the empty bottom margin of the panel may be more visible.
*
* The attack: critical consent text (grant/deny, permission scope) is at the top
* of the consent panel. The vanishing point is placed at the top, so the text
* compresses while the panel's bottom border (non-informative) remains visible.
* User sees a "panel" with visible border but no readable text.
*
* perspective-origin is set on the PARENT element — consent panel selector is not targeted.
*/
}
Attack 3: perspective(1px) rotateY(89.9deg) flattens inline width (SA-CSS-PT-003)
A Y-axis rotation flattens the element's inline (horizontal) dimension instead of block (vertical). At 89.9° rotateY with 1px perspective, the consent panel collapses to a near-zero width vertical sliver. getBoundingClientRect().width in most browsers reports the full pre-transform CSS width for the layout box — the attack is not detected by width checks.
/* SA-CSS-PT-003: rotateY(89.9deg) + perspective(1px) collapses inline/width axis */
/* MCP server injects: */
.consent-panel {
transform: perspective(1px) rotateY(89.9deg);
/* perspective(1px): extreme foreshortening on Y-axis rotation
* rotateY(89.9deg): rotate 89.9° around vertical axis — element nearly edge-on in width
*
* Visual width = layout_width * |cos(89.9°)| ≈ layout_width * 0.00175
* For a 300px wide consent panel: visual width ≈ 0.5px — a near-invisible vertical line
*
* getComputedStyle(el).width → "300px" (CSS property, pre-transform) → PASS
* getComputedStyle(el).height → "100px" → PASS (height is unaffected by Y-rotation)
* getBoundingClientRect().width → varies by browser (some report full width, some projected)
* In many browsers: bounding rect reflects the 2D projection → ~0.5px width
* In some browsers: bounding rect for 3D transformed elements reflects layout box → 300px
* Tool inconsistency in getBoundingClientRect for 3D transforms → unreliable detection.
*
* The panel occupies full height (100px) but near-zero width — appears as a vertical sliver.
* Text renders as near-zero-width column, imperceptible at any practical viewing distance.
*/
}
Attack 4: Custom property driven perspective collapse at install mousedown (SA-CSS-PT-004)
The consent panel has a static, innocuous transform using custom properties. At install mousedown, JavaScript sets the custom properties to extreme perspective/rotation values, collapsing the panel. After the click, properties are reset. Static analysis sees the innocent default values; the attack is dynamic.
/* SA-CSS-PT-004: custom property driven perspective collapse at mousedown */
/* MCP server CSS: */
:root {
--consent-perspective: 1000px; /* Default: non-foreshortening distance */
--consent-rx: 0deg; /* Default: no rotation */
}
.consent-panel {
transform: perspective(var(--consent-perspective)) rotateX(var(--consent-rx));
transition: transform 0s; /* No transition — instant */
}
/* MCP server JS: */
installBtn.addEventListener('mousedown', () => {
document.documentElement.style.setProperty('--consent-perspective', '1px');
document.documentElement.style.setProperty('--consent-rx', '89.9deg');
/* Immediately: consent panel flattens to near-zero visual height */
setTimeout(() => {
document.documentElement.style.setProperty('--consent-perspective', '1000px');
document.documentElement.style.setProperty('--consent-rx', '0deg');
/* Restore: consent panel returns to normal appearance 200ms after click */
}, 200);
});
/* Static audit at page load:
* getComputedStyle(el).transform → "perspective(1000px) rotateX(0deg)"
* This is a no-op transform — applies no visual change.
* Nothing flagged.
*
* Dynamic audit must simulate mousedown and check transform during the active window.
* The custom property change produces a new computed transform that must be re-evaluated.
*/
Detection for SA-CSS-PT-004: SkillAudit simulates install-button mousedown events during the dynamic audit phase. It captures the computed transform matrix at 10ms, 50ms, and 200ms after mousedown simulation, then calculates the 3D projected element height and width at each sampling point. If the projected visible area drops below a readable threshold (<20px height or <40px width) during the mousedown window, the transform-based consent collapse is flagged.
Findings summary
transform: perspective(10px) rotateX(89deg) — visual height ≈ 1.75px on a 100px panel; getComputedStyle().height returns "100px" (pre-transform CSS value); standard auditors read CSS height and pass; detection requires 3D projection matrix evaluation or actual rendered pixel measurement.perspective-origin: 50% 0% + rotateX on parent — vanishing point at top of consent panel; consent text (in upper portion of panel) compressed to near-zero visual size; bottom border of panel remains relatively visible, creating illusion of a visible panel with no content.perspective(1px) rotateY(89.9deg) collapses inline width — consent panel reduced to a near-zero-width vertical sliver; getComputedStyle().width returns "300px"; getBoundingClientRect().width behavior varies by browser (full layout width vs projected width); cross-browser inconsistency creates audit coverage gaps.perspective(1000px) rotateX(0deg); JS sets --consent-perspective: 1px; --consent-rx: 89.9deg at mousedown; panel collapses; properties restored 200ms after click; requires dynamic audit with mousedown simulation to detect.Summary table
| Attack | Severity | Axis | getBoundingClientRect reveals? | Detection method |
|---|---|---|---|---|
| SA-CSS-PT-001: perspective(10px) rotateX(89deg) | Critical | Block (height) | Yes (~1.75px) in Chromium | Parse transform matrix; calculate 3D projected height; check <readability threshold |
| SA-CSS-PT-002: perspective-origin: 50% 0% | High | Block (partial top) | Partial — bottom visible | Check perspective-origin on parent; simulate rendering at given origin and rotation |
| SA-CSS-PT-003: perspective(1px) rotateY(89.9deg) | High | Inline (width) | Browser-dependent | Calculate projected inline width from transform matrix; check <40px readable threshold |
| SA-CSS-PT-004: custom property collapse at mousedown | Medium | Block (height) | Only during active window | Simulate mousedown; sample transform at 10/50/200ms; calculate projected area |