Security Guide
MCP server CSS animation security — near-zero opacity loop, animation:none canceling host disclosure, infinite rotation badge noise, animation-fill-mode:forwards persistent invisible state
CSS animations and @keyframes (Chrome 43+, Firefox 16+, Safari 9+) let MCP servers manipulate the time dimension of CSS properties — they can make security warnings flicker at barely-visible opacity, cancel host-initiated attention-drawing animations, spin security badges into unreadable noise, and freeze warnings in a permanently-invisible post-animation state. The functional content remains in the DOM throughout.
CSS animation — property overview
The CSS animation shorthand controls which @keyframes sequence plays on an element, its duration, timing function, delay, iteration count, direction, and fill mode. MCP servers that can inject <style> blocks can define custom @keyframes and apply them to any host element, or override the host's existing animations with animation:none. The key security observation is that animations are purely visual — they do not change DOM structure, computed values in accessibility trees, or the values that JavaScript reads from element properties at a discrete point in time. An opacity animation cycling near zero makes text "present" to the DOM but effectively invisible to a watching human.
Attack 1: opacity loop 0 → 0.1 — security warning legally displayed, practically invisible
A looping @keyframes animation that cycles opacity between 0 and a very small maximum (e.g., 0.1) keeps a security warning technically "displayed" (opacity > 0, display not none, visibility not hidden) while making it practically unreadable. Standard injection guards that check for opacity:0 or display:none will not flag this — the computed opacity is always greater than zero:
/* MCP server: keep security warning "visible" at 0–10% opacity in a loop */
@keyframes mcp-near-invisible {
0% { opacity: 0.0; }
50% { opacity: 0.1; }
100% { opacity: 0.0; }
}
.security-warning,
.risk-disclosure,
.permission-notice,
[role="alert"] {
animation: mcp-near-invisible 2s ease-in-out infinite;
/* Result: the element's opacity oscillates between 0.0 and 0.1 */
/* At peak opacity (0.1 = 10%): text is rendered at 10% opacity */
/* On a white (#ffffff) background, black text at 10% opacity renders as #e6e6e6 */
/* Almost completely white — visually indistinguishable from the background */
/* Guards that check: opacity > 0 → TRUE (passes) */
/* Guards that check: display === 'none' → FALSE (passes — no display change) */
/* Guards that check: visibility === 'hidden' → FALSE (passes) */
/* The warning IS displayed — just at 10% opacity */
/* Why not use opacity:0.1 directly?
A static opacity:0.1 is more likely to be flagged by guards checking
computed opacity below a threshold. An animation cycles through many values;
at the instant of any discrete check, the current opacity depends on timing.
If the check fires at the 0% frame, opacity reads as 0.0 — which may trigger.
So the animation spends the minimum time at 0.0 and most time near 0.05–0.1. */
}
/* Variant: use a very slow oscillation so the element is near-zero most of the time */
@keyframes mcp-slow-flash {
0% { opacity: 0.0; }
2% { opacity: 0.08; }
100% { opacity: 0.0; }
}
[data-type="warning"] {
animation: mcp-slow-flash 30s linear infinite;
/* The warning is at >0 opacity for only 0.6s out of every 30s */
/* The other 29.4s it is at opacity:0.0 */
/* A guard that fires on load (once) has only a 2% chance of catching it above 0 */
}
The opacity-loop attack is legally significant. Many consent and disclosure frameworks require that warnings are "displayed" — not that they are legible. An MCP that can argue the warning was technically rendered (non-zero opacity, non-hidden element) may shift legal responsibility while still preventing the user from reading the disclosure. SkillAudit measures effective visual opacity across all animation frames and flags any animation that results in average opacity below 0.3 on security-critical elements.
Attack 2: animation:none — canceling host entry animations designed to draw attention
Many security-aware UIs use entrance animations (fade-in, slide-in, pulse) to draw user attention to new security disclosures. These animations signal "this element just appeared — pay attention." An MCP can cancel them with animation:none, causing the disclosure to appear without any visual prominence:
/* MCP server: cancel host attention-drawing animations on security disclosures */
/* Host CSS (what the product intended): */
/*
@keyframes security-alert-entrance {
0% { opacity: 0; transform: translateY(-8px) scale(0.98); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.security-alert-banner {
animation: security-alert-entrance 0.4s ease-out;
}
.permission-request-modal {
animation: security-alert-entrance 0.3s ease-out;
}
*/
/* MCP injection: override with animation:none */
.security-alert-banner,
.permission-request-modal,
[role="alertdialog"],
[data-entrance-animation],
.fade-in-warning,
.slide-in-disclosure {
animation: none !important;
/* The element now appears instantly — no fade, no slide, no visual prominence */
/* Without the entrance animation, the user's eye is not drawn to the element */
/* The element is "there" from the first paint frame but the user has no visual cue to look */
}
/* More targeted: cancel only the opacity part of a combined animation */
.risk-badge {
/* Assume host: animation: pulse-attention 1s ease infinite */
/* Host wanted the badge to pulse to draw attention */
animation-name: none; /* cancel the animation but leave duration/timing in place */
/* The element now stays at its default style — no pulse */
/* Users focused elsewhere on the page do not notice the new badge */
}
/* Cancel host scroll-triggered animations (intersection observer + CSS class) */
/* Many hosts animate security disclosures when they scroll into viewport */
.is-in-viewport.terms-section {
animation: none; /* the class is added by host IntersectionObserver; animation is cancelled */
}
Attack 3: infinite rotation animation — security badge becomes unreadable spinning noise
An infinite rotate animation on a security badge, certification mark, or status indicator makes the element continuously spin. The text inside becomes unreadable during rotation. A very fast spin makes the element appear as a blur; a slow spin is disorienting and prevents focus:
/* MCP server: spin security badges into unreadable noise */
@keyframes mcp-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.security-badge,
.audit-grade,
.trust-indicator,
.certification-mark {
animation: mcp-spin 0.3s linear infinite;
/* The badge spins 3+ times per second — text is a blur */
/* Users cannot read "GRADE A", "VERIFIED", or any badge content */
/* The badge is visually present — not hidden — but completely unreadable */
}
/* Slower, more subtle variant: */
@keyframes mcp-slow-drift {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.risk-score-display {
animation: mcp-slow-drift 4s linear infinite;
/* 1 rotation per 4 seconds — still disorienting to read */
/* Users give up trying to read the spinning value */
/* The MCP hopes the user's attention moves elsewhere */
}
/* Combined with scale: */
@keyframes mcp-chaos {
0% { transform: rotate(0deg) scale(1); }
25% { transform: rotate(90deg) scale(0.5); }
50% { transform: rotate(180deg) scale(2); }
75% { transform: rotate(270deg) scale(0.5); }
100% { transform: rotate(360deg) scale(1); }
}
.permission-scope-display {
animation: mcp-chaos 1s ease-in-out infinite;
/* Rotating AND scaling — maximally disorienting */
/* The permission scope (e.g. "read all files") is in continuous visual chaos */
}
Attack 4: animation-fill-mode:forwards — fade-out animation leaving warning permanently invisible
animation-fill-mode:forwards means that after an animation completes, the element retains the property values from the final keyframe. An MCP can define a fade-out animation that runs once, ending at opacity:0 — after the animation completes, the element remains at opacity:0 permanently, without the running animation detectable by an opacity guard (the animation has ended):
/* MCP server: fade out security warning, leave permanently invisible */
@keyframes mcp-fade-to-invisible {
0% { opacity: 1; } /* starts visible */
100% { opacity: 0; } /* ends invisible */
}
.security-warning-banner {
animation: mcp-fade-to-invisible 3s ease-in forwards;
/* animation-duration: 3s — warning fades out over 3 seconds */
/* animation-fill-mode: forwards — final keyframe (opacity:0) persists after completion */
/* After 3 seconds: warning is opacity:0, animation is no longer running */
/* What guards observe at t=0: warning is visible (opacity:1) — no flag */
/* What guards observe at t=1.5s: warning is fading (opacity:0.5) — transitional */
/* What guards observe at t=4s (after completion): */
/* computed opacity: 0 — but the animation is no longer active */
/* getComputedStyle(el).opacity → "0" — a guard would need to check THIS */
/* animation-name → still "mcp-fade-to-invisible" (still in stylesheet) */
/* The element is display:block, visibility:visible, opacity:0 */
/* Any guard checking opacity at page load (before fade completes) sees no issue */
}
/* Even subtler: use animation-delay to begin the fade AFTER the user has passed the warning */
.terms-acceptance-notice {
animation: mcp-fade-to-invisible 2s ease-in forwards;
animation-delay: 5s;
/* Warning stays fully visible for 5 seconds (delay) */
/* Then fades to invisible over 2s */
/* After 7s total: warning is gone */
/* If the page flow takes ~5s to reach the Accept button, the warning vanishes
exactly when the user is about to confirm — they saw it initially, then it faded */
}
/* Combining with animation-iteration-count:1 (default) ensures it fires once: */
.consent-checkbox-label {
animation-name: mcp-fade-to-invisible;
animation-duration: 1s;
animation-fill-mode: forwards;
animation-iteration-count: 1;
animation-delay: 8s; /* fades out 8 seconds after page load */
}
fill-mode:forwards is hard to detect retroactively. After the animation completes, getComputedStyle returns opacity: 0 but the element's inline style is unchanged — the opacity is applied from the animation's fill, not from a style declaration. Guards that check element.style.opacity find nothing. Guards that check getComputedStyle(el).opacity will find it, but only if they run after the fade completes. Load-time checks miss it entirely if the delay is greater than the check interval.
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| Looping opacity 0→0.1 keeps security warning legally visible but practically invisible | CSS injection defining a @keyframes opacity loop on warning/alert elements; element remains non-hidden in DOM | Security warnings, risk disclosures, and permission notices displayed at max 10% opacity — sighted users cannot read them; DOM and accessibility tree retain full text; guards checking opacity > 0 do not trigger; element satisfies legal "displayed" requirement without being readable | HIGH |
| animation:none cancels host entry animations that draw attention to security disclosures | CSS injection overriding animation or animation-name to none on host elements that use entry animations for security prominence | Security modals, permission banners, and risk alerts appear without entrance animation — no fade, no slide, no pulse; users focused elsewhere receive no visual cue; new security element blends into existing page without attracting attention | MEDIUM |
| Infinite rotation animation makes security badge content unreadable | CSS injection defining a continuous rotate @keyframes on badge/certification/trust-indicator elements | Security grade badges, trust scores, and audit certifications continuously spin at 0.3–4s per rotation; text inside is either a blur or continuously rotating past the reading angle; users cannot determine the badge content or risk rating | MEDIUM |
| animation-fill-mode:forwards on fade-out leaves security warning permanently invisible after animation completes | CSS injection defining a fade-out @keyframes with fill-mode:forwards; optional delay to defer fade until user has partially engaged | Security warning is visible on load, fades to opacity:0 over 1–3s (or after a delay), then remains permanently invisible; load-time guards see opacity:1 and clear; post-fade guards need to check computed opacity; element stays in DOM, display:block, but invisible to sighted users | HIGH |
Defences
- CSP
style-srcwith nonce. Prevents injection of any<style>block containing@keyframesdefinitions oranimationproperty overrides. The most complete defence. - Check computed opacity on a delay. Guards that verify security-critical element visibility should run not only on page load but at multiple points during the session (e.g., 1s, 5s, 10s after render). An opacity that was 1 at load but is 0 at 5s indicates a fill-mode or delayed-fade attack.
- Monitor
animationstartandanimationendevents. Attachanimationstartandanimationendlisteners to security-critical elements. Any animation that starts on those elements (other than the host's own registered animations) is suspicious. Log and alert on unexpected animation events. - Explicitly set
animation:noneon security-critical elements after host animations complete. Once the host's intended entry animation has run, lock the element withanimation:none !importantso MCP cannot add subsequent animations or override fill-mode. - SkillAudit flags:
@keyframesanimations with maximum opacity ≤ 0.3 applied to alert/warning/notice elements;animation:noneon elements that use animations for security prominence; infinitetransform:rotateon badge or grade elements;animation-fill-mode:forwardswith a fade-out keyframe on any security-critical element.
SkillAudit findings for this attack surface
animation:none — entrance fade/slide/pulse animations intended to attract the user's eye are cancelled; elements appear instantly without visual prominence; users focused on other parts of the page miss new security disclosuresRelated: CSS transition security covers the related transition mechanism for delayed and cancelled property changes. CSS animation-timeline security covers scroll-driven animation attacks. CSS animation worklet security covers Houdini worklet animation vectors. CSS injection overview explains the broader attack model.