MCP server CSS scale property security: scale:0 collapses consent while transform='none', asymmetric scale:0.01 1 horizontal compression, CSS custom property indirection, and JS scale transition at mousedown

Published 2026-08-07 — SkillAudit Research

CSS Transforms Level 2 introduced individual transform properties: translate, rotate, and scale — separate from the transform shorthand. Setting scale: 0 visually collapses an element to a single point at its transform origin, but this is a different property from transform: scale(0). The key security implication: getComputedStyle(el).transform returns 'none' when no transform property is set, even if scale: 0 is applied. Security scanners that check getComputedStyle(el).transform for scale(0) or matrix(0,...) patterns will not detect the individual scale property attack. The correct check is getComputedStyle(el).scale.

This attack class is distinct from transform-security (which covers transform: scale(0), transform: matrix(0,...), and transform: scaleX(0) values) and from rotate-property-security (the individual rotate property). The scale property was supported in Chrome 104+ (August 2022), Firefox 72+, and Safari 14.1+ — it is available in all current MCP client environments.

Detection gap: getComputedStyle(el).transform returns 'none' when only the individual scale property is set (no transform property defined). The correct inspection is getComputedStyle(el).scale. Additionally, getBoundingClientRect().width and .height reflect the visual (scaled) dimensions — they return 0 for scale:0 even though offsetWidth and offsetHeight return the layout (pre-transform) dimensions.

Attack 1: scale:0 collapses consent to invisible point — offsetWidth non-zero, transform='none' (SA-CSS-SCAL-001)

scale: 0 applied to the consent disclosure element visually collapses it to a single pixel at its transform origin (default: center). The element still occupies its layout space — offsetWidth and offsetHeight return the pre-transform dimensions. The consent text is in the DOM and accessible tree. But the user sees nothing — the visual box is a 0×0 point. Checking getComputedStyle(el).transform returns 'none' because no transform property is set. Only getComputedStyle(el).scale returns '0'. getBoundingClientRect() returns {width: 0, height: 0} — the visual dimensions after transform application.

/* MCP attack: */
.consent-disclosure {
  scale: 0;
  /* Visual: collapsed to single point
     offsetWidth:  320px (layout space preserved)
     offsetHeight: 48px  (layout space preserved)
     getBoundingClientRect().width:  0  ← visual collapse
     getBoundingClientRect().height: 0  ← visual collapse
     getComputedStyle(el).transform: 'none'  ← transform-scanner misses
     getComputedStyle(el).scale: '0'         ← only this reveals it */
}

// Detection:
function detectScalePropertyCollapse(el) {
  const cs = window.getComputedStyle(el);
  const scaleValue = cs.scale;
  if (scaleValue && scaleValue !== 'none') {
    const parts = scaleValue.trim().split(/\s+/).map(Number);
    const sx = parts[0] ?? 1;
    const sy = parts[1] ?? sx;
    if (Math.abs(sx) < 0.05 || Math.abs(sy) < 0.05) {
      console.error('SA-CSS-SCAL-001: CSS scale property collapses consent element', {
        el,
        scale: scaleValue,
        computedTransform: cs.transform,    // will show 'none'
        offsetWidth: el.offsetWidth,
        bcrWidth: el.getBoundingClientRect().width
      });
    }
  }
  // Also verify via getBoundingClientRect vs offsetWidth discrepancy
  const rect = el.getBoundingClientRect();
  if (rect.width < el.offsetWidth * 0.1 && el.textContent.trim().length > 0) {
    console.error('SA-CSS-SCAL-001: visual width << layout width — transform/scale collapse', { el });
  }
}

Attack 2: scale:0.01 1 — horizontal 1% compression, text compressed to 5px hairline (SA-CSS-SCAL-002)

Asymmetric scale: scale: 0.01 1 compresses only the X axis to 1% while leaving the Y axis at 100%. A 500px-wide consent element compressed to 1% is 5px visible width — all consent text characters are stacked into a 5px-wide hairline. The element height is unchanged; the layout width is unchanged. It does not look like a collapsed element — it looks like a thin vertical stripe, potentially dismissed as a decorative divider or icon. offsetWidth remains 500px; getBoundingClientRect().width returns 5px. The distinction catches tools that check only .width < 1 (catching scale:0) but miss .width < 10 (needed to catch hairline compression).

/* MCP attack: */
.consent-disclosure {
  scale: 0.01 1;
  /* X compressed to 1% — looks like a thin vertical bar
     Y unchanged — element has normal height
     offsetWidth:  500px
     getBoundingClientRect().width:  5px
     All text characters compressed into 5px span — illegible */
}

/* With CSS custom property obfuscation: */
:root {
  --mcp-dialog-ratio-x: 0.01;    /* looks like a theme token */
  --mcp-dialog-ratio-y: 1;
}
.consent-disclosure {
  scale: var(--mcp-dialog-ratio-x) var(--mcp-dialog-ratio-y);
  /* getComputedStyle().scale resolves to '0.01 1 1' */
}

// Detection:
function detectAsymmetricScaleCompression(el) {
  const cs = window.getComputedStyle(el);
  const scaleValue = cs.scale;
  if (!scaleValue || scaleValue === 'none') return;
  const parts = scaleValue.trim().split(/\s+/).map(Number);
  const sx = parts[0] ?? 1;
  const sy = parts[1] ?? sx;
  if (sx < 0.1) {
    console.error('SA-CSS-SCAL-002: X-axis scale < 10% compresses consent text to hairline', {
      el, scale: scaleValue, sx, sy
    });
  }
  // Also check getBoundingClientRect for visual width collapse
  const rect = el.getBoundingClientRect();
  if (rect.width < 10 && el.textContent.trim().length > 20) {
    console.error('SA-CSS-SCAL-002: visual width < 10px despite long text content — asymmetric scale?', { el, width: rect.width });
  }
}

Attack 3: CSS custom property indirection — scale:var(--mcp-scale) with :root --mcp-scale:0 (SA-CSS-SCAL-003)

The consent element's scale property is set to var(--mcp-scale). The custom property --mcp-scale: 0 is defined on :root and looks like a legitimate design token (e.g., a "hidden state" flag for an off-canvas element). A stylesheet scanner reading the consent element's rule sees only scale: var(--mcp-scale) — not an immediately suspicious value. The :root token definition may be in a separate CSS file or dynamically injected. getComputedStyle(el).scale resolves the var() chain and returns '0' — the computed value exposes the collapse regardless of indirection depth.

/* MCP attack: */
:root {
  --mcp-scale: 0;    /* scale token — looks like off-canvas initial state */
  --mcp-scale-y: 1;
}

/* In a separate file: mcp-theme.css */
.consent-disclosure {
  scale: var(--mcp-scale) var(--mcp-scale-y);
  /* Stylsheet reads: scale: var(--mcp-scale) var(--mcp-scale-y)
     No literal 0 on this element's rule
     getComputedStyle().scale: '0 1 1' — exposes the collapse */
}

/* With default fallback obfuscation: */
.consent-disclosure {
  scale: var(--mcp-reveal-scale, 0);
  /* If --mcp-reveal-scale is not set (default), scale = 0
     Author can "enable" consent by setting --mcp-reveal-scale:1
     But the MCP server never sets it */
}

// Detection: computed value resolves var() chains
function detectVarIndirectedScale(el) {
  const scaleValue = window.getComputedStyle(el).scale;
  if (!scaleValue || scaleValue === 'none') return;
  const parts = scaleValue.trim().split(/\s+/).map(Number);
  const sx = parts[0] ?? 1;
  const sy = parts[1] ?? sx;
  const sz = parts[2] ?? 1;
  if (Math.abs(sx) < 0.05 || Math.abs(sy) < 0.05) {
    // Also check if element's declared style shows var() (indirection)
    const declaredScale = el.style.scale;
    const isIndirected = /var\s*\(/.test(declaredScale);
    console.error('SA-CSS-SCAL-003: CSS custom property scale collapse', {
      el,
      computedScale: scaleValue,
      declaredScale,
      isIndirected
    });
  }
}

Attack 4: JS mousedown sets scale:'0' — smooth CSS transition collapses consent at install click (SA-CSS-SCAL-004)

The baseline CSS has scale: 1 (normal size) and transition: scale 0.2s ease-out defined on the consent element. At mousedown on the install button, JS sets consentEl.style.scale = '0'. The CSS transition triggers a smooth 200ms collapse from scale 1 to scale 0. The collapse completes by the time the click event fires. Users see the consent element smoothly collapse as they click install — they interpret this as a normal UI animation (accordion close, step completion) rather than a consent removal. Load-time audit sees scale: 1. MutationObserver on the style attribute detects the inline scale change.

/* Baseline CSS: consent visible */
.consent-disclosure {
  scale: 1;
  transition: scale 0.2s ease-out;    /* smooth collapse animation */
}

// MCP JS — triggers collapse on install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  const consent = document.querySelector('.consent-disclosure');
  if (consent) {
    consent.style.scale = '0';
    /* 200ms smooth collapse — looks like accordion close or step-complete animation
       By the time the user releases the mouse button (click), scale is 0
       Consent text invisible during and after the install interaction */
  }
}, { capture: true });

// Detection:
function detectDynamicScaleCollapse() {
  document.querySelectorAll('.consent-disclosure, [data-consent], #consent-panel').forEach(el => {
    // Check baseline CSS for transition on scale property
    const cs = window.getComputedStyle(el);
    if (cs.transitionProperty.includes('scale') || cs.transitionProperty === 'all') {
      console.warn('SA-CSS-SCAL-004: consent element has CSS transition on scale property — check for JS-triggered collapse');
    }
    // MutationObserver for style attribute changes
    const observer = new MutationObserver(() => {
      if (el.style.scale !== undefined && el.style.scale !== '' && el.style.scale !== 'none') {
        const sv = parseFloat(el.style.scale);
        if (!isNaN(sv) && sv < 0.05) {
          console.error('SA-CSS-SCAL-004: JS set scale:0 on consent element at interaction time', {
            el, scaleValue: el.style.scale
          });
        }
      }
    });
    observer.observe(el, { attributes: true, attributeFilter: ['style'] });
    // Simulate mousedown to trigger JS
    el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
    requestAnimationFrame(() => {
      const postScale = getComputedStyle(el).scale;
      if (postScale && postScale !== 'none' && parseFloat(postScale) < 0.05) {
        console.error('SA-CSS-SCAL-004: scale collapsed after mousedown simulation', { el, scale: postScale });
      }
    });
  });
}

Root detection method for all scale property attacks: Always check getComputedStyle(el).scale specifically — not getComputedStyle(el).transform. The transform property and the scale property are independent CSS properties; the individual scale value does not appear in the transform computed value. Parse the result as a triplet (sx sy sz); flag if sx < 0.1 or sy < 0.1. Additionally, compare getBoundingClientRect().width to el.offsetWidth — a ratio below 0.1 (visual width less than 10% of layout width) indicates scale or transform collapse regardless of which property caused it. SkillAudit checks both scale and the getBoundingClientRect ratio on every identified consent element.

Attack summary

IDCSS / JS techniquegetComputedStyle().transformgetComputedStyle().scaleSeverity
SA-CSS-SCAL-001scale: 0 — full collapse'none''0'High
SA-CSS-SCAL-002scale: 0.01 1 — X-axis 1% compression'none''0.01 1 1'High
SA-CSS-SCAL-003scale: var(--mcp-scale) with --mcp-scale:0'none''0 1 1'High
SA-CSS-SCAL-004JS el.style.scale='0' at mousedown + CSS transition'none''0' (after mousedown)High

Consolidated finding blocks

High CSS scale:0 collapses consent to invisible point — offsetWidth non-zero, getComputedStyle().transform='none': MCP server sets scale: 0 on the consent element. The layout box is preserved (offsetWidth unchanged); only the visual rendering collapses. getComputedStyle(el).transform returns 'none' because no transform property is set — transform-scanning tools miss this. Only getComputedStyle(el).scale === '0' and getBoundingClientRect().width === 0 reveal the attack.
High CSS scale:0.01 1 horizontal 1% compression — consent text compressed to 5px hairline: Asymmetric scale compresses the X axis to 1% while the Y axis is unchanged. A 500px element is visually 5px wide — all characters stacked into an illegible hairline stripe. The element looks like a decorative separator, not a collapsed text block. Detection: getBoundingClientRect().width < 10 on an element with more than 20 characters of textContent.
High CSS custom property scale:var(--mcp-scale) with :root --mcp-scale:0 — stylesheet shows only var() reference: The consent element's scale rule contains only a var() reference. The actual zero value is in the root custom property definition, potentially in a separate file. getComputedStyle(el).scale resolves the entire var() chain and returns '0 1 1' — the computed value always exposes the attack regardless of indirection depth.
High JS scale:'0' with CSS transition at mousedown — smooth 200ms consent collapse during install click: Baseline consent is scale:1 (visible). At mousedown, JS sets el.style.scale = '0', triggering a CSS transition: scale 0.2s ease-out. By the time the click event fires, the consent has smoothly animated to invisible. The collapse looks like a UI animation (accordion, step completion). MutationObserver on the style attribute with scale value parsing detects the transition trigger.

CSS transform:scale() attacks  |  CSS rotate property security  |  Security Checklist