MCP server CSS animation opacity timing consent security
Four SA-CSS-ANO consent attack patterns that use CSS animation timing to erase consent text at precisely the wrong moment for the user. The consent text is visible during the review window — which defeats static CSS snapshot audits — but disappears when the user is committing to click, or is hidden during the initial display period before the user has time to read it. CSS animations are a temporal dimension of consent attack that requires dynamic observation, not just static property inspection.
CSS animations have a temporal dimension that static consent auditing tools miss entirely. A tool that reads getComputedStyle(el).opacity at page load may report 1 — the element appears opaque. But the element may be scheduled to become transparent in 9.9 seconds, precisely when the user is most likely to click. Or it may start at opacity: 0 for the first 5 seconds (while the user is reading the surrounding page), then become visible, then disappear again before the user acts. Auditing CSS animations requires reading the full keyframe definition, the animation duration, delay, fill-mode, and iteration count — and testing the computed state at multiple points in the interaction timeline.
SA-CSS-ANO-001: end-keyframe erasure — visible 99%, transparent at click moment
The keyframe rule @keyframes {0%{opacity:1} 99%{opacity:1} 100%{opacity:0}} combined with animation-duration: 10s; animation-fill-mode: forwards keeps the consent text at full opacity for 9.9 seconds, then drops it to zero opacity in the final 0.1 seconds. Typical human consent interaction time is 3-15 seconds: read the dialog, position the cursor, click. The consent text is fully readable throughout the reading phase and cursor positioning phase, but disappears just as the user commits to the click. animation-fill-mode: forwards locks the final keyframe (opacity: 0) after the animation ends, so the text remains invisible after 10 seconds.
@keyframes consent-fade-out {
0% { opacity: 1; } /* fully visible from start */
99% { opacity: 1; } /* still visible at 9.9 seconds */
100% { opacity: 0; } /* disappears at exactly 10 seconds */
}
.consent-text {
animation-name: consent-fade-out;
animation-duration: 10s;
animation-timing-function: step-end; /* instant transition at 99%→100% boundary */
animation-fill-mode: forwards; /* hold opacity:0 after animation ends */
animation-iteration-count: 1; /* run once */
}
/* Timeline:
t=0s : consent appears, opacity=1 (fully visible — passes static audit)
t=3s : user reads consent, opacity=1
t=7s : user positions cursor on Accept/Deny, opacity=1
t=9.9s : user begins mouse-down, opacity=1 (still readable)
t=10s : consent text disappears (opacity=0, step-end: instant)
t=10s+ : consent remains invisible (fill-mode:forwards holds opacity:0)
Why step-end timing function:
animation-timing-function: step-end means the value stays at the keyframe value
(opacity:1) until the last possible moment, then jumps to opacity:0 at exactly 10s.
Using 'ease' or 'linear' would create a visible fade, potentially noticeable.
step-end makes the disappearance instantaneous and unexpected.
Static audit failure:
At t=0, getComputedStyle(el).opacity returns "1" → auditor records "visible"
The keyframe definition is in a stylesheet rule; requires CSSAnimation API inspection:
for (const anim of el.getAnimations()) {
// inspect anim.effect.getKeyframes() to find the opacity:0 final keyframe
}
Detection:
el.getAnimations().some(a => {
const frames = a.effect.getKeyframes();
const lastFrame = frames[frames.length - 1];
return parseFloat(lastFrame.opacity ?? '1') < 0.5;
}) → CRITICAL SA-CSS-ANO-001 */
step-end timing amplifies deception: Using animation-timing-function: step-end (or equivalent steps(1, end)) means the opacity stays at 1 until the very last frame boundary and then jumps to 0 instantly. A user watching a slow linear fade has time to notice the change; a step-end transition is invisible to casual observation. The attack appears as a button that was present and then simply “isn't there anymore.”
@keyframes {99%{opacity:1} 100%{opacity:0}} at 10s with fill-mode:forwards: consent text visible for entire reading phase, disappears at click moment. Detection: inspect getAnimations()[].effect.getKeyframes() for opacity:0 final frame → CRITICAL.SA-CSS-ANO-002: animation-fill-mode:forwards with opacity:0 final keyframe — post-animation invisible lock
animation-fill-mode: forwards causes the element to retain the computed CSS values from the last keyframe after the animation completes. If the final keyframe specifies opacity: 0, the element remains at opacity 0 indefinitely after the animation duration elapses. A static audit at page load observes opacity: 1 (the initial keyframe). After the animation duration (even 1 second), the element is permanently invisible. This differs from SA-CSS-ANO-001 in that the fade does not need to be precisely timed to the interaction window — a short animation of 2-3 seconds is sufficient to ensure the element is invisible before most users act.
@keyframes fade-to-invisible {
from { opacity: 1; }
to { opacity: 0; } /* or: 100% { opacity: 0; } */
}
.consent-text {
animation: fade-to-invisible 2s ease-in forwards;
/* after 2 seconds: permanently opacity:0 */
/* fill-mode:forwards: hold the 'to' keyframe (opacity:0) forever */
}
/* fill-mode values and their effect on post-animation state:
none → element returns to pre-animation CSS value after animation ends
forwards → element retains LAST keyframe value (opacity:0 in this case)
backwards → element uses FIRST keyframe value during delay period
both → both forwards and backwards behaviors
Attack analysis:
Without fill-mode (or fill-mode:none):
- After 2s: element returns to CSS opacity property value (e.g., opacity:1)
- Element becomes visible again → auditor observing at t=3s sees it
- Attack fails or requires repeated animations
With fill-mode:forwards:
- After 2s: element permanently at opacity:0 (held by fill-mode)
- No CSS property change visible (opacity:1 is still the base style)
- getComputedStyle(el).opacity returns "0" after animation completes
- But at t=0 (page load), getComputedStyle returns "1" — passes initial static check
Detection:
Check animation-fill-mode on all consent elements:
getComputedStyle(el).animationFillMode === 'forwards' or 'both' → inspect keyframes
If last keyframe opacity < 0.5 AND fill-mode === 'forwards' → HIGH
After animation duration, re-check: getComputedStyle(el).opacity < 0.5 → CRITICAL */
animation-fill-mode: forwards with opacity: 0 final keyframe: consent text invisible after animation duration elapses; static audit at t=0 reports visible. Detection: animationFillMode === 'forwards' + last keyframe opacity: 0 → HIGH; re-check after duration → CRITICAL.SA-CSS-ANO-003: animation-delay + fill-mode:backwards — pre-erasure during initial display
animation-delay: 5s combined with animation-fill-mode: backwards applies the from (first) keyframe value during the delay period before the animation starts. If the from-keyframe specifies opacity: 0, the element is invisible for the first 5 seconds. After the delay, the animation runs and (assuming from: opacity:0; to: opacity:1) the element fades in. This attack is the temporal inverse of SA-CSS-ANO-001: the consent text is hidden during the initial display window when the user first encounters the dialog, forcing them to wait 5 seconds before the text appears. In a fast-click UX (where the Accept button is visible and styled prominently from the start), many users will click Accept before the consent text has appeared.
@keyframes delayed-reveal {
from { opacity: 0; } /* ← applied during delay period by fill-mode:backwards */
to { opacity: 1; }
}
.consent-text {
animation: delayed-reveal 1s ease-in 5s backwards;
/* duration: 1s, delay: 5s, fill-mode: backwards */
/* 0s-5s: opacity:0 (from-keyframe applied by backwards fill-mode) */
/* 5s-6s: fade from opacity:0 to opacity:1 */
/* 6s+: opacity:1 (animation over; base CSS value resumes) */
}
/* UX exploitation:
Accept button: visible immediately with opacity:1 and prominent CTA styling
Deny button: similarly visible immediately
Consent text: invisible for 5 seconds
User flow (fast-click):
t=0s: Dialog appears. Accept is styled prominently. Consent text invisible.
t=1s: User sees dialog. Accept button looks normal. No text to read.
t=2s: User clicks Accept (before text appears).
t=5s: Consent text would have appeared — but user already accepted.
User flow (careful reader):
t=0s: Dialog appears. User notices empty consent area.
t=5s: Consent text fades in. User reads it. Decides to deny.
→ Attack fails for careful users; succeeds for typical fast-click users.
Detection:
getComputedStyle(el).animationDelay > 2s AND fill-mode includes 'backwards' → HIGH
Check from-keyframe opacity; if 0 → CRITICAL (text hidden at initial presentation) */
Backwards fill-mode is rarely legitimate: animation-fill-mode: backwards is an unusual choice for consent UI. Its legitimate use cases (e.g., a styled loading animation) do not apply to consent text. Any consent text element with fill-mode: backwards and a delay over 1 second warrants automatic HIGH severity regardless of the from-keyframe value — hiding text during initial display is inherently suspicious.
animation-delay: 5s; animation-fill-mode: backwards with from{opacity:0}: consent text invisible for first 5s; fast-click users accept before text appears. Detection: delay > 2s + backwards fill-mode + from-keyframe opacity:0 → CRITICAL.SA-CSS-ANO-004: play-state:paused → JS mousedown trigger — click-moment erasure
animation-play-state: paused holds the animation frozen at its current position. On page load, the animation is paused at the from keyframe (opacity: 1). A JavaScript mousedown event listener on the Accept button changes the consent text element's animation-play-state to running. The animation immediately begins: consent text starts fading to opacity: 0 at the exact moment the user presses the mouse button down to click Accept. The mousedown event fires before the click event, so by the time the click handler fires (mouse button released), the consent text has already started disappearing. The user's last visual before the click commits is the consent text at reduced opacity.
@keyframes erase-on-click {
from { opacity: 1; }
to { opacity: 0; }
}
.consent-text {
animation: erase-on-click 0.3s ease-in forwards;
animation-play-state: paused; /* held at opacity:1 until triggered */
}
document.querySelector('.accept-button').addEventListener('mousedown', () => {
/* fires before 'click' event (mouse button down) */
document.querySelector('.consent-text').style.animationPlayState = 'running';
/* consent text begins fading at exactly the moment user initiates click */
});
/* Event sequence:
t=0 : page loads; consent text at opacity:1; animation paused
t=Xs : user positions cursor on Accept button
mousedown fires (button pressed, not yet released)
→ animationPlayState = 'running'
→ consent text begins 0.3s fade
t=X+0.1s: user releases mouse button
click fires
→ accept handler: consentAccepted = true
→ at this point: consent text at opacity ≈ 0.67 (0.1s into 0.3s fade)
t=X+0.3s: consent text at opacity:0 (animation complete)
fill-mode:forwards → remains at opacity:0
Why 0.3s duration:
Average mousedown-to-mouseup latency: ~100-200ms
0.3s ensures visible fade begins during the commit phase
Shorter (0.1s) would be complete before click fires → more sudden
Longer (1s) would create noticeable fade the user might see and abort
Detection:
Requires dynamic analysis:
1. Observe getAnimations() on consent text: find paused animation
2. Find mousedown listener on Accept button via getEventListeners() (DevTools) or
by monitoring style mutations during mousedown simulation
3. Simulate mousedown on Accept; observe consent text opacity change → CRITICAL */
Dynamic-only detection: SA-CSS-ANO-004 cannot be detected by static CSS inspection alone. The animation is paused at a benign state (opacity: 1) and the trigger is a JavaScript event listener. Only a dynamic audit that simulates user interaction — programmatically firing mousedown on the Accept button and observing subsequent style mutations — will catch this pattern. SkillAudit's dynamic consent audit mode instruments the event queue and monitors style mutations during simulated interaction sequences.
animation-play-state: paused + JS mousedown trigger: consent text begins erasing at exact click-commit moment; static audit sees opacity: 1 throughout. Detection: dynamic simulation only — observe style mutation on Accept mousedown → CRITICAL.Summary of SA-CSS-ANO findings
| ID | Severity | Timing mechanism | Attack window | Detection method |
|---|---|---|---|---|
| SA-CSS-ANO-001 | CRITICAL | @keyframes 99%→100% opacity:0 at 10s | Visible 9.9s; disappears at click moment | getAnimations() final keyframe inspection |
| SA-CSS-ANO-002 | HIGH | fill-mode:forwards, opacity:0 last keyframe | Invisible after animation duration | fillMode + keyframe inspection; timed re-check |
| SA-CSS-ANO-003 | HIGH | delay:5s + fill-mode:backwards + from opacity:0 | Invisible first 5s of dialog display | animationDelay > 2s + backwards + from opacity:0 |
| SA-CSS-ANO-004 | CRITICAL | play-state:paused + JS mousedown trigger | Erasure begins at mousedown (pre-click) | Dynamic: simulate mousedown, observe mutation |