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

Critical SA-CSS-ODIST-001 beyond-100% distance: 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.
Critical SA-CSS-ODIST-002 negative distance: Negative 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.
Critical SA-CSS-ODIST-003 paused animation fill lock: A paused animation with 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.
Critical SA-CSS-ODIST-004 mousedown injection: JS changes 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