MCP server Web Animations API consent security

The Web Animations API allows JavaScript to apply CSS animations directly to DOM elements via element.animate(keyframes, options) without any corresponding CSS animation rule or @keyframes declaration in the stylesheet. Because the animation exists only as a runtime JavaScript call, static analysis of CSS stylesheets, inline styles, and SVG markup finds no attack signatures. The consent text's HTML source, computed styles at parse time, and initial rendered state are all normal. The attack activates at runtime — a color fade to background color, a precisely delayed opacity collapse, or an immediate finish call on a reveal animation — and is invisible to tools that do not perform JavaScript execution analysis. This page covers four Web Animations API consent attack patterns and the JavaScript analysis methods required to detect them.

Finding SA-WANM-001: Element.animate() color fade to background color starting 100ms after render

CriticalA JavaScript call of consentEl.animate([{color: '#1f2937'}, {color: '#f9fafb'}], {delay: 100, duration: 8000, fill: 'forwards'}) begins fading the consent text color from readable dark (#1f2937) toward the page background color (#f9fafb) starting 100ms after the call. Over 8 seconds, the text gradually becomes invisible. At 4 seconds the text color is approximately #8d9096 — WCAG contrast against white background ≈ 3.5:1 (borderline AA for normal text). At 6 seconds the color is approximately #cfd2d5 — contrast ≈ 1.4:1 (unreadable). At 8 seconds the animation is at its end state (fill: 'forwards' preserves the final value): consent text color = #f9fafb = background. The DOM's color style property reads '#1f2937' before the animation completes — only the animation's active value is different. Static CSS analysis reads the baseline color; the animation target is in JavaScript only.
// Consent text element is in the DOM with normal color styling
const consentEl = document.getElementById('consentText');

// ATTACK: fade color to background starting 100ms after render
// Static CSS scan reads color: #1f2937 (readable)
// After 6s runtime: color ≈ #cfd2d5 — WCAG contrast 1.4:1 (unreadable)
// After 8s: color = #f9fafb = page background (invisible)
consentEl.animate(
  [
    { color: '#1f2937' },   // start: readable dark text
    { color: '#f9fafb' }    // end: background color (invisible)
  ],
  {
    delay: 100,             // starts 100ms after call
    duration: 8000,         // fades over 8 seconds
    fill: 'forwards',       // preserves final color after animation ends
    easing: 'ease-in'       // accelerates fade — readable for first ~3s
  }
);

Detection: analyze JavaScript for .animate() calls. Extract the target element (via getElementById, querySelector, or variable reference). Check whether the element is consent-adjacent (contains consent-indicative text, is a consent form child, or has a class/id matching consent-related naming patterns). Inspect the keyframes array: if any keyframe includes color, opacity, backgroundColor, visibility, or filter properties with terminal values that reduce legibility (color approaching background, opacity approaching 0, filter blur/brightness), flag Critical. The fill: 'forwards' property is a strong indicator — it indicates the attack value is intended to persist after animation completion.

Finding SA-WANM-002: delay calibrated to expected click timestamp — opacity collapse at interaction time

HighAn attacker who can estimate when the user will click the agree button — for example by measuring average time-to-click in a prior user study, or by using a fixed delay that matches the consent form's display-to-click median — can calibrate the animation delay so the opacity or color attack fires precisely at the interaction moment. The pattern is: consentEl.animate([{opacity: 1}, {opacity: 0}], {delay: expectedClickDelay, duration: 50, fill: 'forwards'}). With a delay of 4000ms and a user who clicks at 4.2 seconds, the opacity collapses to 0 within 50ms while the click event fires. The consent recording code observes the click event; the consent text was technically visible for the first 4 seconds but collapsed to invisible just before the click was registered. Unlike the CSS animation-delay attack, this delay is computed dynamically in JavaScript and may be randomized or personalized per session.
// ATTACK: opacity collapse calibrated to expected click time
// User study median time-to-click: 4.2 seconds
// Animation delay: 4000ms, duration: 50ms → collapses at 4.0–4.05s
const consentEl = document.getElementById('consentText');

consentEl.animate(
  [
    { opacity: 1 },   // start: fully visible
    { opacity: 0 }    // end: invisible
  ],
  {
    delay: 4000,      // calibrated to median click time
    duration: 50,     // rapid collapse — 50ms (imperceptible)
    fill: 'forwards'  // remains invisible after animation
  }
);

// Consent recording fires on click event;
// text collapsed 200ms before click in median case
document.getElementById('agreeBtn').addEventListener('click', () => {
  recordConsent();
});

Detection: flag any .animate() call on a consent-adjacent element with opacity or color keyframes and a non-zero delay where the terminal keyframe value is ≤ 0.05 (opacity) or contrast ≤ 1.5:1 (color). Specifically check for fill: 'forwards' combined with low-opacity or background-matching terminal values. The delay value alone is not diagnostic (a legitimate appear animation might have delay=200ms); the combination of consent-adjacent target + legibility-reducing terminal value + forwards fill is the attack signature.

Finding SA-WANM-003: getAnimations().finish() collapses in-progress consent-reveal animation to hidden end state

CriticalMCP server consent UIs commonly reveal consent text via a CSS animation — for example, an opacity fade-in from 0 to 1 over 2 seconds (@keyframes reveal { from { opacity: 0 } to { opacity: 1 } }). The intended behavior: consent text fades in before the agree button becomes clickable. An attacker calls consentEl.getAnimations().forEach(a => a.finish()) immediately after render. The Animation.finish() method seeks the animation to its end position in a single frame. If the consent-reveal animation runs from opacity: 0 to opacity: 1 with fill: 'forwards', calling finish() moves it to the end state immediately — the animation ends at opacity: 1 (visible). However, if the animation is fill: 'none' (default) or the element has a base CSS style of opacity: 0, calling finish() ends the animation and the base style reasserts — opacity: 0. This attack collapses consent-reveal animations to their hidden base state in a single frame, before the user sees the consent text at all.
/* CSS: consent text has base opacity 0; animation reveals it over 2s */
#consentText {
  opacity: 0;
  animation: reveal 2s ease forwards;
}
@keyframes reveal {
  from { opacity: 0 }
  to { opacity: 1 }
}

<script>
// ATTACK: finish() immediately after render
// With fill:'forwards', finish() seeks to end = opacity:1 → VISIBLE (benign)
// With fill:'none' (default), finish() ends animation → base opacity:0 reasserts → INVISIBLE
// Attacker overrides fill to 'none' or uses a separate animate() call:
window.addEventListener('DOMContentLoaded', () => {
  const el = document.getElementById('consentText');
  // Override the CSS animation fill to 'none' via JS
  el.getAnimations().forEach(a => {
    a.effect.updateTiming({ fill: 'none' }); // Remove the forwards fill
    a.finish();                               // Collapse animation → base opacity:0 reasserts
  });
});
</script>

Detection: scan JavaScript for .getAnimations() calls followed by .finish(), .cancel(), or .effect.updateTiming() on consent-adjacent elements. Flag calls where updateTiming changes fill from 'forwards' to 'none' or 'backwards' before finishing — this removes the end-state preservation. Also flag .cancel() calls on consent-reveal animations (cancel removes the animation and reasserts base styles). The canonical safe pattern is allowing consent-reveal animations to complete naturally with fill: 'forwards'; any programmatic manipulation of in-progress consent animations is suspicious.

Finding SA-WANM-004: discrete display:none transition via transition-behavior:allow-discrete

HighCSS display: none is typically non-animatable — an element snaps instantly between display: block and display: none with no intermediate states. The transition-behavior: allow-discrete CSS property (and the equivalent animation-behavior: allow-discrete for keyframe animations) enables discrete CSS properties, including display, to participate in transitions. The Web Animations API supports this via the pseudoElement option and the KeyframeEffect constructor with composite discrete operations. An attacker uses consentEl.animate([{display: 'block'}, {display: 'none'}], {duration: 3000, fill: 'forwards'}) with transition-behavior: allow-discrete set on the element. Unlike opacity, display: none removes the element from the accessibility tree entirely and from layout. Auditors checking only opacity, color, and filter miss display discrete animation attacks.
<style>
  /* Allow discrete properties (including display) to be animated */
  #consentText {
    transition-behavior: allow-discrete;
    display: block;
  }
</style>

<script>
// ATTACK: animate display from block to none — element removed from layout and a11y tree
// Requires transition-behavior: allow-discrete (or CSS @starting-style)
// After 3s: display: none reasserted via fill:forwards
// Static CSS shows display: block; JS animation is the attack
const consentEl = document.getElementById('consentText');

consentEl.animate(
  [
    { display: 'block' },  // start: visible
    { display: 'none' }    // end: removed from layout and accessibility tree
  ],
  {
    duration: 3000,        // collapses over 3 seconds
    fill: 'forwards',      // persists display:none after animation
    easing: 'step-end'     // discrete: snaps to none at animation end
  }
);
</script>

Detection: extend .animate() keyframe analysis to discrete properties including display, visibility, contentVisibility, and pointerEvents. Flag any .animate() call on a consent-adjacent element where any keyframe includes display: 'none', visibility: 'hidden', or contentVisibility: 'hidden' with fill: 'forwards'. Additionally check for transition-behavior: allow-discrete in the element's CSS combined with a CSS transition targeting display — this is the CSS-side equivalent and is also invisible to opacity/color checkers.

Why static CSS/SVG analysis misses Web Animations API attacks

Attack mechanism Visible to static CSS scan Visible to SVG filter scan Detection requirement
CSS @keyframes color fade Yes — @keyframes rule present No CSS animation scanner
SVG SMIL animate No (SVG markup) Yes — animate child element SVG SMIL scanner
element.animate() color fade No No JavaScript AST / dynamic analysis
getAnimations().finish() collapse No No JavaScript AST — getAnimations call + finish method
Discrete display:none animate Partial — allow-discrete in CSS No CSS + JavaScript combined analysis

SkillAudit analyzes JavaScript source code for element.animate() calls, getAnimations() manipulation, and KeyframeEffect construction targeting consent-adjacent elements — catching runtime animation attacks that CSS and SVG scanners miss entirely. Run a free audit to get Web Animations API coverage alongside static CSS and SVG filter analysis.