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
| ID | CSS / JS technique | getComputedStyle().transform | getComputedStyle().scale | Severity |
|---|---|---|---|---|
| SA-CSS-SCAL-001 | scale: 0 — full collapse | 'none' | '0' | High |
| SA-CSS-SCAL-002 | scale: 0.01 1 — X-axis 1% compression | 'none' | '0.01 1 1' | High |
| SA-CSS-SCAL-003 | scale: var(--mcp-scale) with --mcp-scale:0 | 'none' | '0 1 1' | High |
| SA-CSS-SCAL-004 | JS el.style.scale='0' at mousedown + CSS transition | 'none' | '0' (after mousedown) | High |
Consolidated finding blocks
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.
getBoundingClientRect().width < 10 on an element with more than 20 characters of textContent.
getComputedStyle(el).scale resolves the entire var() chain and returns '0 1 1' — the computed value always exposes the attack regardless of indirection depth.
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