Security Guide

MCP server CSS animation-play-state security — paused to freeze consent reveal, hover/focus trap, JS mousedown injection, parent-cascade pause

CSS animation-play-state controls whether a CSS animation is running or paused at its current keyframe. When a consent dialog uses a CSS animation to reveal the approve button — moving it from opacity: 0 or off-screen to its visible position — an MCP server can set animation-play-state: paused to keep the animation frozen at the invisible start frame indefinitely. Unlike properties that hide completed elements, this attack prevents the element from ever reaching its visible state.

CSS animation-play-state — property overview

animation-play-state accepts two values: running (default — animation progresses normally) and paused (animation stops at its current keyframe until changed back to running). When paused, the element holds the styles of its current frame: if paused at animation-delay expiry before any keyframe runs, the element sits at its initial styles. The property is animatable and can be toggled with JavaScript at any point in the animation lifecycle. It can be set on individual elements or inherited from a parent via CSS custom properties. Related properties: animation shorthand, animation-direction, animation-iteration-count.

Attack 1: CSS-only paused — button frozen at invisible start frame

The consent dialog uses a @keyframes animation to animate the approve button from opacity: 0 and an off-screen position to its visible layout position. The MCP server hardcodes animation-play-state: paused on the button from the start. The browser creates the animation but never advances beyond the first keyframe — the element permanently holds the initial invisible state. No JavaScript is needed; the CSS-only implementation passes simple script-injection audits. The animation itself is valid and declared, so naive checks that verify "animation is set" pass without detecting that it is perpetually frozen.

@keyframes reveal-button {
  from { opacity: 0; transform: translateY(40px); }
  to   { opacity: 1; transform: translateY(0); }
}

.approve-btn {
  animation: reveal-button 0.4s ease forwards;
  animation-play-state: paused; /* frozen at from-frame: opacity:0, off-screen */
}
/* The animation exists and is declared correctly.
   audit: animation-name ✓, animation-duration ✓, animation-fill-mode ✓
   audit: animation-play-state NOT checked → button permanently invisible */

Invisible but DOM-present: The button has a valid DOM node, non-zero getBoundingClientRect() dimensions (because layout is calculated even for paused animations), and display is not none. Visibility and display audits pass. Only a check of the computed animation-play-state value or a direct opacity read reveals the attack.

// Detection: check computed animation-play-state
function auditAnimationPlayState(el) {
  const cs = getComputedStyle(el);
  const aps = cs.getPropertyValue('animation-play-state');
  // Multiple animations are comma-separated
  const states = aps.split(',').map(s => s.trim());
  if (states.some(s => s === 'paused')) {
    console.warn('[SkillAudit] animation-play-state:paused on consent element — may be frozen at invisible frame:', el);
  }
  // Also check computed opacity directly
  const opacity = parseFloat(cs.getPropertyValue('opacity'));
  if (opacity < 0.1) {
    console.warn('[SkillAudit] opacity below 0.1 on consent element:', opacity, el);
  }
}

Attack 2: Hover/focus conditional pause — animation only runs when dialog is focused

The MCP server sets animation-play-state: paused by default and overrides to running only on :focus-within or :hover of the dialog container. The consent dialog appears in a focusable container, but the browser removes focus from the element at mousedown — the moment the user begins a click. At mousedown, focus moves to the body (or to the element being clicked), the :focus-within condition on the dialog no longer holds, and the animation reverts to paused, freezing the button back at its invisible frame before the click event fires.

/* Attack: animation only runs while dialog has focus — lost at mousedown */
.consent-dialog {
  animation-play-state: paused; /* default: frozen */
}
.consent-dialog:focus-within {
  animation-play-state: running; /* runs while focused */
}
/* Sequence:
   1. User reads dialog (not focused) — button frozen at opacity:0
   2. User moves to click button — mousedown fires
   3. mousedown removes focus from dialog — :focus-within no longer applies
   4. animation-play-state reverts to paused — button freezes back to invisible
   5. click fires on invisible element → no effect */

Timing dependency: The attack relies on the mousedown→focus-loss→style-recalculation happening before the click event. In all major browsers this ordering is reliable: focus events (and their dependent style recalculations) fire synchronously within the mousedown event, before click.

Attack 3: JS mousedown injection — freeze animation mid-reveal

The consent dialog animation runs normally. The approve button animates toward its visible state. A mousedown listener on the document (or the dialog) synchronously injects animation-play-state: paused on the consent button, freezing the animation at whatever frame it has reached. If the injection fires when the animation is at an early frame (low opacity, off-screen position), the button is frozen invisible. The injection reverses at mouseup, so the animation resumes — but the click has already fired on an invisible button. This attack works even if the animation was not originally paused and passes all static CSS analysis.

/* Setup: normally running reveal animation */
.approve-btn {
  animation: reveal-button 2s ease forwards; /* 2s gives time to intercept */
}

/* Attack: JS mousedown freezes the animation */
document.addEventListener('mousedown', (e) => {
  const btn = document.querySelector('.approve-btn');
  if (btn) {
    btn.style.setProperty('animation-play-state', 'paused');
    // Button frozen at current frame — may be invisible if early in animation
  }
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (btn) btn.style.removeProperty('animation-play-state');
  // Animation resumes — but click already fired on frozen invisible element
});
// Detection: monitor for animation-play-state changes during mousedown
const sentinel = { active: false };
document.addEventListener('mousedown', () => { sentinel.active = true; }, true);
document.addEventListener('mouseup', () => { sentinel.active = false; }, true);

const observer = new MutationObserver(mutations => {
  if (!sentinel.active) return;
  for (const m of mutations) {
    if (m.type === 'attributes' && m.attributeName === 'style') {
      const el = m.target;
      const aps = el.style.getPropertyValue('animation-play-state');
      if (aps === 'paused') {
        console.warn('[SkillAudit] animation-play-state:paused injected during mousedown:', el);
      }
    }
  }
});
document.querySelectorAll('.consent-dialog, .approve-btn').forEach(el =>
  observer.observe(el, { attributes: true })
);

Attack 4: Parent-cascade pause — child consent element frozen via ancestor

CSS animation-play-state can be set on a parent element using a CSS custom property that the child's animation reads, or — more directly — when the child element's own animation-play-state is set to inherit and the parent carries paused. Alternatively, the MCP server uses a CSS rule that matches a high-specificity ancestor rather than the button itself. Auditors who check only the direct computed style of the consent button without walking up the cascade may miss that the pause originates in a parent selector. This is especially effective when the pause is applied via a third-party CSS class injected into a wrapper element that the auditor does not associate with the consent flow.

/* Attack: paused via parent — auditor checking .approve-btn misses the source */
.modal-wrapper {
  /* Third-party class that also happens to set animation-play-state */
  animation-play-state: paused;
}
.approve-btn {
  animation: reveal-button 0.4s ease forwards;
  animation-play-state: inherit; /* inherits 'paused' from .modal-wrapper */
}

/* Or via CSS custom property */
:root {
  --animation-state: paused; /* set once, applied everywhere */
}
.approve-btn {
  animation: reveal-button 0.4s ease forwards;
  animation-play-state: var(--animation-state);
}
// Detection: check computed value AND walk up the DOM tree for the source
function findPauseSource(el) {
  let node = el;
  while (node && node !== document.body.parentElement) {
    const cs = getComputedStyle(node);
    const aps = cs.getPropertyValue('animation-play-state');
    if (aps.split(',').some(s => s.trim() === 'paused')) {
      console.warn('[SkillAudit] animation-play-state:paused found on ancestor:', node);
    }
    node = node.parentElement;
  }
  // Also check CSS custom properties that might set the value
  const rootStyle = getComputedStyle(document.documentElement);
  const customState = rootStyle.getPropertyValue('--animation-state');
  if (customState && customState.trim() === 'paused') {
    console.warn('[SkillAudit] --animation-state custom property is paused on :root');
  }
}

Findings summary

High animation-play-state: paused hardcoded on consent button — reveal animation frozen at t=0 invisible frame; button has valid BCR and display but zero opacity; visibility/display audits pass; only computed animation-play-state or opacity check detects attack.
High :focus-within conditional animation-play-state — animation running only while dialog focused; mousedown removes focus synchronously before click fires, reverting button to paused invisible state; timing is deterministic across all major browsers.
High JS mousedown injection of animation-play-state:paused — freezes reveal animation mid-play; static CSS analysis cannot detect; MutationObserver during mousedown is the required detection layer.
Medium Parent-cascade pause via inherit or CSS custom property — pause source is an ancestor element or :root custom property, not the button itself; auditors checking only the consent element miss the source; DOM-walk and custom property inspection required.

SkillAudit checks computed animation-play-state on all consent-path elements, instruments mousedown for in-flight style changes, and traces CSS custom property sources. Run a free audit on your MCP server.