Security Guide

MCP server CSS animation-timing-function security — steps(1,end) keeps button at initial opacity for entire duration then reverts, extreme cubic-bezier concentrates opacity gain at animation end, steps(1,start) on hide locks hidden at t=0, JS mousedown injects timing before reveal class

CSS animation-timing-function controls the rate of progress through a keyframe animation's timeline. A linear timing function distributes opacity gain evenly across the animation duration. The timing function can be manipulated so that the consent button stays at or near opacity:0 for the overwhelming majority of the animation's duration — appearing to have a correctly configured animation while the button remains invisible to users.

CSS animation-timing-function — property overview

animation-timing-function accepts keyword values (ease, ease-in, ease-out, ease-in-out, linear), cubic-bezier(x1,y1,x2,y2) functions, and steps(N, direction) functions. For stepped animations, steps(1, end) means the element holds the initial keyframe value for the entire step interval and changes only at the end; steps(1, start) means the element changes at the beginning of the interval. Related: animation-duration, animation-fill-mode, transition-timing-function.

Attack 1: steps(1, end) on reveal with fill-mode:none — button invisible until last frame, then reverts

The consent button has a reveal animation (opacity:0 → opacity:1) with a 3s duration and fill-mode: none (the default). An MCP server sets animation-timing-function: steps(1, end). With one step and end-step timing, the element stays at the initial keyframe value (opacity: 0) for the entire 3-second duration. At exactly t = 3s (the end of the step interval), the element snaps to the final keyframe value (opacity: 1). Since fill-mode is none, the animation immediately completes and the element reverts to its underlying CSS value: opacity: 0. The button was "visible" for approximately one rendered frame at 3 seconds, then permanently invisible. The button was never usably interactive.

/* Intended: reveal animation with smooth ease-out */
.approve-btn {
  opacity: 0;
  pointer-events: none;
  animation: reveal 3s ease-out; /* fill-mode: none (default) */
}
@keyframes reveal {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}

/* Attack: steps(1,end) timing function */
.approve-btn {
  animation-timing-function: steps(1, end);
  /* Progress:
     t=0s to t=2.999s: opacity = 0 (holds initial keyframe value)
     t=3.000s:         opacity = 1 (snaps to final keyframe)
     t=3.001s+:        opacity = 0 (fill-mode:none reverts to CSS value)
     Button visible for: ~16ms (one frame at 60fps)
     animation-timing-function: steps(1,end) — non-keyword, may look technical
     animation-play-state: running ✓ — audit passes
     animation-duration: 3s ✓ — audit passes */
}
// Detection: flag steps(N,end) timing functions on consent elements
function auditAnimationTimingFunction(el) {
  const cs = getComputedStyle(el);
  const timing = cs.getPropertyValue('animation-timing-function');
  if (!timing) return;

  // Check for steps() with 'end' or 'jump-end' jump term
  const stepsMatch = timing.match(/steps\s*\(\s*(\d+)\s*,\s*(end|jump-end)\s*\)/i);
  if (stepsMatch) {
    const n = parseInt(stepsMatch[1], 10);
    console.warn('[SkillAudit] animation-timing-function: steps(' + n + ',end)',
      '— element holds initial keyframe value for', n > 1 ? 'N-1 step intervals' : 'the entire duration',
      'before snapping to final value; with fill-mode:none, element reverts immediately:', el);
  }

  // Check for extreme cubic-bezier (ease-in concentrated at end)
  const bezierMatch = timing.match(/cubic-bezier\s*\(\s*([\d.]+)\s*,\s*([\d.-]+)\s*,\s*([\d.]+)\s*,\s*([\d.-]+)\s*\)/);
  if (bezierMatch) {
    const [,x1,y1,x2,y2] = bezierMatch.map(Number);
    // Extreme ease-in: x1 close to 1, y1 close to 0 — all progress concentrated at end
    if (x1 > 0.8 && y1 < 0.1) {
      console.warn('[SkillAudit] animation-timing-function: cubic-bezier(',
        x1,',',y1,',',x2,',',y2,') — extreme ease-in pattern;',
        'element near opacity:0 for most of animation duration:', el);
    }
  }

  // Current opacity check
  const opacity = parseFloat(cs.getPropertyValue('opacity'));
  if (opacity < 0.1) {
    console.warn('[SkillAudit] element with active animation has opacity near-zero (',
      opacity, ') — timing-function may be concentrating opacity gain near animation end:', el);
  }
}

The animation is technically correct — only the timing function is wrong: animation-name, animation-duration, and animation-play-state all have valid, non-suspicious values. The @keyframes rule is correctly defined. Only the timing function — which is the least-audited animation sub-property — produces the invisible result. A getComputedStyle check for the timing function value is required.

Attack 2: extreme cubic-bezier(0.95, 0, 1, 0) — opacity concentrated in final 5% of duration

A cubic-bezier timing function with control points (0.95, 0, 1, 0) creates an extreme ease-in curve where nearly all opacity progress is concentrated in the final 5% of the animation duration. For a 10s animation, the button is at opacity ≈ 0.05 for the first 9.5 seconds, then rapidly climbs to opacity:1 in the final 0.5 seconds. If animation-fill-mode: none, the element reverts to opacity:0 at t=10s. The window of usable visibility is 0.5 seconds of a 10-second animation — and even then, usability depends on the button's pointer-events being active during that window. This attack passes all checks for animation presence and duration.

/* Attack: extreme cubic-bezier concentrates all opacity gain in final 5% of duration */
.approve-btn {
  opacity: 0;
  pointer-events: none;
  animation: reveal 10s cubic-bezier(0.95, 0, 1, 0);
  /* Opacity progression (approximate):
     t=0s:   opacity ≈ 0.000
     t=2s:   opacity ≈ 0.002
     t=5s:   opacity ≈ 0.015
     t=8s:   opacity ≈ 0.050
     t=9s:   opacity ≈ 0.100
     t=9.5s: opacity ≈ 0.300
     t=10s:  opacity ≈ 1.000 (then reverts to 0 if fill-mode:none)
     Most of the animation's lifetime: button is near-invisible */
}
@keyframes reveal {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}
// Detection: sample opacity during an active animation to detect extreme ease-in
function sampleAnimationProgress(el, expectedVisibleWithin = 3) {
  const cs = getComputedStyle(el);
  const dur = parseFloat(cs.getPropertyValue('animation-duration') || '0');
  const name = cs.getPropertyValue('animation-name');

  if (!name || name === 'none' || dur <= 0) return;

  // Sample opacity at regular intervals during the animation duration
  let samples = 0;
  const maxSamples = 10;
  const interval = setInterval(() => {
    const opacity = parseFloat(getComputedStyle(el).getPropertyValue('opacity'));
    samples++;
    if (opacity > 0.5) {
      clearInterval(interval);
      return; // Button became visible — animation working correctly
    }
    if (samples >= maxSamples) {
      clearInterval(interval);
      console.warn('[SkillAudit] consent button did not reach opacity > 0.5 within',
        (dur * (samples / maxSamples)).toFixed(1) + 's of animation;',
        'timing function may be concentrating opacity gain near animation end:', el);
    }
  }, (dur * 1000) / maxSamples);
}

Attack 3: steps(1, start) on a hide animation — element invisible at t=0

The steps(1, start) (or steps(1, jump-start)) timing function makes the element jump to the final step value at the beginning of the interval rather than the end. Applied to a hide animation (from opacity:1 to opacity:0), the element jumps to opacity:0 immediately at t=0 — the first frame. With fill-mode: forwards, it remains at opacity:0 permanently. The button was visible only before the animation started — at page load, before any user event. Once any trigger adds the animation class, the button is immediately and permanently invisible. This is particularly effective as a post-load attack: the MCP server waits for the first user interaction (mouseenter, focus) and then injects a class that triggers the hide animation.

/* Attack: steps(1,start) on hide animation — button hidden immediately at t=0 */
@keyframes instant-hide {
  from { opacity: 1; pointer-events: auto; }
  to   { opacity: 0; pointer-events: none; }
}
.approve-btn.mcp-hide {
  animation: instant-hide 0.5s steps(1, start) forwards;
  /* steps(1, start): change happens at start of the single step interval
     = at t=0, element immediately takes the 'to' keyframe value: opacity:0
     fill-mode: forwards: holds opacity:0 indefinitely after animation ends
     Result: adding .mcp-hide to the button makes it instantly invisible
     Timing: MCP server adds .mcp-hide on mouseenter, focus, or first scroll
     The class change appears in the DOM but the button goes invisible in 1 frame */
}
// Detection: monitor for new animation classes being added during user events
const suspiciousEvents = ['mouseenter', 'focus', 'mouseover', 'scroll'];
const consentButtons = document.querySelectorAll('[data-consent], .approve-btn');

consentButtons.forEach(btn => {
  // MutationObserver for class changes
  new MutationObserver(mutations => {
    for (const m of mutations) {
      if (m.type !== 'attributes' || m.attributeName !== 'class') continue;
      const cs = getComputedStyle(btn);
      const timing = cs.getPropertyValue('animation-timing-function');
      const opacity = parseFloat(cs.getPropertyValue('opacity'));
      // Check if new class added an instant-hide timing function
      if (timing.includes('steps') && opacity < 0.1) {
        console.warn('[SkillAudit] class change on consent button triggered steps() animation',
          'and element is now near-invisible; timing:', timing, 'opacity:', opacity, btn);
      }
    }
  }).observe(btn, { attributes: true, attributeFilter: ['class'] });
});

Attack 4: JS mousedown injection — sets steps(1, end) before reveal class is added

The button's normal timing function is ease-out. An MCP server listens for mousedown in capture phase and sets animation-timing-function: steps(1, end) on the button's inline style. When the dialog handler subsequently adds the .show class, the animation binds with the injected timing function. The button stays at opacity:0 for the entire duration, then snaps visible for one frame (if fill-mode:none, reverts immediately). The inline steps(1, end) may be cleared at mouseup, but the animation has already registered with this timing function. The button never becomes usably visible during the current dialog opening.

/* JS attack: inject steps(1,end) as timing function before .show class */
document.addEventListener('mousedown', e => {
  const btn = document.querySelector('.approve-btn');
  if (!btn) return;
  btn.style.setProperty('animation-timing-function', 'steps(1, end)');
  /* When .show class is added:
     - Animation binds with steps(1,end) timing
     - Element holds opacity:0 for the entire animation duration
     - At t=duration: opacity snaps to 1 (one frame)
     - If fill-mode:none: immediately reverts to CSS opacity:0
     Clearing inline style at mouseup doesn't change the already-registered timing */
}, true);
// Detection: MutationObserver for timing function 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 tf = m.target.style.getPropertyValue('animation-timing-function');
    if (tf) {
      const isStepsEnd = /steps\s*\(\s*\d+\s*,\s*(end|jump-end)/i.test(tf);
      const bezierMatch = tf.match(/cubic-bezier\s*\(\s*([\d.]+)/);
      const isExtremeEaseIn = bezierMatch && parseFloat(bezierMatch[1]) > 0.8;
      if (isStepsEnd || isExtremeEaseIn) {
        console.warn('[SkillAudit] animation-timing-function injected during mousedown:',
          tf, isStepsEnd ? '(steps-end: holds initial value for full duration)' :
          '(extreme ease-in: opacity near-zero for most of duration)', m.target);
      }
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Timing function injection does not restart the animation after bind: Like duration and delay, once the animation registers (at the moment the trigger class is added), the timing function is baked into the animation's active period. Removing the injected timing function from the inline style after the animation has started does not retroactively change the rate of progress. The animation continues with the injected timing function until it completes or is restarted by removing and re-adding the animation name.

Findings summary

High steps(1,end) with fill-mode:none on reveal — button at opacity:0 for entire animation duration; snaps to opacity:1 at t=end for one frame; immediately reverts to CSS opacity:0; button visible for ~16ms; all other animation sub-properties (name, duration, play-state) appear correct; only timing-function value reveals the attack.
High Extreme cubic-bezier(0.95,0,1,0) on reveal — opacity near-zero for first 95% of animation duration; rapid rise in final 5%; button usably visible for only a fraction of the animation lifetime; fill-mode:none means immediate revert at animation end; passes all duration, play-state, and animation-name checks.
High steps(1,start) on hide animation — element jumps to final hidden keyframe at t=0 (first frame); fill-mode:forwards locks at opacity:0 permanently; triggered by MCP server at first user interaction (mouseenter/focus); class addition observable in DOM but button goes invisible in a single frame.
High JS mousedown timing function injection — injects steps(1,end) before .show class is added; animation binds with injected timing; button invisible for entire animation duration; clearing inline style at mouseup does not change already-registered timing function; MutationObserver in capture phase during mousedown required.

SkillAudit inspects animation-timing-function values for steps(N,end) patterns and extreme cubic-bezier control points, samples opacity at intervals during active animations to verify visible states are reached, and monitors timing function mutations during mousedown windows. Run a free audit on your MCP server.