MCP server CSS translate property security: translate:100vw pushes consent off-screen right while transform='none', translate:0 100vh vertical displacement, CSS custom property indirection, and JS mousedown off-screen push
Published 2026-08-07 — SkillAudit Research
CSS Transforms Level 2 introduced translate as an independent CSS property, separate from the transform shorthand. Setting translate: 100vw 0 displaces an element one full viewport width to the right — pushing it off-screen — without setting the transform property at all. The security implication: getComputedStyle(el).transform returns 'none' when only the individual translate property is set. Any security scanner that checks getComputedStyle(el).transform for displacement patterns will not detect the individual translate property attack. The correct check is getComputedStyle(el).translate plus geometric verification via getBoundingClientRect().
This attack is distinct from transform-security (which covers transform: translateX(), transform: translate(), and transform: matrix() displacement). The translate property was supported in Chrome 104+ (August 2022), Firefox 72+, and Safari 14.1+ — available in all current MCP client environments. See the individual transform properties synthesis post for the full attack class context.
Detection gap: getComputedStyle(el).transform returns 'none' when only the individual translate property is set. The correct check is getComputedStyle(el).translate. Additionally, getBoundingClientRect().left > window.innerWidth (horizontal push) or getBoundingClientRect().top > window.innerHeight (vertical push) confirms off-screen displacement. Note that offsetLeft and offsetTop do NOT reflect translated position — only getBoundingClientRect() returns post-transform coordinates.
Attack 1: translate:100vw 0 — consent pushed one viewport width right, layout preserved, transform='none' (SA-CSS-TRNP-001)
translate: 100vw 0 applied to the consent disclosure element displaces it exactly one viewport width to the right. On a 1280×720 viewport, the element's rendered left edge is at x=1280 — just beyond the visible area's right edge. The element remains in the DOM's layout flow: it still occupies its original layout position, affecting sibling element placement. offsetLeft returns the element's layout position (ignoring the translation). Only getBoundingClientRect().left returns the post-transform x=1280 position. getComputedStyle(el).transform is 'none'. getComputedStyle(el).translate is '100vw' (or the resolved pixel value '1280px' depending on browser).
/* MCP attack: */
.consent-disclosure {
translate: 100vw 0;
/* Visual: one viewport width right of its layout position — off-screen
offsetLeft: 120px (layout position — unchanged)
getBoundingClientRect().left: 1400px (≈ 120 + 1280) ← off-screen
getComputedStyle().transform: 'none' ← transform-scanner misses
getComputedStyle().translate: '100vw' ← only this reveals it */
}
// Detection:
function detectTranslateOffScreen(el) {
const cs = window.getComputedStyle(el);
const tv = cs.translate;
if (tv && tv !== 'none') {
// Parse resolved pixel values
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) {
console.error('SA-CSS-TRNP-001: individual translate pushes consent >80% viewport width', {
el, translate: tv, tx, computedTransform: cs.transform // 'none'
});
}
}
// Geometric confirmation
const rect = el.getBoundingClientRect();
if ((rect.right < 0 || rect.left > window.innerWidth) && el.textContent.trim().length > 0) {
console.error('SA-CSS-TRNP-001: consent element is off-screen (horizontal)', { el, rect });
}
}
Attack 2: translate:0 100vh — vertical off-screen push below viewport (SA-CSS-TRNP-002)
translate: 0 100vh pushes the consent element one full viewport height downward — placing it directly below the visible area. On an 812px-tall mobile viewport, the element appears at y=812 (just below the fold). Scrolling does not necessarily reveal it because the element may be placed within a fixed-height dialog container with overflow: hidden. The layout remains intact: other elements in the flow are undisturbed. getComputedStyle(el).transform is 'none'. getBoundingClientRect().top returns the post-translation y position, confirming off-screen displacement.
/* MCP attack: */
.consent-disclosure {
translate: 0 100vh; /* push one full viewport height down */
/* If inside overflow:hidden container: clipped and invisible */
/* If inside scrollable container: requires deliberate scroll to reach */
}
/* Combined variant: off-screen in both axes */
.consent-disclosure {
translate: 100vw 100vh; /* both axes: off to the right AND below */
/* getBoundingClientRect(): left > innerWidth AND top > innerHeight */
}
// Detection:
function detectVerticalTranslateOffScreen(el) {
const cs = window.getComputedStyle(el);
const tv = cs.translate;
if (tv && tv !== 'none') {
const parts = tv.trim().split(/\s+/);
const ty = parseFloat(parts[1] ?? '0');
if (Math.abs(ty) > window.innerHeight * 0.8) {
console.error('SA-CSS-TRNP-002: individual translate pushes consent >80% viewport height', {
el, translate: tv, ty
});
}
}
const rect = el.getBoundingClientRect();
if ((rect.bottom < 0 || rect.top > window.innerHeight) && el.textContent.trim().length > 0) {
console.error('SA-CSS-TRNP-002: consent element is off-screen (vertical)', { el, rect });
}
}
Attack 3: CSS custom property indirection — translate:var(--mcp-offset) with :root --mcp-offset:100vw (SA-CSS-TRNP-003)
The consent element's translate property is set to var(--mcp-offset-x). The custom property --mcp-offset-x: 100vw is defined on :root and may appear to be a legitimate layout token (e.g., a slide-in animation starting position, or a CSS variable controlling off-canvas panel state). A stylesheet scanner reading the consent element's rule sees only translate: var(--mcp-offset-x) — not an immediately suspicious value without resolving the variable. The :root token may be in a separate CSS file or dynamically injected. getComputedStyle(el).translate resolves the var() chain and returns the actual pixel value, revealing the attack regardless of indirection depth.
/* MCP attack: */
:root {
--mcp-panel-start-x: 100vw; /* "off-canvas start position" token */
--mcp-panel-start-y: 0;
}
/* In separate theme.css: */
.consent-disclosure {
translate: var(--mcp-panel-start-x) var(--mcp-panel-start-y);
/* Stylesheet scanner sees: translate: var(--mcp-panel-start-x) var(--mcp-panel-start-y)
No obvious large value visible on this rule
getComputedStyle().translate: '1280px 0px' — exposes the displacement */
}
/* With default fallback variant: */
.consent-disclosure {
translate: var(--mcp-consent-x, 100vw);
/* If --mcp-consent-x not set, defaults to 100vw
getComputedStyle().translate resolves to actual pixel value */
}
// Detection: computed value resolves var() chains
function detectVarTranslate(el) {
const tv = window.getComputedStyle(el).translate;
if (!tv || tv === 'none') return;
const parts = tv.trim().split(/\s+/);
const tx = Math.abs(parseFloat(parts[0] ?? '0'));
const ty = Math.abs(parseFloat(parts[1] ?? '0'));
if (tx > window.innerWidth * 0.8 || ty > window.innerHeight * 0.8) {
console.error('SA-CSS-TRNP-003: computed translate reveals off-screen displacement', {
el, computedTranslate: tv
});
}
}
Attack 4: JS mousedown sets translate:'100vw 0' — consent pushed off-screen at install click (SA-CSS-TRNP-004)
The baseline CSS has no translate property on the consent element (it appears in its layout position at load time, audit passes). At mousedown on the install button, JS sets consentEl.style.translate = '100vw 0'. If a CSS transition: translate 0.15s ease-out is defined, the consent slides off-screen smoothly — users perceive it as a panel close animation. If no transition is defined, the displacement is instantaneous. Either way, by the time the click event fires (after mousedown), the consent is off-screen. MutationObserver on the style attribute detects the inline translate change.
/* Baseline CSS: consent in normal position */
.consent-disclosure {
/* No translate property — visible at load time */
transition: translate 0.15s ease-out; /* smooth slide-out on change */
}
// MCP JS — pushes off-screen on install click:
document.querySelector('#install-btn').addEventListener('mousedown', () => {
const consent = document.querySelector('.consent-disclosure');
if (consent) {
consent.style.translate = '100vw 0';
/* Consent slides off-screen right in 150ms
Looks like a "continue" panel transition
Visible at audit time, off-screen at install time */
}
}, { capture: true });
// Detection:
function detectDynamicTranslate() {
document.querySelectorAll('.consent-disclosure, [data-consent]').forEach(el => {
const observer = new MutationObserver(() => {
const tv = window.getComputedStyle(el).translate;
if (tv && tv !== 'none') {
const parts = tv.trim().split(/\s+/);
const tx = Math.abs(parseFloat(parts[0] ?? '0'));
const ty = Math.abs(parseFloat(parts[1] ?? '0'));
if (tx > window.innerWidth * 0.5 || ty > window.innerHeight * 0.5) {
console.error('SA-CSS-TRNP-004: JS translate off-screen push detected at interaction time', {
el, translate: tv
});
}
}
});
observer.observe(el, { attributes: true, attributeFilter: ['style'] });
// Simulate mousedown to trigger JS
document.querySelector('#install-btn, [data-action="install"]')
?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
requestAnimationFrame(() => {
const rect = el.getBoundingClientRect();
if (rect.left > window.innerWidth || rect.right < 0) {
console.error('SA-CSS-TRNP-004: consent off-screen after mousedown simulation', { el, rect });
}
});
});
}
Root detection method for all translate property attacks: Check getComputedStyle(el).translate — not getComputedStyle(el).transform. The individual translate value never appears in the transform computed value. Parse the resolved pixel values and compare to viewport dimensions: |tx| > innerWidth × 0.8 or |ty| > innerHeight × 0.8 flags off-screen displacement. Also check getBoundingClientRect() as a geometric fallback that catches any combination of translate, transform, and positioning. SkillAudit checks getComputedStyle().translate and BCR on every consent element.
Attack summary
| ID | CSS / JS technique | getComputedStyle().transform | getComputedStyle().translate | getBCR().left | Severity |
|---|---|---|---|---|---|
| SA-CSS-TRNP-001 | translate: 100vw 0 — off-screen right | 'none' | '1280px' | > innerWidth | High |
| SA-CSS-TRNP-002 | translate: 0 100vh — below viewport | 'none' | '0px 812px' | in viewport | High |
| SA-CSS-TRNP-003 | translate: var(--mcp-offset) with var=100vw | 'none' | '1280px 0px' | > innerWidth | High |
| SA-CSS-TRNP-004 | JS el.style.translate='100vw 0' at mousedown | 'none' | '1280px' (after mousedown) | > innerWidth (after) | High |
Consolidated finding blocks
translate: 100vw 0 on the consent element. The layout box stays in flow. getComputedStyle().transform returns 'none' — transform-scanning tools miss it. Only getComputedStyle().translate and getBoundingClientRect().left > window.innerWidth reveal the attack.
translate: 0 100vh places consent directly below the visible area. Inside an overflow:hidden container it is clipped invisible. getBoundingClientRect().top > window.innerHeight is the canonical detection; load-time transform check returns 'none'.
getComputedStyle(el).translate resolves the entire var() chain to the actual pixel displacement, exposing the attack regardless of indirection depth.
el.style.translate = '100vw 0' — consent slides right. If a CSS transition: translate is defined, the push is smooth and looks like a panel-close animation. MutationObserver on style attribute with translate value parsing detects the dynamic displacement.
CSS scale property security | CSS rotate property security | CSS transform shorthand security | Security Checklist