MCP server CSS perspective-origin security: perspective-origin:200% 200% extreme vanishing point collapses consent to hairline, top-left origin with rotateX clips behind parent, CSS custom property indirection, and JS parent origin swap at mousedown
Published 2026-08-07 — SkillAudit Research
The CSS perspective-origin property sets the position of the vanishing point used when rendering 3D perspective — it answers "where does the viewer appear to stand" rather than "how far away the viewer is" (which is the perspective distance property). While perspective controls the depth of the perspective effect, perspective-origin controls which part of the element's 3D space the viewer looks at. By shifting the origin to an extreme position such as 200% 200% (200% right and 200% below the element's own dimensions), the vanishing point sits far outside the element bounds. When combined with a rotateX or rotateY transform applied to a child element, this extreme vanishing point creates severe foreshortening — collapsing the child's rendered height to near-zero while offsetHeight reports the full layout value, display is block, and opacity is 1.
This attack is distinct from perspective-security (which covers the perspective distance property — e.g., perspective: 1px extreme foreshortening) and from transform-origin security (which covers pivot-point manipulation on the transformed element itself). The perspective-origin property is set on the parent element, not the transformed child. All major MCP client environments support CSS 3D Transforms: Chrome 36+, Firefox 16+, Safari 9+. See also individual translate property attacks for a related CSS Transforms Level 2 detection gap.
Detection gap: perspective-origin is a property of the parent (or ancestor) element, not the consent element itself. Checking only the consent element's computed styles misses this attack entirely. The correct audit checks getComputedStyle(el.parentElement).perspectiveOrigin (and ancestor chain) for unusual values when the element has a 3D transform applied.
Attack 1: perspective-origin:200% 200% on parent with rotateX(89deg) on consent — collapses to hairline (SA-CSS-PORS-001)
perspective-origin: 200% 200% on the dialog container moves the vanishing point to 200% right and 200% below the container's own dimensions — far beyond its border box. The consent element has transform: rotateX(89deg) (nearly 90° back-tilt). Without an extreme vanishing point, rotateX(89deg) collapses the element to a hairline and would be detectable as a large-angle rotation. With the origin at 200% 200%, the foreshortening becomes asymmetric: the top edge of the consent element rotates toward the viewer while the bottom tilts away, and at 89° tilt the rendered height converges to approximately 1–2 pixels even with a 60px layout height. The perspective distance is set to a moderate 400px — not the extreme 1px that triggers obvious perspective-security detectors. getBoundingClientRect().height returns close to zero; offsetHeight still reports 60px.
/* MCP attack — applied to the dialog container, not consent: */
.mcp-dialog {
perspective: 400px;
perspective-origin: 200% 200%;
/* Vanishing point is 2× container width right and 2× height below */
}
/* Applied to the consent child: */
.consent-disclosure {
transform: rotateX(89deg);
transform-style: preserve-3d;
/* 89° back-tilt — nearly flat in the camera plane
offsetHeight: 60px (layout — unchanged)
getBoundingClientRect().height: ~1px (foreshortened to hairline)
getComputedStyle(el).transform: 'matrix3d(...)' with near-zero y-scale */
}
// Detection:
function detectPerspectiveOriginAttack(el) {
let ancestor = el.parentElement;
while (ancestor) {
const cs = window.getComputedStyle(ancestor);
const po = cs.perspectiveOrigin; // e.g., "800px 600px" or "200% 200%"
const perspective = parseFloat(cs.perspective);
if (po && po !== '50% 50%') {
// Parse x and y from "200% 200%" or "800px 600px"
const parts = po.trim().split(/\s+/);
const xPct = parts[0]?.includes('%') ? parseFloat(parts[0]) : null;
const yPct = parts[1]?.includes('%') ? parseFloat(parts[1]) : null;
if ((xPct !== null && Math.abs(xPct - 50) > 80) ||
(yPct !== null && Math.abs(yPct - 50) > 80)) {
console.error('SA-CSS-PORS-001: extreme perspective-origin on ancestor', {
ancestor, perspectiveOrigin: po, perspective
});
}
}
ancestor = ancestor.parentElement;
}
// Geometric check
const rect = el.getBoundingClientRect();
if (rect.height < 3 && el.offsetHeight > 20 && el.textContent.trim().length > 0) {
console.error('SA-CSS-PORS-001: consent renders <3px tall while offsetHeight >20px', { el, rect });
}
}
Attack 2: perspective-origin:top left with rotateX(45deg) — consent half-clipped behind parent overflow (SA-CSS-PORS-002)
perspective-origin: 0% 0% (equivalent to top left) places the vanishing point at the top-left corner of the containing block. With a consent element in the lower portion of the dialog, transform: rotateX(45deg) tilts the consent element's bottom edge away from the viewer and top edge toward the viewer — but with the vanishing point at the top-left corner, the perspective lines converge toward the upper-left. This causes the upper portion of the consent element to rotate toward the viewer (becoming larger in the projected plane) while the lower portion tilts away. Combined with overflow: hidden on the parent container and a modest perspective: 300px setting, the tilt pushes approximately half the consent element behind the parent's overflow boundary, making only a thin band of the element visible — and that visible band is the rotated top edge, not the readable text body.
/* MCP attack: */
.mcp-dialog {
perspective: 300px;
perspective-origin: 0% 0%; /* or: top left */
overflow: hidden;
}
.consent-disclosure {
transform: rotateX(45deg);
transform-style: preserve-3d;
/* Top edge tilts toward viewer; bottom edge tilts away and behind overflow
The readable text area is in the mid/bottom section — clipped
Only the top edge band is visible — no readable consent content */
}
// Detection:
function detectTopLeftPerspectiveOrigin(el) {
let ancestor = el.parentElement;
while (ancestor) {
const cs = window.getComputedStyle(ancestor);
const po = cs.perspectiveOrigin;
if (po) {
const parts = po.trim().split(/\s+/);
const xPx = parseFloat(parts[0]);
const yPx = parseFloat(parts[1]);
const elRect = el.getBoundingClientRect();
const ancRect = ancestor.getBoundingClientRect();
// Origin is near a corner (within 10% of ancestor dimensions)
if (xPx < ancRect.width * 0.1 && yPx < ancRect.height * 0.1) {
const cs2 = window.getComputedStyle(el);
if (cs2.transform && cs2.transform !== 'none') {
console.error('SA-CSS-PORS-002: corner perspective-origin + transform on consent', {
ancestor, perspectiveOrigin: po, transform: cs2.transform
});
}
}
}
ancestor = ancestor.parentElement;
}
}
Attack 3: CSS custom property indirection — perspective-origin:var(--mcp-vp) with var resolving to extreme position (SA-CSS-PORS-003)
The dialog container's perspective-origin is set to var(--mcp-vp-x) var(--mcp-vp-y), with :root { --mcp-vp-x: 200%; --mcp-vp-y: 200%; } defined in a separate theme or configuration CSS file. A stylesheet scanner reading the dialog's rule sees only perspective-origin: var(--mcp-vp-x) var(--mcp-vp-y) — without resolving the variable chain, there is no obviously suspicious large value on the rule itself. The tokens may appear to be legitimate layout configuration (e.g., a slider panel's "fly-in origin" configuration). getComputedStyle(ancestor).perspectiveOrigin resolves the var() chain and returns the actual computed position in pixel or percentage form, exposing the extreme vanishing point regardless of variable nesting depth.
/* MCP attack: */
:root {
--mcp-vp-x: 200%; /* "viewport perspective x" — sounds like UI config */
--mcp-vp-y: 200%;
}
.mcp-dialog {
perspective: 400px;
perspective-origin: var(--mcp-vp-x) var(--mcp-vp-y);
/* Source scanner sees: var() references — no large literal value */
}
.consent-disclosure {
transform: rotateX(88deg);
/* Extreme tilt + displaced origin = hairline rendering */
}
// Detection: computed value resolves var() chain
function detectVarPerspectiveOrigin() {
document.querySelectorAll('[style*="perspective"], [class*="dialog"], [class*="modal"]').forEach(parent => {
const po = window.getComputedStyle(parent).perspectiveOrigin;
if (!po) return;
const parts = po.trim().split(/\s+/);
const xPct = parts[0]?.includes('%') ? parseFloat(parts[0]) : null;
const yPct = parts[1]?.includes('%') ? parseFloat(parts[1]) : null;
if ((xPct !== null && Math.abs(xPct - 50) > 80) ||
(yPct !== null && Math.abs(yPct - 50) > 80)) {
parent.querySelectorAll('[data-consent], .consent-disclosure').forEach(el => {
console.error('SA-CSS-PORS-003: var() resolved perspective-origin exposes extreme origin', {
parent, perspectiveOrigin: po
});
});
}
});
}
Attack 4: JS mousedown swaps parent perspective-origin to extreme position — consent collapses during install click (SA-CSS-PORS-004)
At page load, the dialog has perspective-origin: 50% 50% (the default center position) and the consent element has a mild 3D transform that renders it normally. This passes any load-time audit that checks perspective-origin values. At mousedown on the install button, JS reassigns dialogEl.style.perspectiveOrigin = '200% 200%'. Because the consent child has transform-style: preserve-3d and a pre-existing rotateX applied, the perspective foreshortening immediately takes effect — the consent collapses to a hairline during the install click without touching the consent element's own styles. MutationObserver on the dialog element's style attribute detects the origin change.
/* Baseline CSS — loads normally: */
.mcp-dialog {
perspective: 400px;
perspective-origin: 50% 50%; /* center — default, passes audit */
}
.consent-disclosure {
transform: rotateX(5deg); /* mild tilt — still readable */
transform-style: preserve-3d;
transition: perspective-origin 0.1s; /* parent transition, fast */
}
// MCP JS — triggers during install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const dialog = document.querySelector('.mcp-dialog');
if (dialog) {
dialog.style.perspectiveOrigin = '200% 200%';
/* Vanishing point jumps to extreme position
Consent child rotateX(5deg) becomes severe foreshortening
At extreme origin, even 5° tilt collapses rendered height to near-zero
Looks like a "perspective close" UI animation */
}
}, { capture: true });
// Detection:
function detectDynamicPerspectiveOrigin() {
document.querySelectorAll('.mcp-dialog, [class*="dialog"], [class*="modal"]').forEach(parent => {
const observer = new MutationObserver(() => {
const po = window.getComputedStyle(parent).perspectiveOrigin;
const parts = po.trim().split(/\s+/);
const xPct = parts[0]?.includes('%') ? parseFloat(parts[0]) : null;
const yPct = parts[1]?.includes('%') ? parseFloat(parts[1]) : null;
if ((xPct !== null && Math.abs(xPct - 50) > 80) ||
(yPct !== null && Math.abs(yPct - 50) > 80)) {
console.error('SA-CSS-PORS-004: perspective-origin changed to extreme value at interaction', {
parent, perspectiveOrigin: po
});
// Also check consent child BCR
parent.querySelectorAll('[data-consent], .consent-disclosure').forEach(el => {
requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
if (rect.height < 3) console.error('SA-CSS-PORS-004: consent height collapsed after origin swap', { el, rect });
});
});
}
});
observer.observe(parent, { attributes: true, attributeFilter: ['style'] });
// Simulate mousedown
document.querySelector('#install-btn, [data-action="install"]')
?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
});
}
Root detection method for all perspective-origin attacks: Walk the ancestor chain of each consent element and check getComputedStyle(ancestor).perspectiveOrigin. Flag any origin where x or y deviates more than 80 percentage points from center (50%). Also compute the geometric result: if getBoundingClientRect().height < 3px while offsetHeight > 20px and the element has non-empty text, flag the discrepancy regardless of what property caused it. SkillAudit audits perspective-origin on the full ancestor chain, not just the consent element's own computed styles.
Attack summary
| ID | CSS / JS technique | offsetHeight | getBCR().height | perspectiveOrigin (parent) | Severity |
|---|---|---|---|---|---|
| SA-CSS-PORS-001 | Parent perspective-origin:200% 200% + child rotateX(89deg) | 60px | ~1px | '200% 200%' | High |
| SA-CSS-PORS-002 | Parent perspective-origin:top left + child rotateX(45deg) + overflow:hidden | 60px | partial | '0% 0%' | High |
| SA-CSS-PORS-003 | Parent perspective-origin:var(--mcp-vp-x) resolves to 200% | 60px | ~1px | '200% 200%' (computed) | High |
| SA-CSS-PORS-004 | JS sets parent perspectiveOrigin='200% 200%' at mousedown | 60px | ~1px (after) | '200% 200%' (after mousedown) | High |
Consolidated finding blocks
perspective-origin: 200% 200% with moderate perspective: 400px. Consent child has rotateX(89deg). Extreme vanishing point creates asymmetric foreshortening; rendered height collapses to ~1px. offsetHeight remains 60px; display: block; opacity: 1. Only ancestor perspectiveOrigin check and geometric BCR reveal the attack.
0% 0% combined with child rotateX(45deg) and parent overflow: hidden clips the readable portion of the consent element behind the container's top edge. Only a thin top-edge band of the rotated element remains visible — not the text body.
getComputedStyle(ancestor).perspectiveOrigin resolves the chain to the actual extreme position (200% 200%), exposing the attack regardless of how many custom property layers the MCP server uses.
perspective-origin: 50% 50% (default) and consent renders normally. At mousedown, JS changes the parent's perspectiveOrigin style; existing child rotateX immediately foreshortens consent to near-zero. MutationObserver on parent's style attribute detects the change.
CSS perspective distance security | CSS transform-origin security | CSS translate property security | Security Checklist