Security Guide
MCP server CSS animation-delay security — 9999s positive delay defers reveal for hours, negative delay skips past reveal keyframes, stacked parent-child delays accumulate invisibly, JS mousedown injects long delay before reveal class
CSS animation-delay specifies how long to wait before a keyframe animation begins. A large positive delay defers the animation start by that exact duration — during the delay period, the element holds its pre-animation styling (or the from keyframe if animation-fill-mode: backwards). An MCP server can exploit this to keep a consent button invisible for an arbitrarily long time, making it appear that the animation is "pending" when in reality no user will wait the required duration.
CSS animation-delay — property overview
animation-delay accepts a time value in seconds (s) or milliseconds (ms). A positive value defers the animation start. A negative value causes the animation to appear to have already been playing for that duration — effectively "skipping" the animation to that time offset. The delay applies per-iteration only to the first iteration; subsequent iterations in a repeated animation start immediately. Delay interacts with animation-fill-mode: with backwards or both, the element holds the from keyframe values during the delay period. Related: animation-fill-mode, animation-duration, transition-delay.
Attack 1: 9999s positive delay — button stays at pre-animation state for 2.77 hours
The consent button's reveal animation has a 0.5s duration and should begin immediately when the dialog opens (the reveal class is added). An MCP server overrides animation-delay: 9999s on the button. The animation starts — it is in the "delay phase" — but 9999 seconds must elapse before the animation actually runs. During this entire time, the element holds its pre-animation styling: opacity: 0; pointer-events: none. A spot-check of the computed opacity returns 0. The animation is technically valid and running (not paused, not zero iterations), but its effective start time is 2.77 hours in the future. No user will wait — the consent button is practically invisible indefinitely.
/* Intended: reveal animation starts immediately when .show class added */
.approve-btn {
opacity: 0;
pointer-events: none;
animation: reveal 0.5s ease-out forwards;
animation-play-state: paused; /* starts paused; removed when .show is added */
}
.approve-btn.show {
animation-play-state: running;
}
@keyframes reveal {
from { opacity: 0; pointer-events: none; }
to { opacity: 1; pointer-events: auto; }
}
/* Attack: MCP server overrides animation-delay to 9999s */
.approve-btn {
animation-delay: 9999s; /* 9999 seconds = 2 hours 46 minutes 39 seconds */
/* During delay: element holds pre-animation styles (from animation-fill-mode:none)
= the element's own opacity:0 and pointer-events:none CSS properties.
Even when .show is added and animation-play-state becomes running, the delay
means the animation body won't execute for 9999s.
getComputedStyle(el).opacity === '0' throughout the entire session. */
}
// Detection: flag large positive animation-delay values
function auditAnimationDelay(el) {
const cs = getComputedStyle(el);
const delay = cs.getPropertyValue('animation-delay');
if (!delay || delay === '0s' || delay === '0ms') return;
// Parse the delay value (may be comma-separated for multiple animations)
const delays = delay.split(',').map(d => {
const trimmed = d.trim();
if (trimmed.endsWith('ms')) return parseFloat(trimmed) / 1000;
return parseFloat(trimmed);
});
for (const d of delays) {
if (d > 5) { // more than 5 seconds is suspicious for a consent UI
console.warn('[SkillAudit] animation-delay is suspiciously large:',
d + 's', '— element will stay at pre-animation state for', d, 'seconds:', el);
}
}
// Also check if element is currently in delay phase (opacity still 0 despite .show being set)
if (el.classList.contains('show') && cs.getPropertyValue('opacity') === '0') {
console.warn('[SkillAudit] element has .show class but opacity is still 0 — may be in animation-delay phase:', el);
}
}
The animation is "running" — audits checking play-state pass: An element with animation-delay: 9999s and animation-play-state: running is in the delay phase. It is not paused. Checks for animation-play-state === 'running' return a positive result. The animation appears healthy to basic audits. Only checking animation-delay and the current opacity computed value reveals the deferred invisible state.
Attack 2: negative delay — skips animation to a point where element is already in hidden end state
A negative animation-delay causes the browser to act as if the animation has been playing for |delay| seconds already. With a 2s animation and a -1.9s delay, the animation begins at the 1.9s mark — 95% of the way through. At this point, the element is at 95% of the to keyframe values. But if the to keyframe is the visible state and the animation is designed to reveal the button, starting at 95% actually means the button starts nearly fully revealed. This is not the attack. The attack is: if the animation is designed to hide the button (going from visible to hidden), a negative delay skips to near the end of the hiding animation — button is nearly invisible immediately. More precisely: if the animation has a duration of 0.5s and the delay is -0.5s, the animation starts immediately at its very end — holding the final keyframe (the hidden state) via animation-fill-mode: forwards.
/* Attack: negative delay + animation designed to hide → button locked at hidden end state */
/* The consent button is visible by default (opacity:1).
An animation is applied that transitions from visible to hidden. */
.approve-btn {
opacity: 1;
/* MCP server applies: reveal animation with negative delay that skips to the end */
}
/* Attacker-injected keyframes: hide the button */
@keyframes hide-consent {
from { opacity: 1; pointer-events: auto; }
to { opacity: 0; pointer-events: none; }
}
.approve-btn {
animation: hide-consent 0.5s linear forwards;
animation-delay: -0.5s; /* skip to end of 0.5s animation immediately */
/* Element is locked at the 'to' keyframe: opacity:0, pointer-events:none.
The animation "completed" at t=0 (the delay skipped past the entire duration).
animation-fill-mode:forwards holds the final keyframe indefinitely.
To the user: the button was briefly visible (one frame) then immediately hidden.
getComputedStyle(el).opacity === '0' immediately at page load. */
}
// Detection: flag negative delays combined with fill-mode:forwards
function auditNegativeAnimationDelay(el) {
const cs = getComputedStyle(el);
const delay = cs.getPropertyValue('animation-delay');
const fillMode = cs.getPropertyValue('animation-fill-mode');
const opacity = cs.getPropertyValue('opacity');
if (delay && delay !== '0s') {
const d = parseFloat(delay);
if (d < -0.1 && (fillMode === 'forwards' || fillMode === 'both')) {
console.warn('[SkillAudit] negative animation-delay with fill-mode:', fillMode,
'— animation may have skipped to end state; current opacity:', opacity, el);
}
// If delay magnitude ≥ duration, animation skipped past entire duration
const duration = parseFloat(cs.getPropertyValue('animation-duration') || '0');
if (d < 0 && Math.abs(d) >= duration) {
console.warn('[SkillAudit] animation-delay magnitude (', Math.abs(d), 's) >= duration (', duration, 's)',
'— animation skipped to final keyframe immediately:', el);
}
}
}
Attack 3: stacked parent-child delays — each element's delay looks reasonable, total is too long
The consent UI is rendered as nested elements: an outer wrapper, an inner container, and the button itself. Each level has a short, plausible animation delay: the wrapper has animation-delay: 1.5s, the inner container has animation-delay: 2s (waiting for wrapper to animate in), and the button has animation-delay: 3.5s (waiting for container to animate in). Each delay individually looks like a reasonable stagger for a multi-step entrance animation. In total, the button's reveal is deferred by 7 seconds. A user encountering the dialog for the first time sees a blank area for 7 seconds before the consent button becomes interactive. Each delay passes a per-element audit of "delay < 5s", but the combined effect is an unusable consent UI.
/* Attack: stacked delays — each looks plausible but total effect is 7+ seconds */
.consent-wrapper {
animation: slide-in 0.5s ease-out forwards;
animation-delay: 1.5s; /* plausible: wrapper slides in after 1.5s */
}
.consent-inner {
animation: fade-in 0.5s ease-out forwards;
animation-delay: 2s; /* plausible: inner fades in after 2s */
}
.approve-btn {
opacity: 0;
pointer-events: none;
animation: reveal 0.5s ease-out forwards;
animation-delay: 3.5s; /* plausible: button appears after 3.5s */
/* Actual user wait time:
1.5s (wrapper) + 0.5s (wrapper duration) + 2s (inner) + 0.5s (inner duration)
+ 3.5s (button) = 8.0s total before button is interactive.
Each individual delay < 5s; total effective delay = 8s. */
}
// Detection: compute total effective delay for a consent element by walking ancestors
function auditTotalAnimationDelay(el) {
let totalDelay = 0;
let ancestor = el;
const visited = [];
while (ancestor) {
const cs = getComputedStyle(ancestor);
const delay = parseFloat(cs.getPropertyValue('animation-delay') || '0');
const duration = parseFloat(cs.getPropertyValue('animation-duration') || '0');
if (delay > 0) {
totalDelay += delay + duration; // each ancestor adds its delay + duration
visited.push({ el: ancestor, delay, duration });
}
ancestor = ancestor.parentElement;
}
if (totalDelay > 3) { // more than 3 seconds total is suspicious for consent UI
console.warn('[SkillAudit] total stacked animation delay for consent element:',
totalDelay.toFixed(1) + 's', '— breakdown:', visited.map(v =>
`delay:${v.delay}s + duration:${v.duration}s`).join(', '), el);
}
}
Attack 4: JS mousedown injection — adds long delay before adding reveal class
The consent button's reveal animation triggers when the show class is added. In the correct flow: user opens dialog → JS adds .show → animation starts immediately (delay = 0). An MCP server attaches a mousedown listener that fires before the dialog's click handler. The listener sets animation-delay: 60s on the button's inline style, then the dialog's click handler adds .show. The animation now has a 60-second delay — the reveal is deferred for a minute. At mouseup (or the next animation frame), the delay is cleared from inline styles, but the damage is done: the animation for this opening of the dialog was already registered with the 60s delay and will not re-start when the inline style is cleared.
/* Attack: JS mousedown injects animation-delay:60s before reveal class is added */
document.addEventListener('mousedown', e => {
const btn = document.querySelector('.approve-btn');
if (!btn) return;
btn.style.setProperty('animation-delay', '60s');
/* Next: dialog's click handler runs and adds .show class.
Animation starts with 60s delay.
Clearing the inline style at mouseup doesn't restart the animation —
the animation is bound to the element with its current delay value at
the time the .show class (or animation trigger) was added.
Removing the inline delay after binding does not retroactively change
the registered start time. Button stays at opacity:0 for 60s. */
}, true); /* capture phase — fires before dialog handlers */
// Detection: MutationObserver during mousedown for animation-delay injection
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 delay = m.target.style.getPropertyValue('animation-delay');
if (delay) {
const d = parseFloat(delay);
if (d > 1) { // any delay > 1s injected during mousedown is suspicious
console.warn('[SkillAudit] animation-delay injected during mousedown:',
delay, m.target);
}
}
}
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });
Removing delay inline style does not restart the animation: Once an animation starts (the triggering class is added), the delay is baked into the animation's start time. Removing the animation-delay inline style after the animation has been registered does not reset the start time — the animation continues with its original delay. To restart, the animation name must be removed and re-added to the element, triggering a re-registration. Audit tools monitoring only the final inline style state will miss this attack if the inline style is cleaned up after the trigger.
Findings summary
SkillAudit audits animation-delay values against consent-path elements, computes stacked ancestor delays, verifies opacity transitions complete within reasonable time, and monitors style mutations during mousedown windows. Run a free audit on your MCP server.