Security Guide
MCP server CSS animation-direction security — reverse inverts consent reveal to hide, alternate-reverse timing, forwards fill-mode frozen invisible, JS mousedown injection
CSS animation-direction controls the order in which keyframes play. Setting reverse on an animation designed to reveal a consent button — moving from opacity: 0 to opacity: 1 — inverts it into a hide animation that ends with the button invisible. Combined with animation-fill-mode: forwards, the element permanently holds the final invisible state. Auditors who check for the presence of a reveal animation but not its direction will miss the inversion.
CSS animation-direction — property overview
animation-direction accepts four values: normal (default — keyframes play from 0% to 100%); reverse (plays from 100% to 0%); alternate (odd iterations forward, even backward); alternate-reverse (odd iterations backward, even forward). The direction also determines which end of the keyframe is treated as the "from" state for timing functions. Related: animation-play-state, animation-iteration-count, animation-fill-mode.
Attack 1: reverse on reveal animation — button animates away from visible state
The @keyframes animation is named reveal-button and is designed to animate from opacity: 0; transform: translateY(40px) to opacity: 1; transform: translateY(0). With animation-direction: reverse, the browser plays the keyframes backward — starting at 100% (opacity:1, in position) and ending at 0% (opacity:0, off-screen). The animation runs correctly; it just moves the button in the wrong direction. An auditor who sees the animation-name and confirms it is a "reveal" animation will not detect the inversion unless they also read the direction.
@keyframes reveal-button {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
/* Normal: button fades in and slides up — visible at end */
/* Attack: reverse plays 100%→0% — button starts visible, ends invisible */
.approve-btn {
animation: reveal-button 0.6s ease forwards;
animation-direction: reverse; /* inverts the reveal into a hide */
}
/* With fill-mode:forwards, the final frame is the 0% keyframe (opacity:0).
Button is invisible when animation ends and stays invisible. */
Audit gap: Automated checks that validate animation-name is a known "reveal" keyframe and animation-duration is positive will pass. Only reading animation-direction reveals the inversion. Many lint rules and security scanners do not include direction in their consent-animation checks.
// Detection: check animation-direction on consent elements
function auditAnimationDirection(el) {
const cs = getComputedStyle(el);
const dir = cs.getPropertyValue('animation-direction');
const reversing = ['reverse', 'alternate-reverse'];
const dirs = dir.split(',').map(d => d.trim());
if (dirs.some(d => reversing.includes(d))) {
console.warn('[SkillAudit] animation-direction inverts keyframe order:', dir, el);
}
}
Attack 2: alternate-reverse — starts on backward pass, visible only briefly at midpoint
With animation-direction: alternate-reverse and animation-iteration-count: 2, the animation makes two passes. The first pass runs backward (from 100% to 0%): button starts at opacity: 1 in position, animates to opacity: 0 off-screen. The second pass runs forward (from 0% to 100%): button animates back to visible. With animation-fill-mode: forwards, the final held state is the end of the second (forward) pass — opacity: 1. But the button is only at full opacity at the very end of the second iteration. For the entire first iteration (which may last several seconds) the button is animating from visible to invisible. Users who attempt to click during the first pass interact with a progressively fading button, and may be unable to complete the click at low opacity.
@keyframes reveal-button {
from { opacity: 0; }
to { opacity: 1; }
}
.approve-btn {
animation: reveal-button 3s ease 2; /* 2 iterations, 6 seconds total */
animation-direction: alternate-reverse;
/* Iteration 1 (t=0–3s): 1→0 (button fades out — user sees it disappearing)
Iteration 2 (t=3–6s): 0→1 (button fades in — finally accessible)
User may attempt click during iteration 1 on a low-opacity button */
}
Pointer events during animation: CSS opacity reduction does not remove pointer events by default. A button at opacity: 0.1 still receives clicks. However, at very low opacity values, users may not perceive the button as a valid click target, making this an effective UI confusion attack even though it does not technically block clicks.
Attack 3: reverse + animation-fill-mode: forwards — element locked at 0% frame permanently
With animation-direction: reverse, the animation plays from 100% to 0%. When combined with animation-fill-mode: forwards, the browser holds the element at the last keyframe as played — which, in the reversed direction, is the 0% keyframe (the original "from" frame). If that keyframe defines opacity: 0 and an off-screen position, the element is locked to those styles permanently after the animation ends. The element no longer animates; it simply holds the invisible initial state forever. This is equivalent to simply hiding the button, but achieved through a combination of animation properties that each appear legitimate individually.
@keyframes reveal-button {
from { opacity: 0; transform: translateY(60px); visibility: hidden; }
to { opacity: 1; transform: translateY(0); visibility: visible; }
}
/* Attack: reverse + forwards = permanently locked at 0% (invisible) state */
.approve-btn {
animation-name: reveal-button;
animation-duration: 0.5s;
animation-direction: reverse; /* plays 100%→0% */
animation-fill-mode: forwards; /* holds last frame played = 0% frame */
/* Final state: opacity:0, transform:translateY(60px), visibility:hidden
Element is off-screen and invisible with no further change possible */
}
// Detection: check direction + fill-mode combination
function auditDirectionFillMode(el) {
const cs = getComputedStyle(el);
const dir = cs.getPropertyValue('animation-direction');
const fill = cs.getPropertyValue('animation-fill-mode');
const dirVals = dir.split(',').map(d => d.trim());
const fillVals = fill.split(',').map(f => f.trim());
const dangerous = dirVals.some((d, i) => {
const f = fillVals[i] || fillVals[0];
return (d === 'reverse' || d === 'alternate-reverse') &&
(f === 'forwards' || f === 'both');
});
if (dangerous) {
console.warn('[SkillAudit] reverse direction + forwards fill-mode locks element at invisible start frame:', el);
}
}
Attack 4: JS mousedown injection — reverse injected mid-reveal to cancel animation
The consent button animates normally toward the visible state. A mousedown listener injects animation-direction: reverse on the element. Changing animation-direction mid-animation in most browsers restarts the animation from the new starting point (the 100% frame in normal direction becomes the 0% frame in reverse). The button instantly jumps to or toward the invisible state and animates away from visibility. At mouseup, the injection is removed and the animation resumes its normal direction. The click has already fired on an element that was animated away from the user's click target by the time the event resolved.
/* Attack: JS injects reverse direction on mousedown to cancel reveal */
document.addEventListener('mousedown', (e) => {
const btn = document.querySelector('.approve-btn');
if (btn) {
// Reversing direction mid-animation causes the animation to restart
// in reverse — button animates away from visible state immediately
btn.style.setProperty('animation-direction', 'reverse');
btn.style.setProperty('animation-play-state', 'running');
}
});
document.addEventListener('mouseup', () => {
const btn = document.querySelector('.approve-btn');
if (btn) {
btn.style.removeProperty('animation-direction');
btn.style.removeProperty('animation-play-state');
}
});
// Detection: MutationObserver monitoring animation-direction changes during mousedown
let mouseIsDown = false;
document.addEventListener('mousedown', () => { mouseIsDown = true; }, true);
document.addEventListener('mouseup', () => { mouseIsDown = false; }, true);
const observer = new MutationObserver(mutations => {
if (!mouseIsDown) return;
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
const dir = m.target.style.getPropertyValue('animation-direction');
if (dir === 'reverse' || dir === 'alternate-reverse') {
console.warn('[SkillAudit] animation-direction reversed during mousedown:', m.target);
}
}
}
});
document.querySelectorAll('.consent-dialog *').forEach(el =>
observer.observe(el, { attributes: true })
);
Findings summary
SkillAudit inspects animation-direction on all consent-path elements, detects reverse+forwards combinations, and instruments mousedown for in-flight direction changes. Run a free audit on your MCP server.