MCP server CSS offset-distance security: beyond-100% path displacement, negative distance off-screen, paused animation lock, and JS mousedown distance injection attacks
Published 2026-08-19 — SkillAudit Research
CSS offset-distance is the distance an element has traveled along its offset-path. Specified as a percentage or absolute length, it controls where along the path the element is placed. The value 0% places the element at the path origin; 100% places it at the path end. Critically, values outside the [0%, 100%] range are valid — they place the element beyond the path endpoints, extrapolating the path direction. For a ray(0deg) path, offset-distance: 200% sends the element twice the containing block width to the right of the origin.
This path extrapolation is the primary attack vector. Combined with the critical detection gap — getComputedStyle(el).transform always returns 'none' regardless of motion path positioning — offset-distance manipulation can send consent dialogs completely off-screen while every standard visibility check passes.
Critical detection gap: Motion path positioning does not affect transform — they are separate CSS subsystems. An element displaced 200vw to the right by offset-distance: 200% will always report getComputedStyle(el).transform === 'none'. The correct detection axis is getComputedStyle(el).offsetDistance combined with getBoundingClientRect().
Attack 1 (SA-CSS-ODIST-001): offset-distance beyond 100% extrapolates path off-screen
For a ray(0deg) path (a ray pointing right from the containing block), offset-distance: 200% places the element 200% of the containing block width to the right of the origin — typically 2× viewport width, far off-screen. This is the same mechanism as the offset-path ray attack, but achieved by inflating the distance percentage rather than constructing a long ray. The offset-path itself looks short and reasonable; only the offset-distance value betrays the intent:
/* MCP attack: beyond-100% distance extrapolates ray off-screen */
.consent-dialog {
position: absolute;
offset-path: ray(0deg); /* ray pointing right — appears short */
offset-distance: 200%; /* extrapolates 2× containing block width right */
/* For a 1280px viewport: element center at x=2560 — off-screen
getComputedStyle(el).offsetDistance: "200%" — detectable
getComputedStyle(el).transform: "none" — standard checks miss this
getBoundingClientRect().left: ~2560 — BCR reveals displacement */
}
/* Variant using absolute length: */
.consent-dialog-abs {
offset-path: ray(0deg);
offset-distance: 2000px; /* 2000px explicit — off-screen on any normal display */
/* offsetDistance: "2000px" — flag any absolute value > 110vw */
}
/* Detection: */
function detectBeyondPathDistance(el) {
const cs = getComputedStyle(el);
if (cs.offsetPath === 'none') return null;
const distStr = cs.offsetDistance;
if (!distStr || distStr === '0px' || distStr === '0%') return null;
const distPct = parseFloat(distStr);
const isPercent = distStr.includes('%');
const vw = window.innerWidth;
const bcr = el.getBoundingClientRect();
const offScreen = bcr.left > vw + 50 || bcr.right < -50 || bcr.top > window.innerHeight + 50 || bcr.bottom < -50;
if (offScreen) {
return {
severity: 'Critical',
finding: 'SA-CSS-ODIST-001',
offsetPath: cs.offsetPath,
offsetDistance: distStr,
bcr: { left: Math.round(bcr.left), right: Math.round(bcr.right) },
reason: `offset-distance: ${distStr} displaces element off-screen. BCR left: ${Math.round(bcr.left)}px (viewport: ${vw}px). getComputedStyle.transform: 'none' — motion path positioning bypasses transform checks.`,
};
}
if (isPercent && (distPct > 105 || distPct < -5)) {
return {
severity: 'High',
finding: 'SA-CSS-ODIST-001',
offsetDistance: distStr,
reason: `offset-distance: ${distStr} extrapolates beyond path endpoints. Positions outside [0%,100%] place the element beyond the path termini — displacement may push consent off-screen at certain viewport sizes.`,
};
}
return null;
}
Attack 2 (SA-CSS-ODIST-002): negative offset-distance sends element behind the page origin
Negative values for offset-distance extrapolate the path in the opposite direction from the origin. For a ray(0deg) path pointing right, offset-distance: -100% places the element 100% of the containing block width to the left of the origin — in negative x coordinates, off-screen to the left. This is particularly effective for defeating left-boundary checks that only test whether BCR.left ≥ 0:
/* MCP attack: negative distance sends element left of page origin */
.consent-dialog {
offset-path: ray(0deg);
offset-distance: -150%; /* 1.5× viewport width to the LEFT of origin */
/* BCR.left: ~-1920, BCR.right: ~-1620 (300px wide element)
Entire element in negative x-space — off-screen left
BCR.left < -50 is the detection signal */
}
/* Diagonal ray variant: */
.consent-dialog-diag {
offset-path: ray(225deg); /* pointing down-left */
offset-distance: -100%; /* extrapolates up-right — may be off-screen */
/* Direction of extrapolation: opposite of the ray direction
For 225deg (down-left), -100% goes up-right
May push element off top-right of screen depending on containing block position */
}
/* Combined with offset-anchor: the displaced element's BCR can be further shifted
by moving the anchor point — see mcp-server-css-offset-anchor-security for the compound */
Attack 3 (SA-CSS-ODIST-003): paused animation locks offset-distance at 100% — frozen at path endpoint
CSS animations on offset-distance can be declared, set to run duration 0s with animation-fill-mode: forwards, and paused — effectively locking the element at the end position without any apparent running animation. A scanner checking for animation-play-state: running finds a paused animation and may report no active displacement. But the fill-mode has already applied the endpoint value:
/* MCP attack: paused animation locks element at path end */
@keyframes consent-slide {
from { offset-distance: 0%; }
to { offset-distance: 200%; } /* path endpoint: 2× viewport width right */
}
.consent-dialog {
offset-path: ray(0deg);
offset-distance: 0%; /* initial: in-viewport */
animation: consent-slide 0.001s linear forwards paused;
/* animation-play-state: paused → no active animation running
animation-fill-mode: forwards → end value applied: offset-distance effectively 200%
animation-duration: 0.001s → instantaneous, so forwards fill is always active
BCR: off-screen right (200% path distance)
getComputedStyle(el).offsetDistance: depends on browser — may report 0% (specified)
or 200% (computed from animation fill) — browsers vary
getComputedStyle(el).animationPlayState: "paused" — looks inactive
Detection: check BCR regardless of animation state */
}
/* Robust detection: */
function detectAnimationLockedDistance(el) {
const cs = getComputedStyle(el);
if (cs.offsetPath === 'none') return null;
// Check both the animationName and the resulting BCR
const hasAnim = cs.animationName !== 'none';
const bcr = el.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight;
const offScreen = bcr.left > vw + 50 || bcr.right < -50 || bcr.top > vh + 50 || bcr.bottom < -50;
if (hasAnim && offScreen) {
return {
severity: 'Critical',
finding: 'SA-CSS-ODIST-003',
animationName: cs.animationName,
animationPlayState: cs.animationPlayState,
animationFillMode: cs.animationFillMode,
reason: `Offset-path element with animation (name: ${cs.animationName}, state: ${cs.animationPlayState}, fill: ${cs.animationFillMode}) is off-screen. Paused animation with forwards fill-mode locks element at path endpoint beyond viewport.`,
};
}
return null;
}
Attack 4 (SA-CSS-ODIST-004): JS mousedown injects extreme offset-distance at install time
The consent dialog is positioned at offset-distance: 0% at load time — in-viewport, auditable. At mousedown on the install button, the MCP server injects offset-distance: 500px or any value that sends the element off-screen. The transition happens in the same paint cycle as the mousedown event — before the click event fires and before the user can see the layout shift:
/* Load/audit state: offset-distance 0% — consent in viewport */
.consent-dialog {
offset-path: ray(0deg);
offset-distance: 0%;
}
/* MCP JS: inject at mousedown */
document.querySelector('.install-btn').addEventListener('mousedown', () => {
document.querySelector('.consent-dialog').style.offsetDistance = '200%';
// Element jumps off-screen right
// The user's finger/cursor is already on the install button
// The click fires after mousedown — consent was visible when user targeted the button
// but is off-screen at the actual moment of install confirmation
}, { capture: true });
/* Detection: simulate mousedown, compare offset-distance before and after */
function detectMousedownDistanceInjection(rootEl) {
const consentEls = Array.from(rootEl.querySelectorAll(
'[class*="consent"],[class*="terms"],[class*="permission"],[role="dialog"]'
));
const btns = Array.from(rootEl.querySelectorAll(
'button[class*="install"],button[class*="confirm"],button[type="submit"]'
));
for (const btn of btns) {
const before = consentEls.map(el => getComputedStyle(el).offsetDistance);
btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
const after = consentEls.map(el => ({
dist: getComputedStyle(el).offsetDistance,
bcr: el.getBoundingClientRect(),
}));
btn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
btn.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
const vw = window.innerWidth, vh = window.innerHeight;
for (let i = 0; i < consentEls.length; i++) {
if (before[i] !== after[i].dist) {
const b = after[i].bcr;
const offScreen = b.left > vw + 50 || b.right < -50 || b.top > vh + 50 || b.bottom < -50;
return {
severity: 'Critical',
finding: 'SA-CSS-ODIST-004',
before: before[i],
after: after[i].dist,
offScreenAfter: offScreen,
reason: `offset-distance changed from "${before[i]}" to "${after[i].dist}" at mousedown${offScreen ? ' — element is off-screen after change' : ''}. Dynamic path distance injection at consent commit time.`,
};
}
}
}
return null;
}
Unified detection strategy: For all offset-distance attacks, the reliable primary check is getBoundingClientRect() on consent elements that have getComputedStyle(el).offsetPath !== 'none'. If BCR places the element outside the viewport, flag Critical regardless of the offsetDistance value. Secondary: flag any offsetDistance percentage outside [0%, 100%] or any absolute value exceeding 120% of the viewport dimension in the ray direction.
Attack summary
| ID | Attack | Mechanism | Detection point | Severity |
|---|---|---|---|---|
| SA-CSS-ODIST-001 | Beyond-100% distance extrapolation | offset-distance: 200% extrapolates 2× viewport width along ray direction — element off-screen; transform: none; BCR reveals position |
offsetPath !== 'none' + BCR off-viewport |
Critical |
| SA-CSS-ODIST-002 | Negative distance extrapolation | Negative offset-distance extrapolates behind path origin into negative coordinates; element off-screen left/top; BCR.left < -50 signal |
BCR.left < -50 or BCR.top < -50 |
Critical |
| SA-CSS-ODIST-003 | Paused animation locks at path endpoint | animation-fill-mode: forwards + paused 0.001s animation locks element at 100%+ path endpoint; play-state check shows 'paused' — looks inactive; BCR is off-screen |
Check BCR on all elements with offsetPath — ignore animation-play-state | Critical |
| SA-CSS-ODIST-004 | JS mousedown distance injection | Consent in-viewport at load; JS sets offset-distance: 200% at mousedown — element jumps off-screen at commit time; earlier mouseover/focus checks showed in-viewport |
Simulate mousedown; compare offsetDistance before/after; check BCR | Critical |
Finding blocks
offset-distance value exceeds 100% — element extrapolated beyond path end into off-screen territory. BCR confirms off-viewport position. getComputedStyle.transform returns 'none'; standard transform checks miss this. Key: BCR on elements with an active offsetPath is the reliable detector.
offset-distance extrapolates the element behind the path origin into negative coordinate space. BCR.left or BCR.top is negative beyond threshold. Particularly effective at defeating scanners that only test BCR.left ≥ 0 without accounting for motion path extrapolation.
animation-fill-mode: forwards applies the animation endpoint value — including offset-distance: 200% — without showing animation-play-state: running. BCR is off-screen despite the animation appearing inactive. Never skip BCR checks on the grounds that no animation is playing.
offset-distance at mousedown — consent was in-viewport during audit and at hover, but jumps off-screen at the moment of install commit. Simulate mousedown on all install/confirm buttons and compare BCR of consent elements before and after.
← Blog | offset-anchor attacks | offset-path attacks | Motion Path synthesis post | Security Checklist