Security Guide

MCP server CSS transform security — scale(0) collapse, translateX off-screen, skewX text distortion, 3D rotation hiding consent disclosures

CSS transform manipulates the visual rendering of an element without touching its layout box — and that separation is exactly what makes it such a powerful attack surface for hiding MCP server consent and permission disclosures. Unlike display:none or visibility:hidden, a transformed element still occupies its original space in the document flow, still has a non-zero offsetHeight and offsetWidth, and can still pass most standard visibility checks. Four distinct attack vectors exploit this divergence: transform:scale(0) collapses the element to a single invisible point while getBoundingClientRect() continues to report the original pre-scale dimensions; transform:translateX(-9999px) moves the visual rendering off-screen while the layout box stays in place, hiding the displacement from offsetLeft-based checks; extreme skewX or scaleX values crush text into an unreadable hair-thin sliver while bounding box dimensions remain positive; and translateZ combined with a perspective() ancestor creates a 3D stacking context that places the disclosure behind other elements regardless of its 2D z-index. Each vector requires a different detection approach — none are caught by standard display, visibility, or opacity checks.

Why CSS transform bypasses standard visibility checks

CSS transforms operate in the compositing layer — they affect how the browser paints and composites an element, but they do not affect the element's position in the layout flow, its box model dimensions, or any of the DOM properties that standard visibility audits rely on. This creates a systematic gap between visual reality and what the DOM API reports:

The only DOM API that reveals transform-based hiding is getComputedStyle(el).transform, which returns the computed transform as a CSS matrix string. Parsing and decomposing that matrix is the only reliable detection path for scale, skew, and 3D-stacking attacks. For translate attacks, getBoundingClientRect() is the correct tool — but only when compared against the viewport bounds, not against offsetLeft.

Critical distinction: getBoundingClientRect() behaviour differs between transform types. For translate, it reports the visual (post-transform) position — so off-screen translate is detectable. For scale, it reports the layout-box dimensions before scaling — so a scale(0) element appears to have normal dimensions. This inconsistency means no single check catches all transform attacks; a comprehensive audit must decompose the computed transform matrix and apply type-specific detection logic for each component (scaleX, scaleY, translateX, translateY, skewX, and the Z-axis components of a 3D matrix).

Attack 1 — transform:scale(0) — collapsing element to zero rendered size (HIGH)

transform:scale(0) scales the element uniformly to zero in both axes, shrinking its visual rendering to a single point at the transform-origin (default: center center). The element remains in the DOM flow — it still contributes its full original height and width to the containing block's layout — but it is visually invisible. To a human looking at the page, there is nothing there. To every standard DOM API check, the element is fully present with correct dimensions.

/* Malicious CSS: scale(0) applied to MCP consent disclosure */
.mcp-disclosure {
  transform: scale(0);
  /* Element occupies full layout space — parent container has expected height */
  /* Visually invisible: rendered as a zero-size point at center center */
  /* No layout shift in the UI — nothing appears broken or missing */
}

/* Variant: scale(0.001) — renders 300px disclosure as a 0.3px dot */
.mcp-disclosure-v2 {
  transform: scale(0.001);
  /* getBoundingClientRect().height returns 300 */
  /* Visual rendered height: 300 * 0.001 = 0.3px — subpixel, invisible */
}

/* Variant: scale(0, 0) — explicit two-argument form */
.mcp-disclosure-v3 {
  transform: scale(0, 0); /* same as scale(0) */
}

/* Variant: scaleX(0) scaleY(0) — longhand form */
.mcp-disclosure-v4 {
  transform: scaleX(0) scaleY(0);
}

The scale factor of 0.001 is a particularly insidious variant. A disclosure element that is 300px tall and 400px wide becomes 0.3px by 0.4px at scale(0.001) — a sub-pixel dot that is physically unpaintable on any display. Yet getBoundingClientRect() returns { width: 400, height: 300, ... } with the element's layout-box coordinates. Any check that tests getBoundingClientRect().height > 0 returns true. The element "passes" every standard visibility check while being completely invisible to the user.

Why standard checks fail for scale(0): getBoundingClientRect() for a scaled element returns the element's axis-aligned bounding box in layout coordinates — which, for a scale transform applied to a block element, equals the pre-scale dimensions. The spec allows browsers to return either the visual bounds or the layout bounds for transformed elements, and in practice all major browsers return the layout-box coordinates for scale-only transforms. This means a 300px × 400px disclosure at scale(0) reports getBoundingClientRect().width === 400 and .height === 300. Combined with non-zero offsetHeight, display: block, visibility: visible, and opacity: 1, the element passes every check in the standard audit playbook.

Detection: parsing the computed transform matrix

The only reliable way to detect scale-based hiding is to parse the computed transform matrix from getComputedStyle(el).transform. CSS transforms are always returned as a matrix — either a 2D matrix(a,b,c,d,e,f) or a 3D matrix3d(...). For a 2D matrix, the scale components are extracted as follows:

// Parse the computed transform matrix to extract scale components
function getScaleComponents(el) {
  const t = getComputedStyle(el).transform;
  if (t === 'none') return { scaleX: 1, scaleY: 1 };

  // 2D matrix: matrix(a, b, c, d, e, f)
  const m2 = t.match(/^matrix\(([^)]+)\)$/);
  if (m2) {
    const [a, b, c, d] = m2[1].split(',').map(Number);
    // scaleX = sqrt(a² + b²), scaleY = sqrt(c² + d²)
    const scaleX = Math.sqrt(a * a + b * b);
    const scaleY = Math.sqrt(c * c + d * d);
    return { scaleX, scaleY };
  }

  // 3D matrix: matrix3d(a1, a2, ..., a16) — 4x4 column-major
  const m3 = t.match(/^matrix3d\(([^)]+)\)$/);
  if (m3) {
    const vals = m3[1].split(',').map(Number);
    // Column 0: (vals[0], vals[1], vals[2]), Column 1: (vals[4], vals[5], vals[6])
    const scaleX = Math.sqrt(vals[0]**2 + vals[1]**2 + vals[2]**2);
    const scaleY = Math.sqrt(vals[4]**2 + vals[5]**2 + vals[6]**2);
    return { scaleX, scaleY };
  }

  return { scaleX: 1, scaleY: 1 }; // fallback
}

// Flag as collapsed if either scale component is below threshold
function isCollapsedByScale(el) {
  const { scaleX, scaleY } = getScaleComponents(el);
  const SCALE_THRESHOLD = 0.1; // anything below 10% is effectively invisible
  return scaleX < SCALE_THRESHOLD || scaleY < SCALE_THRESHOLD;
}

// Usage: run on every element that might be a consent disclosure
const disclosure = document.querySelector('.mcp-disclosure');
if (isCollapsedByScale(disclosure)) {
  console.error('FINDING: Disclosure collapsed via transform:scale — visual size near zero');
}

The threshold of 0.1 (10% of original size) is a practical cutoff: at 10% scale, a 300px disclosure renders as 30px — still technically visible but extremely difficult to read. At 5% scale it renders as 15px; at 1% scale as 3px. Any scale value below 0.1 should be flagged as a deliberate hiding attempt. Legitimate animations that happen to pass through low scale values should not be applied to consent disclosure elements; if they are, the final resting scale (not the in-transit scale) is what matters.

Attack 2 — transform:translateX(-9999px) — off-screen displacement (HIGH)

transform:translate() moves the element's visual rendering by a fixed offset without altering its layout position. The layout box — the space the element occupies in the document flow — stays exactly where it was. The visual rendering moves by the specified amount. This creates a split between where the element is in the layout (where other elements flow around it, where offsetLeft and offsetTop point) and where the element appears on screen.

/* Malicious CSS: translateX moves disclosure far off-screen to the left */
.mcp-disclosure {
  transform: translateX(-9999px);
  /* Layout box: stays in original position — no gap in the UI */
  /* Visual rendering: moved 9999px to the left of the screen */
  /* offsetLeft: returns original position — NOT suspicious */
  /* getBoundingClientRect().left: returns original_left - 9999 — reveals the attack */
}

/* Variant: negative translateY — moves below bottom of page */
.mcp-disclosure-v2 {
  transform: translateY(9999px);
}

/* Variant: viewport-relative — adapts to window size */
.mcp-disclosure-v3 {
  transform: translateX(-100vw);
  /* At 1440px viewport: renders as translateX(-1440px) */
  /* getBoundingClientRect().left = original_left - 1440 — off left edge */
}

/* Variant: percentage — relative to element's own width */
.mcp-disclosure-v4 {
  transform: translateX(-500%);
  /* On a 400px-wide element: moves 2000px to the left */
}

Unlike position-based off-screen attacks (position:absolute; left:-9999px), a transform:translateX(-9999px) attack does not show a suspicious gap in the UI. The layout box remains in place, so surrounding elements still flow around it normally. A dialog that should have a disclosure paragraph in a particular position still shows that position filled in the layout — the element is there, taking up space. The disclosure text has simply been visually transported off-screen while leaving its spatial footprint behind.

The offsetLeft / offsetTop blind spot: element.offsetLeft and element.offsetTop return the element's position relative to its offset parent, computed from the layout box — they are unaffected by CSS transforms. A disclosure pushed off-screen via translateX(-9999px) has the same offsetLeft as if no transform were applied. Any audit that checks offsetLeft < 0 or offsetTop < 0 to catch off-screen placement will miss this attack entirely. Only getBoundingClientRect() accounts for transforms when reporting coordinates — it returns the visual bounding rect, not the layout-box rect. For translate attacks, getBoundingClientRect().left < 0 (or .right > window.innerWidth) is the correct signal.

Detection: comparing getBoundingClientRect against viewport bounds

Because getBoundingClientRect() does account for translate transforms (returning the visual position), detecting off-screen translate is straightforward — but only if you check against viewport boundaries rather than relying on offsetLeft:

// Detect off-screen displacement via translate transform
function isOffScreenByTranslate(el) {
  const rect = el.getBoundingClientRect();
  const vw = window.innerWidth;
  const vh = window.innerHeight;

  // Element is entirely to the left of the viewport
  if (rect.right <= 0) return { offScreen: true, direction: 'left', amount: -rect.right };
  // Element is entirely to the right of the viewport
  if (rect.left >= vw) return { offScreen: true, direction: 'right', amount: rect.left - vw };
  // Element is entirely above the viewport
  if (rect.bottom <= 0) return { offScreen: true, direction: 'top', amount: -rect.bottom };
  // Element is entirely below the viewport
  if (rect.top >= vh) return { offScreen: true, direction: 'bottom', amount: rect.top - vh };

  return { offScreen: false };
}

// Additionally: extract translate components from the computed matrix
function getTranslateComponents(el) {
  const t = getComputedStyle(el).transform;
  if (t === 'none') return { tx: 0, ty: 0 };

  const m2 = t.match(/^matrix\(([^)]+)\)$/);
  if (m2) {
    const parts = m2[1].split(',').map(Number);
    return { tx: parts[4], ty: parts[5] }; // e and f components
  }

  const m3 = t.match(/^matrix3d\(([^)]+)\)$/);
  if (m3) {
    const vals = m3[1].split(',').map(Number);
    return { tx: vals[12], ty: vals[13], tz: vals[14] }; // m41, m42, m43
  }

  return { tx: 0, ty: 0 };
}

// Flag large translate values as suspicious even if inside the viewport
// (e.g. translateX(-9998px) on a disclosure at x=10000px would still be in viewport)
function hasSuspiciousTranslate(el) {
  const { tx, ty } = getTranslateComponents(el);
  const TRANSLATE_THRESHOLD = 200; // any translate > 200px on a consent disclosure is suspicious
  return Math.abs(tx) > TRANSLATE_THRESHOLD || Math.abs(ty) > TRANSLATE_THRESHOLD;
}

The large-translate heuristic (flagging any translate greater than 200px on a consent disclosure element) catches cases where the disclosure is positioned far to the right side of a wide page and a large negative translate brings it back into view — and then a slightly smaller translate moves it just off the left edge but within 9999px. The safe approach is to flag any consent disclosure element with a large translate component regardless of whether it appears off-screen, and confirm with the getBoundingClientRect viewport check.

Related attack surface: The split between layout position and visual position created by translate transforms is also exploited by the CSS will-change property, which can trigger compositing layer promotion and stacking context changes that affect how translate-positioned elements interact with other elements' z-index. See MCP server CSS will-change security for the compositing-layer attack surface.

Attack 3 — transform:skewX(89deg) or scaleX(0.001) — text compressed to unreadable sliver (MEDIUM-HIGH)

Not all transform hiding attacks aim to make the element disappear entirely. A subtler class of attack uses extreme shear or one-axis compression to make the disclosure text visually unreadable while keeping the element clearly present in the DOM with positive dimensions and a non-zero visual footprint. The element is technically "visible" in every measurable sense — it has positive rendered dimensions and is within the viewport — but no human can read what it says.

/* Attack: extreme skewX collapses all text to vertical lines */
.mcp-disclosure {
  transform: skewX(89deg);
  /* At 89 degrees horizontal shear, all horizontal text lines become
     near-vertical. Each character is compressed horizontally and
     stretched vertically to near-infinite. The text appears as a
     forest of thin vertical lines — the element has positive width
     and height in getBoundingClientRect but is unreadable. */
}

/* Attack: scaleX(0.001) — single-axis compression */
.mcp-disclosure-v2 {
  transform: scaleX(0.001);
  /* A 400px-wide disclosure is compressed to 0.4px wide — one pixel column.
     Height is unaffected. The element is a tall thin vertical line.
     getBoundingClientRect() reports: width ~400 (layout box), height unchanged.
     Visual rendered width: 0.4px — subpixel, invisibly thin. */
}

/* Attack: rotate(90deg) — text rotated sideways */
.mcp-disclosure-v3 {
  transform: rotate(90deg);
  /* Text is rotated 90 degrees clockwise. Readable sideways only.
     getBoundingClientRect swaps width/height for the visual bounds
     but offsetWidth/offsetHeight remain unchanged. */
}

/* Attack: rotate(180deg) — text upside down */
.mcp-disclosure-v4 {
  transform: rotate(180deg);
  /* Text is flipped both horizontally and vertically — upside down and mirrored.
     Technically readable with effort, but combined with rapid UI flow
     (auto-advance, time pressure) creates effective obscuration. */
}

/* Compound attack: skewX + scaleX */
.mcp-disclosure-v5 {
  transform: skewX(45deg) scaleX(0.01);
  /* Combined shear and compression — effectively invisible */
}

The skewX(89deg) attack deserves special analysis. A horizontal skew of 89 degrees means the element's horizontal axis is rotated 89 degrees toward the vertical. All text rendered inside the element — which flows horizontally in the element's local coordinate system — appears nearly vertical on screen. Each character is sheared so severely that it becomes a thin diagonal stroke. A paragraph of disclosure text becomes a row of near-identical vertical marks with no readable letter forms. The element has a bounding box (which may actually be larger than the original due to the parallelogram expansion from skew) and is present in the viewport, but no human can extract meaningful text from it.

Why getBoundingClientRect cannot catch skew attacks: For a severely skewed element, the axis-aligned bounding box (which is what getBoundingClientRect() returns) is actually larger than the un-skewed element. A 400px × 200px disclosure at skewX(89deg) has a bounding box approximately 400 + 200 * tan(89deg) ≈ 11,500px wide. This enormous bounding box is still reported as the element's rect, and any check that tests whether the bounding box is within the viewport would correctly flag this as off-screen — but only because the extreme skew pushes the bounding box out, not because the attack is directly detected. Moderate skew values (e.g. skewX(60deg)) would keep the bounding box within the viewport while still making text unreadable.

Detection: extracting skew and single-axis scale from the transform matrix

In a 2D transform matrix matrix(a, b, c, d, e, f), the skew components are encoded in the relationship between the matrix columns after normalising for scale. The full decomposition:

// Full 2D matrix decomposition for skew detection
function decompose2DMatrix(el) {
  const t = getComputedStyle(el).transform;
  if (t === 'none') return { scaleX: 1, scaleY: 1, skewXDeg: 0, rotation: 0 };

  const m = t.match(/^matrix\(([^)]+)\)$/);
  if (!m) return null;

  const [a, b, c, d, e, f] = m[1].split(',').map(Number);

  // Extract scale
  const scaleX = Math.sqrt(a * a + b * b);
  const scaleY = Math.sqrt(c * c + d * d);

  // Extract rotation (in radians)
  const rotation = Math.atan2(b, a);

  // Extract skewX: after removing rotation and scale, the remaining
  // off-diagonal element encodes the shear. For a pure skewX(theta):
  // matrix(1, 0, tan(theta), 1, 0, 0)
  // After scale normalisation: skewXAngle = atan2(a*c + b*d, scaleX * scaleY)
  const skewXRad = Math.atan2(a * c + b * d, scaleX * scaleY);
  const skewXDeg = skewXRad * (180 / Math.PI);

  return { scaleX, scaleY, skewXDeg, rotation: rotation * (180 / Math.PI) };
}

// Detection thresholds for distortion attacks on consent disclosures
function isDistortedByTransform(el) {
  const decomposed = decompose2DMatrix(el);
  if (!decomposed) return false;

  const { scaleX, scaleY, skewXDeg, rotation } = decomposed;

  // Single-axis collapse: scaleX or scaleY below 0.05 (5%)
  if (scaleX < 0.05 || scaleY < 0.05) {
    return { distorted: true, reason: `scaleX=${scaleX.toFixed(4)} or scaleY=${scaleY.toFixed(4)} below 5% threshold` };
  }

  // Extreme shear: skewX beyond ±60 degrees makes text unreadable
  if (Math.abs(skewXDeg) > 60) {
    return { distorted: true, reason: `skewX=${skewXDeg.toFixed(1)}deg exceeds 60-degree readability threshold` };
  }

  // Sideways rotation: 90±15 degrees or 270±15 degrees
  const absRot = Math.abs(rotation % 360);
  const isSideways = (absRot > 75 && absRot < 105) || (absRot > 255 && absRot < 285);
  if (isSideways) {
    return { distorted: true, reason: `rotation=${rotation.toFixed(1)}deg — text rendered sideways` };
  }

  // Upside-down: 180±30 degrees
  const isUpsideDown = absRot > 150 && absRot < 210;
  if (isUpsideDown) {
    return { distorted: true, reason: `rotation=${rotation.toFixed(1)}deg — text rendered upside down` };
  }

  return { distorted: false };
}

The 60-degree skew threshold is chosen because at 60 degrees, tan(60°) ≈ 1.73 — each character column is shifted 1.73x the line height relative to adjacent columns, making the text extremely difficult to read even at slow reading pace. Below 45 degrees the text is distorted but legible; above 60 degrees it becomes effectively unreadable for most users. For consent disclosures, any skew beyond 30 degrees should be flagged and reviewed.

Related attack surface: CSS Individual Transforms (scale, rotate, translate as standalone properties, introduced in the CSS Transforms Level 2 spec) provide an alternative syntax for the same attacks that may evade regex-based detection looking for the transform property name. See MCP server CSS individual transforms security for how standalone scale, rotate, and translate properties are used in consent disclosure hiding attacks.

Attack 4 — transform:translateZ(-1px) with perspective — 3D stacking context attack (HIGH)

CSS 3D transforms introduce a third dimension to the browser's rendering model. When an element uses a 3D transform function (translateZ, rotateX, rotateY, perspective(), translate3d, matrix3d), the browser creates a 3D rendering context and positions the element in 3D space. This has two exploitable consequences: elements can be moved behind other elements in 3D space regardless of their 2D z-index, and 3D transforms create new stacking contexts that isolate an element's z-index from its parent's stacking order.

/* Attack: translateZ places disclosure behind dialog backdrop in 3D space */

/* Ancestor element: creates the 3D perspective context */
.mcp-dialog {
  transform-style: preserve-3d;  /* CRITICAL: enables 3D context for children */
  perspective: 800px;             /* defines the vanishing point distance */
}

/* Dialog backdrop: appears in front in 3D space */
.mcp-dialog-backdrop {
  transform: translateZ(0px);   /* at z=0 in the 3D context */
  background: rgba(255,255,255,0.98);
  position: absolute;
  inset: 0;
}

/* Disclosure: pushed behind the backdrop in 3D space */
.mcp-disclosure {
  transform: translateZ(-1px);   /* 1px behind the backdrop — rendered under it */
  /* 2D z-index check: getComputedStyle().zIndex returns 'auto' — not suspicious */
  /* Visual result: disclosure is behind the white backdrop, completely hidden */
  /* offsetHeight: returns normal value — no layout indication */
  /* getBoundingClientRect: returns layout-box coordinates — appears in viewport */
}
/* Extreme variant: beyond-vanishing-point placement */
.mcp-dialog {
  transform-style: preserve-3d;
  perspective: 500px;  /* vanishing point 500px "in front of" the screen */
}

.mcp-disclosure {
  transform: translateZ(-600px);
  /* At perspective:500px, a point at z=-600 is beyond the vanishing point
     It is projected behind the viewer and becomes invisible in the 3D projection.
     The element effectively disappears from the rendered output.
     All 2D DOM checks still return normal values. */
}

The vanishing-point attack (perspective:500px + translateZ(-600px)) is particularly dangerous because the element is mathematically projected out of the visible frustum — it is "behind the camera" in the 3D perspective projection. No amount of 2D z-index manipulation can bring it back into view; it is outside the rendering space entirely. Yet getComputedStyle(el).zIndex returns 'auto', display returns 'block', and offsetHeight returns the original element height.

The stacking context isolation trap: CSS 3D transforms create a new stacking context on the transformed element. This means the element's z-index is evaluated within its own stacking context, not against the parent dialog's stacking context. Even if the disclosure has z-index: 999, if its 3D transform places it at a negative Z coordinate in its parent's 3D rendering context, the 3D placement overrides the 2D stacking order. An audit that checks z-index values on the disclosure will see 999 and conclude no stacking attack is present — but the 3D transform silently wins. The only way to detect this is to parse the computed transform for Z-axis components and check whether a preserve-3d ancestor exists.

Detection: parsing 3D transform components and checking for preserve-3d ancestors

// Detect 3D stacking attacks on consent disclosure elements
function analyze3DTransformAttack(el) {
  const issues = [];

  // Step 1: Check for 3D transform functions on the element itself
  const transform = getComputedStyle(el).transform;

  // Check for matrix3d — indicates a 3D transform was applied
  const is3DMatrix = /^matrix3d/.test(transform);

  if (is3DMatrix) {
    const vals = transform.match(/matrix3d\(([^)]+)\)/)[1].split(',').map(Number);
    const tz = vals[14]; // m43: Z translation component

    if (tz < 0) {
      issues.push({ type: 'translateZ-negative', tz, severity: 'high',
        detail: `translateZ(${tz}px) moves disclosure behind other 3D-positioned elements` });
    }

    // Check for non-trivial rotateX or rotateY (which also create 3D context)
    const m11 = vals[0], m12 = vals[1], m13 = vals[2];
    const m21 = vals[4], m22 = vals[5], m23 = vals[6];
    if (Math.abs(m13) > 0.01 || Math.abs(m23) > 0.01) {
      issues.push({ type: 'rotateXY-3d', severity: 'medium',
        detail: 'rotateX or rotateY detected — may hide disclosure by rotating it face-away' });
    }
  }

  // Step 2: Walk ancestors looking for transform-style:preserve-3d and perspective
  let ancestor = el.parentElement;
  let foundPreserve3d = false;
  let perspectiveValue = null;

  while (ancestor && ancestor !== document.body) {
    const cs = getComputedStyle(ancestor);

    if (cs.transformStyle === 'preserve-3d') {
      foundPreserve3d = true;
    }
    if (cs.perspective && cs.perspective !== 'none') {
      perspectiveValue = parseFloat(cs.perspective);
    }

    ancestor = ancestor.parentElement;
  }

  if (foundPreserve3d && is3DMatrix) {
    const vals = transform.match(/matrix3d\(([^)]+)\)/)[1].split(',').map(Number);
    const tz = vals[14];

    if (perspectiveValue && Math.abs(tz) > perspectiveValue) {
      issues.push({
        type: 'beyond-vanishing-point',
        severity: 'critical',
        detail: `translateZ(${tz}px) exceeds perspective(${perspectiveValue}px) — element projected beyond vanishing point, invisible in 3D space`
      });
    } else if (tz < 0) {
      issues.push({
        type: '3d-stacking-behind-context',
        severity: 'high',
        detail: `translateZ(${tz}px) in preserve-3d context — disclosure may render behind sibling elements at z=0`
      });
    }
  }

  return issues;
}

// Check for stacking context created by 3D transform (z-index isolation)
function has3DStackingContextIsolation(el) {
  // A 3D transform creates a stacking context, potentially isolating z-index
  const transform = getComputedStyle(el).transform;
  const willChange = getComputedStyle(el).willChange;

  return transform !== 'none' || willChange.includes('transform');
}

The beyond-vanishing-point case (|tz| > perspectiveValue) is classified as critical because the element is mathematically invisible — not merely occluded, but outside the rendering frustum entirely. No CSS adjustment can make the element visible again without removing either the negative translateZ or reducing the perspective value. The disclosure has effectively been removed from the visual rendering space while remaining fully present in the DOM.

Detection matrix

The following table summarises which standard checks pass for each transform attack, and what the correct detection method is. A checkmark in "passes check" means the check returns a non-suspicious result even though the disclosure is hidden.

Check Passes scale(0)? Passes translateX(-9999px)? Passes extreme skewX? Passes translateZ (3D)?
getComputedStyle().display !== 'none' Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
getComputedStyle().visibility Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
getComputedStyle().opacity > 0 Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
element.offsetHeight > 0 Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
getBoundingClientRect().height > 0 Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
getBoundingClientRect() in viewport Passes (false negative) DETECTS (rect is off-screen) Partial (extreme skew expands bbox) Passes (false negative)
offsetLeft / offsetTop < 0 Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
element.checkVisibility() Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
getComputedStyle().zIndex Passes (false negative) Passes (false negative) Passes (false negative) Passes (false negative)
Matrix decomposition: scaleX/Y < 0.1 DETECTS Passes (tx/ty, not scale) DETECTS (scaleX < threshold) Passes (Z not scale)
Matrix decomposition: |tx| or |ty| > 200px Passes (no translation) DETECTS Passes (no translation) Partial (2D components only)
Matrix decomposition: |skewX| > 60deg Passes (no skew) Passes (no skew) DETECTS Passes (no skew)
matrix3d + preserve-3d ancestor + tz < 0 Passes (2D only) Passes (2D only) Passes (2D only) DETECTS

The table makes the core problem clear: standard visibility checks (display, visibility, opacity, offsetHeight, getBoundingClientRect height, checkVisibility) all return false negatives for every transform attack. getBoundingClientRect() viewport intersection catches translate attacks but misses scale, skew, and 3D attacks. Only CSS matrix decomposition — parsing the computed transform value and extracting scale, translate, skew, and Z components individually — provides comprehensive detection coverage.

Defense — secure CSS transform policies for MCP consent dialogs

Defending consent disclosure elements from transform-based hiding requires both proactive policy and runtime validation. The following controls together close all four attack vectors.

1. Content Security Policy for inline styles

If the MCP server's consent dialog renders in a controlled iframe or sandboxed environment, a Content-Security-Policy header can block inline style injection. However, if the transform is applied via a stylesheet (linked or embedded), CSP inline-style restrictions will not catch it — the transform property is valid CSS in any stylesheet. CSP is a necessary but not sufficient control.

/* Server response header for MCP consent dialog iframe */
Content-Security-Policy: style-src 'self'; default-src 'none'

2. Runtime transform audit on disclosure element

Before presenting the consent dialog to the user (and after any JavaScript that may modify styles has executed), run a comprehensive transform audit on the disclosure element:

// Comprehensive transform audit for consent disclosure elements
// Run after all scripts have executed, before showing "Accept" button
function auditDisclosureTransform(disclosureEl) {
  const violations = [];

  // Check 1: Parse computed transform and decompose
  const transformStr = getComputedStyle(disclosureEl).transform;

  if (transformStr !== 'none') {
    // 2D matrix
    const m2 = transformStr.match(/^matrix\(([^)]+)\)$/);
    if (m2) {
      const [a, b, c, d, e, f] = m2[1].split(',').map(Number);
      const scaleX = Math.sqrt(a*a + b*b);
      const scaleY = Math.sqrt(c*c + d*d);
      const tx = e, ty = f;
      const skewXDeg = Math.atan2(a*c + b*d, scaleX*scaleY) * 180/Math.PI;
      const rotDeg = Math.atan2(b, a) * 180/Math.PI;

      if (scaleX < 0.1 || scaleY < 0.1)
        violations.push(`scale collapse: scaleX=${scaleX.toFixed(3)}, scaleY=${scaleY.toFixed(3)}`);
      if (Math.abs(tx) > 200 || Math.abs(ty) > 200)
        violations.push(`off-screen translate: tx=${tx}px, ty=${ty}px`);
      if (Math.abs(skewXDeg) > 60)
        violations.push(`extreme skew: ${skewXDeg.toFixed(1)}deg`);
      const absRot = Math.abs(rotDeg % 360);
      if ((absRot > 75 && absRot < 285 && absRot !== 180) || (absRot > 150 && absRot < 210))
        violations.push(`disorienting rotation: ${rotDeg.toFixed(1)}deg`);
    }

    // 3D matrix
    const m3 = transformStr.match(/^matrix3d\(([^)]+)\)$/);
    if (m3) {
      const vals = m3[1].split(',').map(Number);
      const tz = vals[14]; // Z translation
      if (tz < -10) violations.push(`3D negative Z: translateZ(${tz}px) — possible stacking attack`);
    }
  }

  // Check 2: Viewport position via getBoundingClientRect
  const rect = disclosureEl.getBoundingClientRect();
  if (rect.right <= 0 || rect.left >= window.innerWidth ||
      rect.bottom <= 0 || rect.top >= window.innerHeight) {
    violations.push(`off-screen position: rect=${JSON.stringify({
      left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom
    })}`);
  }

  // Check 3: Preserve-3d ancestor with negative Z
  if (transformStr.includes('matrix3d')) {
    let ancestor = disclosureEl.parentElement;
    while (ancestor) {
      if (getComputedStyle(ancestor).transformStyle === 'preserve-3d') {
        violations.push('preserve-3d ancestor detected with 3D matrix on disclosure — stacking attack risk');
        break;
      }
      ancestor = ancestor.parentElement;
    }
  }

  return violations;
}

// Gate the Accept button on passing the audit
const violations = auditDisclosureTransform(document.querySelector('.mcp-disclosure'));
if (violations.length > 0) {
  // Do not show the Accept button
  // Log violations for security telemetry
  console.error('Transform audit violations:', violations);
  showSecurityError('Consent disclosure integrity check failed');
}

3. MutationObserver to watch for dynamic transform injection

Transform-based hiding attacks may apply the transform after the disclosure is first rendered, using setTimeout, event listeners, or framework lifecycle hooks to inject the hiding transform once initial checks have already passed. A MutationObserver watching the disclosure element's style attribute and the stylesheet rules that apply to it can catch post-render transform injection:

// Watch for dynamic transform injection via style attribute mutation
const disclosureEl = document.querySelector('.mcp-disclosure');

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
      const inlineTransform = disclosureEl.style.transform;
      if (inlineTransform && inlineTransform !== 'none') {
        // Inline transform was added after render — suspicious
        console.error('SECURITY: inline transform added to disclosure after render:', inlineTransform);
        disclosureEl.style.transform = ''; // remove it
        // Re-run full audit
      }
    }
    if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
      // Class changed — check if new class adds a hiding transform
      const violations = auditDisclosureTransform(disclosureEl);
      if (violations.length > 0) {
        console.error('SECURITY: class change triggered transform violation:', violations);
      }
    }
  }
});

observer.observe(disclosureEl, {
  attributes: true,
  attributeFilter: ['style', 'class']
});

4. CSS defensive declarations on disclosure element

Applying a secure baseline transform to the disclosure element itself can prevent overrides in some attack scenarios. CSS specificity means an attacker would need to use higher-specificity selectors or !important to override these declarations:

/* Defensive CSS: lock transform on consent disclosure */
/* Use maximum specificity + !important to resist override */
html body .mcp-consent-dialog .mcp-disclosure {
  transform: none !important;
  transform-style: flat !important;
  /* Also lock adjacent visibility properties */
  opacity: 1 !important;
  visibility: visible !important;
  clip-path: none !important;
  filter: none !important;
}

Note that CSS !important declarations can still be overridden by user agent stylesheets and by inline styles with !important. This is a defence-in-depth measure, not a guarantee — the runtime audit (step 2) remains essential.

SkillAudit findings

High transform:scale(0) detected on consent disclosure element via computed matrix decomposition: matrix(0, 0, 0, 0, 200, 150) — scaleX=0.000, scaleY=0.000. getBoundingClientRect() returns { width: 400, height: 300 } — false negative on all standard checks. Disclosure visually invisible; rendered as a zero-size point at transform-origin (200px, 150px).
High transform:translateX(-9999px) detected: computed matrix matrix(1, 0, 0, 1, -9999, 0). offsetLeft returns non-suspicious original position; getBoundingClientRect().right returns -9599 — element entirely off the left edge of the viewport. Layout box occupies original position; no visible gap in UI. Disclosure text unreachable by user.
High transform:skewX(89deg) detected: computed matrix decomposition yields skewX component of 88.8 degrees — exceeds 60-degree readability threshold by 28.8 degrees. All text in the disclosure element is rendered as near-vertical sheared strokes. getBoundingClientRect() reports width of ~11,460px due to bounding box expansion from extreme shear; element clips at viewport edge. Text content technically present but visually indecipherable.
High transform:translateZ(-600px) with perspective:500px ancestor detected. Disclosure placed beyond vanishing point in 3D projection — element projected behind the viewer and invisible in rendered output. getComputedStyle().zIndex returns 'auto'; offsetHeight returns 240; getBoundingClientRect() returns layout-box coordinates within viewport. 3D stacking attack confirmed via matrix3d Z component (tz=-600) and transform-style:preserve-3d ancestor at dialog level.
Medium transform:scaleX(0.001) detected: computed matrix matrix(0.001, 0, 0, 1, 0, 0) — scaleX=0.001. Disclosure width compressed to 0.4px visual rendered width (element layout width: 400px). Height unaffected. Disclosure appears as a near-invisible vertical line. Standard checks all report normal height and width values.

Related security guides: CSS transform attacks are one of several compositing-layer attack surfaces for hiding MCP server consent disclosures. See also CSS will-change security for how will-change:transform promotes elements to independent compositing layers, creating stacking context side effects that can hide disclosures; CSS individual transforms security for standalone scale, rotate, and translate properties (CSS Transforms Level 2) that provide an alternative syntax for the same attacks; and the SkillAudit blog for the latest research on MCP server CSS attack techniques and detection methodology.

Audit your MCP server with SkillAudit. SkillAudit's transform security analysis performs full CSS matrix decomposition — extracting scaleX, scaleY, translateX, translateY, skewX, rotation, and 3D Z components from every getComputedStyle().transform value on every element in a consent disclosure's ancestor chain and subtree. All four transform attack vectors (scale collapse, off-screen translate, extreme skew/distortion, and 3D stacking) are detected and reported with the specific computed matrix values and decomposed components that triggered each finding. Start a free audit or read the full methodology.