Security research · MCP consent attacks · CSS Transforms Level 2 · Individual transform properties

CSS Individual Transform Properties as MCP Consent Bypass: When translate, rotate, and scale Evade transform-Based Scanners

CSS Transforms Level 2 shipped three new CSS properties — translate, rotate, and scale — that are entirely independent of the transform shorthand. When any of these properties collapses or displaces a consent element, getComputedStyle(el).transform returns 'none'. Every security scanner, auditing tool, and headless browser check that inspects the transform computed value misses this entire attack class. Three properties. One blind spot.

SkillAudit Research August 7, 2026 13 min read

Contents

  1. CSS Transforms Level 2 background
  2. The core detection gap
  3. Attack class: individual translate
  4. Attack class: individual rotate
  5. Attack class: individual scale
  6. Combined individual transforms and compositing
  7. Detection comparison table
  8. Unified ConsentTransformAudit detector
  9. Safe consent pattern

CSS Transforms Level 2 background

The CSS transform shorthand has been a consent-hiding vector since MCP servers first appeared — applying transform: scale(0) or transform: translate(-9999px, 0) to a consent element collapses or displaces it while leaving the DOM text intact and accessibility-tree readable. This class is documented on the CSS transform-security page and is widely known to auditors.

CSS Transforms Level 2 changed the calculus. The specification introduced three individual transform properties: translate, rotate, and scale. These are not shorthand aliases. They are independent CSS properties with their own entries in the CSS Object Model, their own computed-style slots, and their own cascade resolution. Setting translate: 100vw 0 on an element does not affect the element's transform property at all. The two are composed independently during rendering.

Browser support arrived in waves: translate, rotate, and scale as individual properties landed in Chrome 104 (August 2022), Firefox 72 (January 2020), and Safari 14.1 (April 2021). As of 2026, all current MCP client environments — Claude Code, Cursor, Windsurf, VS Code with Copilot — ship Chromium 120+ and are fully exposed to individual-transform-property attacks.

Browser support as attack surface: Chrome 104+ (August 2022), Firefox 72+, Safari 14.1+. All current MCP client environments use Chromium 120+. Individual transform properties are not experimental — they are stable, widely deployed, and fully available to any MCP server's CSS.

The core detection gap

The canonical audit check for transform-based consent hiding reads getComputedStyle(el).transform and inspects the resulting matrix or keyword value. When a transform property is applied with a scale(0) or translate(-9999px) function, this check correctly catches the attack. When only the individual translate, rotate, or scale property is set — with no transform property on the same element — the check returns 'none'.

/* MCP attack — individual property variant: */
.consent-disclosure {
  translate: 100vw 0;    /* individual CSS property */
  /* NO transform property set */
}

// Audit check — misses the attack:
const cs = window.getComputedStyle(consentEl);
console.log(cs.transform);    // 'none' ← attack invisible to this check
console.log(cs.translate);    // '100vw' — reveals the attack
console.log(cs.rotate);       // 'none' — not set
console.log(cs.scale);        // 'none' — not set

The gap exists because the rendering pipeline composes these properties independently. The order of application is: translate first, then rotate, then scale, then transform (in this specific order per spec). Each is applied without affecting the others' computed values. An auditor checking only transform is inspecting one of four independent compositing stages.

Attack class: individual translate

1

translate: 100vw 0 — consent pushed off-screen right, transform shows 'none'

CSS individual translate property displaces consent element one full viewport width to the right. getComputedStyle().transform = 'none'. getBoundingClientRect().left > window.innerWidth confirms off-screen position.

translate: 100vw 0 applied to the consent element pushes it exactly one viewport width to the right. On a 1280px viewport, the consent element's rendered position starts at x=1280 — just outside the visible area's right edge. The element is in the DOM, in the layout flow, accessible to screen readers, and has a non-zero offsetWidth. No transform property is set. getComputedStyle(el).transform returns 'none'. The attack is invisible to transform-based scanners.

Unlike transform: translateX(100vw) — which sets the transform shorthand and does appear in the computed style — the individual translate property produces a separate computed-style entry. The detection requires checking getComputedStyle(el).translate and comparing getBoundingClientRect().left against window.innerWidth. See the dedicated translate-property-security page for all four attack variants including vertical push, CSS custom property indirection, and JS mousedown displacement.

Detection approach

function detectTranslateProperty(el) {
  const cs = window.getComputedStyle(el);
  const tv = cs.translate;
  if (tv && tv !== 'none') {
    // Parse translate value: "Xpx Ypx" or "Xvw Yvh" (resolved to px)
    const parts = tv.trim().split(/\s+/);
    const tx = parseFloat(parts[0] ?? '0');
    const ty = parseFloat(parts[1] ?? '0');
    if (Math.abs(tx) > window.innerWidth * 0.8 || Math.abs(ty) > window.innerHeight * 0.8) {
      console.error('Individual translate off-screen displacement', { el, translate: tv, tx, ty });
    }
  }
  // Geometric check catches vw-unit values after resolution
  const rect = el.getBoundingClientRect();
  if (rect.right < 0 || rect.left > window.innerWidth ||
      rect.bottom < 0 || rect.top > window.innerHeight) {
    if (el.textContent.trim().length > 0) {
      console.error('Consent element is off-screen', { el, rect });
    }
  }
}

Attack class: individual rotate

2

rotate: 90deg + overflow:hidden parent — rotated consent clipped to hairline

CSS individual rotate property rotates the consent element 90 degrees within an overflow:hidden parent, clipping all text to a narrow hairline. rotate: 180deg flips text upside down — legible in a mirror, not to a normal reader.

rotate: 90deg applied to the consent element within a narrow overflow: hidden parent produces a striking visual effect: a horizontal block of text becomes a vertical stripe. If the parent is narrower than the text height (which it now occupies as width), the overflow clips it further. The user sees a thin vertical line where consent should be. The element has non-zero offsetWidth and offsetHeight. getComputedStyle(el).transform is 'none'. Only getComputedStyle(el).rotate returns '90deg'.

A second variant, rotate: 180deg, flips the consent text completely upside down. The text is technically readable — every glyph is present, mirrored vertically — but a normal reader sees it as garbled. The MCP server's defense in a dispute: "the consent was visibly displayed." getBoundingClientRect() still returns the element's pre-rotation bounding box positioned within the viewport, so off-screen checks pass. The detection must inspect the rotate computed property directly.

This attack is distinct from transform: rotate(90deg) which sets the transform shorthand and appears in the transform computed style. The individual rotate property is its own CSS entry. The dedicated rotate-property-security page covers all four variants in detail.

Detection approach

function detectRotateProperty(el) {
  const cs = window.getComputedStyle(el);
  const rv = cs.rotate;
  if (rv && rv !== 'none') {
    // Parse: could be "90deg", "0.25turn", "1.5708rad", or "x y z angle"
    const angleMatch = rv.match(/[-\d.]+\s*(deg|rad|turn|grad)/i);
    if (angleMatch) {
      let degrees = parseFloat(angleMatch[0]);
      const unit = angleMatch[1].toLowerCase();
      if (unit === 'rad') degrees = degrees * 180 / Math.PI;
      if (unit === 'turn') degrees = degrees * 360;
      if (unit === 'grad') degrees = degrees * 0.9;
      const normalizedDeg = ((degrees % 360) + 360) % 360;
      // Flag: 45-315 degrees = significant rotation (not decorative micro-rotation)
      if (normalizedDeg > 15 && normalizedDeg < 345) {
        console.error('Significant individual rotate on consent element', {
          el, rotate: rv, normalizedDeg
        });
      }
    }
  }
}

Attack class: individual scale

3

scale: 0 — consent collapsed to invisible point, offsetWidth unchanged, transform='none'

CSS individual scale property collapses consent to a single point at the transform origin. offsetWidth preserves layout space. getBoundingClientRect().width/height = 0. getComputedStyle().transform = 'none'.

scale: 0 collapses the consent element's visual rendering to a single pixel point at its transform origin (center by default). The layout box is fully preserved — the element still occupies its space, pushing sibling elements as normal. offsetWidth and offsetHeight return the pre-scale dimensions. Only getBoundingClientRect() returns zero dimensions (reflecting post-transform visual geometry). getComputedStyle(el).transform returns 'none'. The attack is undetectable by any tool that checks only the transform property.

The asymmetric variant scale: 0.01 1 compresses the X axis to 1% of its normal width. A 500px-wide consent element becomes visually 5px wide — an illegible hairline that resembles a decorative divider. The Y axis is unchanged; the element has normal height. This evades tools that check only for complete collapse (getBoundingClientRect().width === 0) because the width is 5px, not zero. A threshold of getBoundingClientRect().width < 10 catches both.

All four attack variants — full scale:0 collapse, asymmetric X-axis compression, CSS custom property indirection, and JS mousedown scale transition — are documented on the scale-property-security page.

Detection approach

function detectScaleProperty(el) {
  const cs = window.getComputedStyle(el);
  const sv = cs.scale;
  if (sv && sv !== 'none') {
    const parts = sv.trim().split(/\s+/).map(Number);
    const sx = parts[0] ?? 1;
    const sy = parts[1] ?? sx;
    if (Math.abs(sx) < 0.1 || Math.abs(sy) < 0.1) {
      console.error('Individual scale collapse on consent element', {
        el, scale: sv, sx, sy, computedTransform: cs.transform  // will show 'none'
      });
    }
  }
  // Geometric catch: bcr dimensions vs offset dimensions
  const rect = el.getBoundingClientRect();
  if (rect.width < el.offsetWidth * 0.1 || rect.height < el.offsetHeight * 0.1) {
    if (el.textContent.trim().length > 0) {
      console.error('Visual dimensions << layout dimensions — scale or transform collapse', { el });
    }
  }
}

Combined individual transforms and compositing

The spec-defined compositing order for individual transform properties is: translate → rotate → scale → transform. Each stage is applied after the previous one. This creates two security-relevant scenarios that are harder to detect than any single property in isolation.

Scenario A: Partially canceling combinations. An MCP server sets translate: 50px 50px (plausible as a UI positioning offset), rotate: 3deg (plausible as a card tilt effect), and scale: 0.02 (very small but not zero). No single property looks immediately suspicious. The combined visual result: the consent element is offset by 50px from its layout position, tilted 3 degrees, and compressed to 2% of its normal size — effectively invisible. An auditor checking each property individually may not flag the combination.

Scenario B: Detached compositing — transform property used for legitimate layout, individual properties for attack. A well-structured MCP dialog uses transform: perspective(500px) on the consent container for a 3D card effect. The MCP attack is on the consent element itself via scale: 0. The transform property on the parent is legitimate and unremarkable; the scale property on the child is the attack. An auditor scanning for "suspicious transform values" on the consent element will not see the transform: perspective(500px) on the parent as a threat, and will miss the scale: 0 if not checking all four individual properties.

/* Scenario A: plausible individual values, invisible combined result */
.mcp-consent-card {
  translate: 50px 50px;     /* "UI positioning offset" */
  rotate: 3deg;             /* "card tilt — UI style" */
  scale: 0.02;              /* "subtle" but 2% = invisible */
  /* Combined result: offset, slightly tilted, effectively invisible */
}

/* Scenario B: transform on parent, scale attack on child */
.mcp-dialog-container {
  transform: perspective(500px);  /* legitimate 3D card effect */
}
.mcp-dialog-container .consent-disclosure {
  scale: 0;    /* attack on child — transform check on child returns 'none' */
  /* parent's transform: perspective() does not affect child's computed transform */
}

Compositing order matters for detection: The individual transform properties compose in the order translate → rotate → scale, then the shorthand transform is applied. Auditing all four independently catches individual attacks. Catching combinations that compose to invisible requires geometric verification: if getBoundingClientRect().width or .height is zero (or <10px) on an element with non-empty text content, the combined transform effect is the attack — regardless of which individual property caused it.

Detection comparison table

The table below shows what each standard audit check returns for each individual-transform attack variant. All attacks evade every check that reads only getComputedStyle().transform.

Attack getComputedStyle().transform getComputedStyle().translate getComputedStyle().rotate getComputedStyle().scale getBCR dimensions
translate: 100vw 0 'none' ✗ '1280px' ✓ n/a n/a left > innerWidth ✓
translate: 0 100vh 'none' ✗ '0px 812px' ✓ n/a n/a top > innerHeight ✓
rotate: 90deg 'none' ✗ n/a '90deg' ✓ n/a may be in viewport
rotate: 180deg 'none' ✗ n/a '180deg' ✓ n/a in viewport (upside-down)
scale: 0 'none' ✗ n/a n/a '0' ✓ width=0, height=0 ✓
scale: 0.01 1 'none' ✗ n/a n/a '0.01 1 1' ✓ width<10px ✓
Combined translate+scale 'none' ✗ reveals ✓ n/a reveals ✓ off-screen ✓

Unified ConsentTransformAudit detector

A complete audit of transform-based consent hiding must check all four properties: transform, translate, rotate, and scale. It must also perform geometric verification via getBoundingClientRect() to catch combined effects. The following class covers all cases, including dynamic attacks via MutationObserver and mousedown simulation.

class ConsentTransformAudit {
  static TRANSLATE_THRESHOLD_FRACTION = 0.8;   // >80% of viewport = off-screen
  static SCALE_THRESHOLD = 0.1;                // <10% = invisible
  static ROTATION_THRESHOLD_DEG = 15;          // >15deg = significant rotation
  static BCR_RATIO_THRESHOLD = 0.1;            // bcr/offset < 10% = collapsed

  static checkAllTransformProperties(el) {
    const cs = window.getComputedStyle(el);
    const findings = [];

    // 1. transform shorthand (existing check)
    if (cs.transform !== 'none') {
      const m = new DOMMatrix(cs.transform);
      if (Math.abs(m.a) < 0.05 || Math.abs(m.d) < 0.05) {
        findings.push({ id: 'CSS-TRF-SHORTHAND', prop: 'transform', value: cs.transform });
      }
    }

    // 2. Individual translate property (NEW)
    if (cs.translate && cs.translate !== 'none') {
      const parts = cs.translate.trim().split(/\s+/);
      const tx = parseFloat(parts[0] ?? '0');
      const ty = parseFloat(parts[1] ?? '0');
      if (Math.abs(tx) > window.innerWidth * this.TRANSLATE_THRESHOLD_FRACTION ||
          Math.abs(ty) > window.innerHeight * this.TRANSLATE_THRESHOLD_FRACTION) {
        findings.push({ id: 'CSS-TRNP-001', prop: 'translate', value: cs.translate, tx, ty });
      }
    }

    // 3. Individual rotate property (NEW)
    if (cs.rotate && cs.rotate !== 'none') {
      const angleMatch = cs.rotate.match(/[-\d.]+\s*(deg|rad|turn|grad)/i);
      if (angleMatch) {
        let deg = parseFloat(angleMatch[0]);
        const unit = angleMatch[1].toLowerCase();
        if (unit === 'rad') deg = deg * 180 / Math.PI;
        else if (unit === 'turn') deg = deg * 360;
        else if (unit === 'grad') deg = deg * 0.9;
        const normalized = ((deg % 360) + 360) % 360;
        if (normalized > this.ROTATION_THRESHOLD_DEG && normalized < (360 - this.ROTATION_THRESHOLD_DEG)) {
          findings.push({ id: 'CSS-ROTP-001', prop: 'rotate', value: cs.rotate, normalizedDeg: normalized });
        }
      }
    }

    // 4. Individual scale property (NEW)
    if (cs.scale && cs.scale !== 'none') {
      const parts = cs.scale.trim().split(/\s+/).map(Number);
      const sx = isNaN(parts[0]) ? 1 : parts[0];
      const sy = isNaN(parts[1]) ? sx : parts[1];
      if (Math.abs(sx) < this.SCALE_THRESHOLD || Math.abs(sy) < this.SCALE_THRESHOLD) {
        findings.push({ id: 'CSS-SCAL-001', prop: 'scale', value: cs.scale, sx, sy });
      }
    }

    // 5. Geometric fallback — catches combinations (NEW)
    const rect = el.getBoundingClientRect();
    const hasText = el.textContent.trim().length > 10;
    if (hasText) {
      if (rect.width < el.offsetWidth * this.BCR_RATIO_THRESHOLD && el.offsetWidth > 20) {
        findings.push({ id: 'CSS-GEOM-001', type: 'geometric-width-collapse',
          bcrWidth: rect.width, offsetWidth: el.offsetWidth });
      }
      if (rect.height < el.offsetHeight * this.BCR_RATIO_THRESHOLD && el.offsetHeight > 10) {
        findings.push({ id: 'CSS-GEOM-002', type: 'geometric-height-collapse',
          bcrHeight: rect.height, offsetHeight: el.offsetHeight });
      }
      if (rect.right < 0 || rect.left > window.innerWidth) {
        findings.push({ id: 'CSS-GEOM-003', type: 'off-screen-horizontal', rect });
      }
      if (rect.bottom < 0 || rect.top > window.innerHeight) {
        findings.push({ id: 'CSS-GEOM-004', type: 'off-screen-vertical', rect });
      }
    }

    return findings;
  }

  static auditConsentElements(selector = '[data-consent], .consent-disclosure, #mcp-consent') {
    const findings = [];
    document.querySelectorAll(selector).forEach(el => {
      const elFindings = this.checkAllTransformProperties(el);
      if (elFindings.length > 0) {
        findings.push({ el, findings: elFindings });
        console.error('ConsentTransformAudit: transform-based consent hiding detected', { el, elFindings });
      }
    });
    return findings;
  }

  static observeForDynamicAttacks(el) {
    const observer = new MutationObserver(() => {
      const findings = this.checkAllTransformProperties(el);
      if (findings.length > 0) {
        console.error('ConsentTransformAudit: dynamic transform change detected', { el, findings });
      }
    });
    observer.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });
    // Simulate mousedown to trigger JS-based attacks
    const installBtn = document.querySelector('[data-action="install"], #install-btn, .install-button');
    if (installBtn) {
      installBtn.addEventListener('mousedown', () => {
        requestAnimationFrame(() => {
          const findings = this.checkAllTransformProperties(el);
          if (findings.length > 0) {
            console.error('ConsentTransformAudit: transform collapse detected post-mousedown', { el, findings });
          }
        });
      }, { capture: true });
    }
    return observer;
  }
}

Safe consent pattern

A safe MCP consent element must not use translate, rotate, or scale properties in ways that displace or collapse the visible text. The safe pattern avoids all four compositing stages for consent elements:

/* Safe consent element — no transform properties at any stage */
.mcp-consent-safe {
  /* No transform, translate, rotate, or scale properties */
  display: block;
  width: 100%;
  padding: 16px;
  font-size: 14px;
  line-height: 1.6;
  color: #111;          /* explicit color — no inherit from theme */
  background: #fff;     /* explicit background */
  border: 1px solid #ddd;
  border-radius: 8px;
  /* overflow: visible (default) — not hidden */
}

/* Gating install on consent acknowledgment: */
.mcp-install-gate {
  opacity: 0.4;
  pointer-events: none;
}
.mcp-install-gate.consent-acknowledged {
  opacity: 1;
  pointer-events: auto;
}

SkillAudit's scanner checks all four individual transform properties — transform, translate, rotate, and scale — on every identified consent element, plus geometric BCR verification and mousedown-triggered dynamic audit. An MCP server cannot use any individual transform property to bypass audit without detection. See the security checklist for the full list of consent-visibility checks.

What SkillAudit checks: All four compositing stages (transform, translate, rotate, scale), geometric BCR verification, parent overflow context, and dynamic interaction-time checks via mousedown simulation. Individual transform properties are a first-class audit category — not a secondary check.

Related pages

CSS translate property security  |  CSS rotate property security  |  CSS scale property security  |  CSS transform shorthand security  |  Full security checklist  |  CSS timing attack synthesis