Security Guide

MCP server CSS animation-iteration-count security — 0 skips reveal entirely, 0.5 freezes at half-opacity, infinite cycling with hidden phase, JS mousedown sets count to 0

CSS animation-iteration-count controls how many times an animation plays. Setting it to 0 means the animation runs zero times — the element never leaves its pre-animation state. If the consent button starts invisible (initial opacity: 0, off-screen) and a reveal animation is declared but its iteration count is zero, the button never appears. Fractional counts like 0.5 freeze the animation at its midpoint, while infinite combined with a cycle that is hidden 90% of the time makes the consent window unpredictable.

CSS animation-iteration-count — property overview

animation-iteration-count accepts a positive number (including fractions) or the keyword infinite. The number specifies how many complete cycles the animation plays. A value of 1 (default) plays the animation once. 0 means the animation plays zero times — the element holds its pre-animation styles regardless of any fill-mode. Fractional values (e.g., 0.5) play the animation for the specified fraction of one cycle, then stop at that keyframe position (subject to fill-mode). Related: animation-play-state, animation-direction, animation-fill-mode.

Attack 1: animation-iteration-count: 0 — reveal animation never runs

The @keyframes reveal-button animation is declared on the button, but animation-iteration-count: 0 prevents it from ever executing. The browser does not apply any keyframe styles — not even the from frame. The element retains its CSS-specified initial styles, which the MCP server sets to opacity: 0 and an off-screen position. The button never becomes visible. Because the animation name, duration, and fill-mode are all set correctly, auditors who check for animation declarations will confirm everything "looks right." Only a check of the iteration count (or a direct opacity/BCR verification) reveals the attack.

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

.approve-btn {
  /* Initial state: hidden */
  opacity: 0;
  transform: translateY(40px);

  /* Reveal animation — correctly declared but never runs */
  animation-name: reveal-button;
  animation-duration: 0.5s;
  animation-fill-mode: forwards;
  animation-iteration-count: 0; /* zero iterations — animation does not execute */
}
/* Button stays at opacity:0, transform:translateY(40px) — invisible forever */

Spec note on count:0 and fill-mode: Per the CSS Animations spec, when animation-iteration-count is 0, the element does not enter the animation active period at all. Fill-mode does not apply — no keyframe styles are applied forward or backward. The element stays exactly at its underlying CSS values. This is distinct from animation-play-state: paused, which does freeze at the animation start frame.

// Detection: check animation-iteration-count
function auditIterationCount(el) {
  const cs = getComputedStyle(el);
  const count = cs.getPropertyValue('animation-iteration-count');
  const counts = count.split(',').map(c => c.trim());
  if (counts.some(c => parseFloat(c) === 0)) {
    console.warn('[SkillAudit] animation-iteration-count:0 — reveal animation never runs:', el);
  }
  // Verify final visible state independently of animation
  const opacity = parseFloat(cs.getPropertyValue('opacity'));
  if (opacity < 0.1) {
    console.warn('[SkillAudit] opacity below 0.1 on consent element (possible reveal blocked):', el);
  }
}

Attack 2: Fractional count (0.5) — animation frozen at 50% keyframe

A fractional iteration count of 0.5 runs the animation for exactly half of one cycle — from the 0% keyframe to the 50% position — then stops. With animation-fill-mode: forwards, the element holds the style values at the halfway point. For an opacity: 0 to opacity: 1 animation with a linear timing function, the frozen state is opacity: 0.5. The button is semi-transparent: visible enough that some interaction-presence audits will detect it, but not clearly readable or clickable for typical users. Combined with reduced contrast from the partial opacity, the button may not meet WCAG contrast thresholds and may be missed by users scanning the UI.

@keyframes reveal-button {
  from { opacity: 0; }
  to   { opacity: 1; }
}
.approve-btn {
  animation: reveal-button 1s linear forwards;
  animation-iteration-count: 0.5; /* stops at 50% = opacity:0.5 */
  /* Depending on timing function, the exact keyframe position varies.
     With linear: opacity = 0.5 (half-visible)
     With ease-in: opacity is lower at 50% of time (even less visible) */
}

Timing function interaction: The 0.5 fractional count stops the animation at the 50% time position, not the 50% progress position. With non-linear timing functions like ease-in, the animation progresses slowly at first, meaning at 50% of time the opacity may only be 10–20%. This makes fractional counts more powerful with non-linear timing functions.

Attack 3: infinite iteration count — consent window is a brief phase in a long cycle

The animation runs infinitely, but the keyframe cycle spends most of its time in an invisible state. The consent button is at opacity: 1 only for a brief window each cycle (say, 0.5 seconds out of every 10 seconds), then returns to invisible for 9.5 seconds. Users who load the page during the hidden phase see no button for many seconds. Users who see the button momentarily and attempt to click must time their click precisely within the 0.5-second window, which is not a realistic interaction model. The button is genuinely visible at times, so BCR checks at audit time may pass. Only a long-duration audit that samples opacity over time detects the cycling behavior.

@keyframes consent-pulse {
  0%   { opacity: 0; pointer-events: none; }
  90%  { opacity: 0; pointer-events: none; } /* invisible for 9s in a 10s cycle */
  91%  { opacity: 1; pointer-events: auto; } /* visible for only 0.1s */
  99%  { opacity: 1; pointer-events: auto; }
  100% { opacity: 0; pointer-events: none; }
}
.approve-btn {
  animation: consent-pulse 10s linear infinite;
  /* Button visible for ~0.1s out of every 10s.
     At the moment of a spot-check audit, there is a 99% chance
     the button is at opacity:0 and pointer-events:none */
}
// Detection: sample computed opacity over time to catch cycling behavior
async function auditOpacityOverTime(el, samples = 20, intervalMs = 500) {
  const opacities = [];
  for (let i = 0; i < samples; i++) {
    opacities.push(parseFloat(getComputedStyle(el).opacity));
    await new Promise(r => setTimeout(r, intervalMs));
  }
  const visibleCount = opacities.filter(o => o > 0.9).length;
  const ratio = visibleCount / samples;
  if (ratio < 0.8) {
    console.warn(`[SkillAudit] consent button visible only ${Math.round(ratio*100)}% of sampled time — cycling or delayed reveal:`, el, opacities);
  }
}

Attack 4: JS mousedown sets animation-iteration-count: 0 — cancels in-flight reveal

The consent button reveal animation is in progress. A mousedown listener synchronously injects animation-iteration-count: 0 on the element. Per the CSS Animations spec, changing the iteration count to 0 on a running animation removes the element from its active animation period immediately — the element reverts to its underlying (pre-animation) CSS values. If those values define opacity: 0 and an off-screen position, the button instantly jumps to its invisible state at mousedown. At mouseup, the count is reset to its original value and the animation restarts from the beginning. The click fires on the element at its temporarily invisible position.

/* Attack: cancel in-flight reveal by setting iteration-count to 0 */
document.addEventListener('mousedown', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  // Injecting 0 immediately removes element from animation active period
  // Element snaps back to underlying opacity:0, off-screen CSS values
  btn.style.setProperty('animation-iteration-count', '0');
});
document.addEventListener('mouseup', () => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.removeProperty('animation-iteration-count');
  // Animation restarts — but click has already fired on invisible element
});
// Detection: MutationObserver watching for iteration-count changes during mousedown
let mouseDown = false;
document.addEventListener('mousedown', () => { mouseDown = true; }, true);
document.addEventListener('mouseup', () => { mouseDown = false; }, true);

const observer = new MutationObserver(muts => {
  if (!mouseDown) return;
  for (const m of muts) {
    if (m.type === 'attributes' && m.attributeName === 'style') {
      const ic = m.target.style.getPropertyValue('animation-iteration-count');
      if (ic !== '' && parseFloat(ic) === 0) {
        console.warn('[SkillAudit] animation-iteration-count set to 0 during mousedown:', m.target);
      }
    }
  }
});
document.querySelectorAll('.consent-dialog, .approve-btn').forEach(el =>
  observer.observe(el, { attributes: true })
);

Findings summary

High animation-iteration-count:0 — animation plays zero times; consent button stays at initial invisible state; fill-mode does not apply when count is zero; animation-name and duration audits pass; only iteration-count check or opacity verification detects attack.
Medium Fractional iteration-count (0.5) — animation stops at 50% of one cycle; with ease-in timing, actual progress at 50% of time may be as low as 10–20% opacity; button semi-visible but not actionable for most users; timing-function amplifies effect.
High Infinite iteration-count with hidden-phase keyframes — button visible for 0.1s per 10s cycle; 99% probability of being invisible at any spot-check moment; only time-sampled opacity audit detects the cycling pattern.
High JS mousedown sets iteration-count:0 — removes element from animation active period; element snaps to underlying invisible CSS values; animation restarts at mouseup; click fires on element at invisible position; MutationObserver required for detection.

SkillAudit checks animation-iteration-count for zero and fractional values, samples consent element opacity over time to detect cycling, and instruments mousedown for iteration-count injection. Run a free audit on your MCP server.