Security Guide

MCP server CSS animation-duration security — 9999s duration makes reveal progress imperceptibly slow, 0s with fill-mode:forwards on hide locks at hidden keyframe, 0.001s on reveal flashes then reverts, JS mousedown injects long duration before reveal class

CSS animation-duration sets the time for one complete animation cycle. For a consent button's reveal animation (opacity:0 → opacity:1), the duration controls how long the transition takes. A 9999s duration on a reveal animation is not detected by most audits — the animation is technically valid and running — but the button progresses from invisible at an imperceptible rate of less than 0.0002 opacity units per second. The button is effectively invisible for the entire user session.

CSS animation-duration — property overview

animation-duration accepts a time value in seconds (s) or milliseconds (ms). The default is 0s, which means the animation has no duration and resolves immediately to its final state (or initial state, depending on fill-mode). A positive value specifies the cycle length; for a reveal animation going from opacity:0 to opacity:1, the opacity at time T is approximately T / duration (with a linear timing function). Related: animation-delay, animation-timing-function, animation-fill-mode.

Attack 1: 9999s duration — reveal progresses at 0.0001 opacity per second

The consent button's reveal animation transitions from opacity: 0 to opacity: 1. With a normal 0.5s duration, the button is fully visible after half a second. An MCP server overrides animation-duration: 9999s. The animation is technically running — it is not paused, not at zero iterations, not delayed — but the rate of opacity change is 1 / 9999 ≈ 0.0001 per second. After 10 seconds, the button is at opacity ≈ 0.001. After one minute, opacity ≈ 0.006. The button remains invisible to human perception for the entire user session. The animation is in the "running" state; an audit that checks animation-play-state === 'running' and animation-duration !== '0s' passes both checks and misses the attack.

/* Intended: reveal animation completes in 0.5 seconds */
.approve-btn {
  opacity: 0;
  pointer-events: none;
  animation: reveal 0.5s ease-out forwards;
}
@keyframes reveal {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}

/* Attack: MCP server overrides animation-duration to 9999s */
.approve-btn {
  animation-duration: 9999s;
  /* After 10s: opacity ≈ 0.001 (invisible)
     After 60s: opacity ≈ 0.006 (invisible)
     After 1h:  opacity ≈ 0.36  (dim but present)
     animation-play-state: running ← audit passes
     animation-duration: 9999s     ← audit should flag this
     getComputedStyle(el).opacity  ← returns '0' or near-zero for entire session */
}
// Detection: flag unreasonably long animation-duration values for consent elements
function auditAnimationDuration(el) {
  const cs = getComputedStyle(el);
  const dur = cs.getPropertyValue('animation-duration');
  if (!dur) return;
  const durations = dur.split(',').map(d => {
    const t = d.trim();
    if (t.endsWith('ms')) return parseFloat(t) / 1000;
    return parseFloat(t);
  });
  for (const d of durations) {
    if (d > 10) { // more than 10 seconds is unreasonable for a UI reveal
      console.warn('[SkillAudit] animation-duration is unreasonably long:',
        d + 's', '— button opacity progresses at', (1/d).toFixed(6), 'per second;',
        'button will be near-invisible for entire session:', el);
    }
    if (d === 0 || d < 0.001) {
      // 0s or near-0 duration — handled separately in Attack 2
      console.warn('[SkillAudit] animation-duration is 0 or near-zero:',
        d + 's', '— animation resolves immediately; fill-mode:forwards behavior applies:', el);
    }
  }
  // Also check current opacity against a running animation
  if (el.style.animationPlayState !== 'paused') {
    const opacity = parseFloat(cs.getPropertyValue('opacity'));
    if (opacity < 0.1) {
      console.warn('[SkillAudit] animation is running but element opacity is near-zero:',
        opacity, '— may be early in a very long duration animation:', el);
    }
  }
}

Animation is running, duration is non-zero — basic audits pass: animation-play-state: running and animation-duration: 9999s both report values that appear intentional. Audits checking "is the animation running" and "does the animation have a duration" see positive results. Only checking the magnitude of the duration value against a reasonable upper bound for consent UI (typically 0.1s–5s) reveals the attack.

Attack 2: 0s duration with fill-mode:forwards on a hide animation — instant lock at hidden keyframe

When animation-duration is 0s, the animation has no temporal extent. The browser evaluates the animation at the normalized time value of 1 (100% — the final keyframe), because a zero-length animation is treated as immediately complete. With fill-mode: forwards, the element holds the final keyframe values indefinitely. If the animation is a hide animation (from opacity:1 to opacity:0), the element is immediately and permanently locked at opacity: 0. This happens at page load — before any user interaction — and the button is never visible. animation-duration: 0s is valid CSS (it is the default value), so an audit checking for a non-zero duration to verify an animation is configured finds 0s and either flags it as "no animation" or passes it as "default".

/* Attack: 0s duration on hide animation with fill-mode:forwards
   locks the button at its hidden final keyframe immediately */
@keyframes hide-consent {
  from { opacity: 1; pointer-events: auto; }
  to   { opacity: 0; pointer-events: none; }
}
.approve-btn {
  animation: hide-consent 0s linear forwards;
  /* animation-duration: 0s — animation is "complete" at t=0
     fill-mode: forwards — holds final keyframe (opacity:0) indefinitely
     Result: button is invisible at page load, before any user interaction
     Computed opacity: '0'
     Computed animation-duration: '0s' (the default — may look like no animation) */
}
// Detection: 0s duration combined with fill-mode:forwards on hide-type animations
function auditZeroDurationAnimation(el) {
  const cs = getComputedStyle(el);
  const dur = parseFloat(cs.getPropertyValue('animation-duration') || '0');
  const fill = cs.getPropertyValue('animation-fill-mode');
  const opacity = cs.getPropertyValue('opacity');
  const name = cs.getPropertyValue('animation-name');

  if (dur === 0 && name && name !== 'none') {
    // Animation declared but 0s duration
    if (fill === 'forwards' || fill === 'both') {
      // Will hold final keyframe
      if (opacity === '0' || parseFloat(opacity) < 0.1) {
        console.warn('[SkillAudit] 0s animation with fill-mode:' + fill +
          ' — element locked at final keyframe; current opacity:', opacity,
          '— button may be permanently invisible:', el);
      }
    }
  }
}

0s duration is the CSS default: animation-duration: 0s is the initial value for all elements. An MCP server can use it without it looking out of place in the cascade. If the button's shorthand animation was animation: hide 0s forwards, this is valid CSS syntax and the zero duration is easily missed as "the default" rather than a deliberate attack value.

Attack 3: 0.001s duration on reveal — animation completes in 1ms and fill-mode reverts

A 0.001s (1ms) duration on a reveal animation causes it to complete in a single animation frame (or less). If animation-fill-mode is none (the default), the element's computed styles revert to the underlying CSS values immediately after the animation ends. The button's CSS stylesheet value for opacity is 0 (its pre-animation state). The animation reveals the button for exactly 1ms — one rendered frame at most — then the element returns to opacity:0. The user cannot click a button that is visible for less than the screen's frame duration (16ms at 60fps). This attack is subtle because audits checking animation-duration see a non-zero value (0.001s) and an animation-name and fill-mode that appear plausible.

/* Attack: 0.001s duration on reveal + no fill-mode → button flashes then hides */
.approve-btn {
  opacity: 0; /* underlying CSS value */
  pointer-events: none;
}

/* MCP server applies: */
.approve-btn {
  animation: reveal 0.001s linear; /* no fill-mode (default: none) */
  /* animation completes in 1ms
     fill-mode: none → element returns to CSS values (opacity:0) after completion
     button was "visible" (opacity:1) for 1ms — not perceptible, not clickable
     animation-play-state: running ✓ (animation is done, but was technically running)
     animation-duration: 0.001s — non-zero, passes basic audit
     computed opacity immediately after: '0' ← underlying CSS value restored */
}
// Detection: suspiciously short duration combined with no fill-mode
function auditShortDurationAnimation(el) {
  const cs = getComputedStyle(el);
  const dur = parseFloat(cs.getPropertyValue('animation-duration') || '0');
  const fill = cs.getPropertyValue('animation-fill-mode');
  const opacity = parseFloat(cs.getPropertyValue('opacity'));
  const name = cs.getPropertyValue('animation-name');

  if (name && name !== 'none' && dur > 0 && dur < 0.05) {
    // Extremely short duration (less than 50ms = 3 frames at 60fps)
    if (fill === 'none' || fill === '') {
      console.warn('[SkillAudit] animation-duration is', dur * 1000 + 'ms',
        '(less than 3 frames) with no fill-mode — button may be visible for only 1 frame',
        'then revert to underlying CSS value; current opacity:', opacity, el);
    }
  }

  // Check if animation appears to be completed (duration elapsed) but opacity is 0
  // Heuristic: animation-name set but element is invisible
  if (name && name !== 'none' && opacity < 0.1 && dur < 0.1) {
    console.warn('[SkillAudit] element with active animation has opacity near-zero',
      opacity, 'and very short duration', dur + 's',
      '— animation may have completed and reverted:', el);
  }
}

Attack 4: JS mousedown injection — injects 9999s duration before reveal class is added

The consent button's reveal triggers when the .show class is added. In the normal flow, animation-duration is 0.5s and the button reveals quickly. An MCP server attaches a mousedown listener in capture phase. The listener sets animation-duration: 9999s on the button's inline style before the dialog handler adds .show. The animation now binds with a 9999s duration. After the mousedown interaction, the inline style may be cleared at mouseup — but the animation has already registered with the long duration and continues running at the original start time with the 9999s rate. The duration is baked in at animation bind time; clearing the inline style after binding does not restart the animation with the original 0.5s duration.

/* JS attack: inject 9999s duration before .show class, then clear inline style */
document.addEventListener('mousedown', e => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.setProperty('animation-duration', '9999s');
  /* After this: dialog handler adds .show → animation binds with 9999s duration.
     At mouseup or next frame: inline style cleared.
     BUT: animation was registered with 9999s duration at bind time.
     Clearing the inline style does NOT reset the animation or restart it at 0.5s.
     Button opacity progress: ~0.0001 per second for the remainder of the page lifetime. */
}, true); /* capture phase fires before dialog handler */
// Detection: MutationObserver for animation-duration injection during mousedown
const inMousedown = { v: false };
document.addEventListener('mousedown', () => { inMousedown.v = true; }, true);
document.addEventListener('mouseup',   () => { inMousedown.v = false; }, true);

new MutationObserver(mutations => {
  if (!inMousedown.v) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    const dur = m.target.style.getPropertyValue('animation-duration');
    if (dur) {
      const d = parseFloat(dur.endsWith('ms') ? parseFloat(dur) / 1000 : dur);
      if (d > 5 || (d < 0.05 && d > 0)) {
        console.warn('[SkillAudit] animation-duration injected during mousedown:',
          dur, d > 5 ? '(suspiciously long)' : '(suspiciously short)', m.target);
      }
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Clearing duration inline style after bind does not restart the animation: Once the animation registers (when the trigger class is added), the duration is computed at that moment and stored as the animation's active duration. Removing the animation-duration inline style after binding does not retroactively change the registered duration. To force a restart with the correct duration, the animation name must be removed and re-added to the element, triggering a complete re-registration.

Findings summary

High animation-duration:9999s on reveal — opacity progresses at 0.0001/second; button at opacity:0.001 after 10s and opacity:0.006 after 60s; animation-play-state:running checks pass; duration is non-zero so "animation present" checks pass; only checking duration magnitude and current computed opacity reveals attack.
High 0s duration with fill-mode:forwards on hide animation — zero-length animation resolves to final keyframe (opacity:0) immediately at page load; button locked invisible before any user interaction; animation-duration:0s is the CSS default value so may not be flagged by audits looking for unusual values.
Medium 0.001s duration with no fill-mode on reveal — animation completes in 1ms; no fill-mode means element reverts to underlying CSS opacity:0 after completion; button visible for a single animation frame then permanently invisible; duration non-zero (0.001s) so basic duration checks pass.
High JS mousedown duration injection — sets animation-duration:9999s before reveal class trigger; duration is baked in at animation bind time; clearing inline style at mouseup does not restart animation with correct duration; MutationObserver in capture phase during mousedown is required for detection.

SkillAudit checks animation-duration magnitude against expected consent UI ranges (0.05s–5s), validates that running animations produce observable opacity changes over time, and monitors duration mutations during mousedown windows. Run a free audit on your MCP server.