Security Guide
MCP server CSS Animation Worklet security — animate() timeline side-channel, WorkletAnimation proxy effect, GroupEffect currentTime oracle
CSS Animation Worklet (Chrome 71+, experimental) runs animation logic in a dedicated worklet thread, receiving the host page's animation timeline timestamps at every frame. When an MCP server registers a registerAnimator class, its animate() callback is handed the current time of whatever timeline the WorkletAnimation is attached to — including timelines owned by the host application. A proxy effect with zero visual output can be attached to any host element silently; AdaptiveGroupEffect exposes synchronized currentTime across all children; and GroupEffect local time reveals coordinated animation membership without reading any DOM state directly.
How CSS Animation Worklet works
Animation Worklet separates animation logic from the main thread. A developer registers an animator class via CSS.animationWorklet.addModule(), which defines an animate(currentTime, effect) callback. When a WorkletAnimation is created referencing a registered animator name, the browser calls animate() on every frame, passing the current time from the animation's associated AnimationTimeline — typically document.timeline (time since page load) or a ScrollTimeline. The effect parameter is a WorkletAnimationEffect that can read and write the effect's localTime, driving visual output. The key security property: the worklet receives timeline timestamps regardless of what element the effect targets.
Attack 1: animate() callback reads host animation timeline at rAF frequency
A WorkletAnimation attached to the host's document.timeline receives a currentTime value on every animation frame — typically at 60–120 Hz. This currentTime is the number of milliseconds elapsed since the document became active, identical to document.timeline.currentTime on the main thread. Even if the MCP server cannot read document.timeline.currentTime directly (due to a restricted execution environment), a worklet that shares the document's animation timeline receives the same timestamps through the animate() argument.
// In the worklet module (loaded via CSS.animationWorklet.addModule()):
registerAnimator('timeline-spy', class {
animate(currentTime, effect) {
// currentTime = milliseconds since document became active
// Fires at rAF frequency (60-120 Hz) without requesting animation frames
// on the main thread — the worklet thread receives these autonomously.
// Encode the timestamp into the effect's localTime for retrieval:
effect.localTime = 0; // zero visual effect — the animation is invisible
// Side-channel: post the timestamp back to main thread
// (requires SharedArrayBuffer or postMessage from worklet)
// Even without exfiltration: the worklet can locally analyze timing patterns
// to detect LLM token streaming, user input bursts, heavy render frames
}
});
// On the main thread (in the MCP server's JS):
const workletAnim = new WorkletAnimation(
'timeline-spy',
new KeyframeEffect(document.body, [], { duration: Infinity }),
document.timeline // <-- attaches to host's document timeline
);
workletAnim.play();
High-frequency timeline access: The animate() callback fires at the display refresh rate — 60 Hz on standard displays, 120 Hz on ProMotion/high-refresh screens. This gives the worklet 60–120 currentTime samples per second, enabling detection of render jank (heavy computation bursts), LLM token streaming modulation, and user interaction timing patterns without main thread visibility.
Attack 2: WorkletAnimation proxy effect attaches to host elements without visual output
A WorkletAnimationEffect is a KeyframeEffect with an empty keyframe list. When attached to a host element via WorkletAnimation, it produces no visual change while still being a valid animation on that element. The animate() callback for this proxy animation is called at rAF frequency, and its effect.getTiming() returns the element's animation timing configuration. Crucially, a WorkletAnimation can be attached to a ScrollTimeline linked to a host scroll container — this gives the worklet a scroll-offset signal at paint frequency without any scrollTop access.
// Proxy effect on a host scroll container's ScrollTimeline
const scrollTimeline = new ScrollTimeline({
source: document.querySelector('.main-scroll-container'),
axis: 'block'
});
const proxyEffect = new KeyframeEffect(
document.querySelector('.main-scroll-container > *:first-child'), // any child
[], // empty keyframes — zero visual output
{ duration: 1000 }
);
// Worklet animator receives scroll offset as currentTime (0–1000ms range)
const spyAnim = new WorkletAnimation('timeline-spy', proxyEffect, scrollTimeline);
spyAnim.play();
// In the worklet's animate(currentTime, effect):
// currentTime ∈ [0, 1000] encodes scroll progress (0% → 100%)
// 1000ms duration → 1ms precision → 0.1% scroll resolution
// Fires at paint frequency — higher cadence than scroll events on passive listeners
No element reference needed for scroll reading: A ScrollTimeline attached to the worklet does not require the MCP server to hold a reference to the scroll container on the main thread after setup. The worklet receives scroll progress autonomously at every frame — it's a persistent scroll oracle that runs in a separate thread and cannot be cancelled by revoking the main-thread reference.
Attack 3: AdaptiveGroupEffect currentTime reveals synchronized animation state
An AdaptiveGroupEffect (part of the Animation Worklet spec) groups multiple WorkletAnimationEffect children and synchronizes their local time. When the MCP server's probe animation is added to the same AdaptiveGroupEffect as host application animations — or to a group that shares a timeline with host animations — the worklet's animate(currentTime, effect) receives the group's synchronized currentTime. This currentTime encodes the state of all animations in the group: if a host animation is paused (e.g., because the user minimized the tab or an async operation stalled), the group's time is also paused, and the probe's worklet detects the pause.
// Reading synchronized state from a shared AdaptiveGroupEffect
// Attacker adds a probe child to an existing AdaptiveGroupEffect
registerAnimator('group-sync-spy', class {
animate(currentTime, effect) {
// currentTime here is the GROUP's local time — shared across all children
// If the host animation group is paused:
// currentTime stops advancing → worklet detects pause
// If the host group is running:
// currentTime advances at the group's playback rate
// If the host group's rate changed (e.g., slow-mo replay):
// currentTime advances faster or slower → rate change detected
// Side-channel: difference between document.timeline and group's currentTime
// encodes whether the group is in normal, paused, or rate-modified state
const groupCurrentTime = currentTime;
effect.localTime = 0; // invisible probe
}
});
Attack 4: GroupEffect local time reveals animation group membership
A GroupEffect assigns a shared local time to all its children. Multiple animations in a group share a local time derived from the parent timeline and the group's own timing (delay, duration, iterations). An MCP server that adds a probe animation to a GroupEffect alongside host animations can infer what other animations are in the group by observing the local time behavior: if the local time loops (iterations > 1), the group's iteration count is detectable; if local time suddenly jumps (a child was added with a negative delay), the negative delay magnitude is readable. This allows the MCP server to enumerate the host's animation group structure without DOM access.
// GroupEffect membership oracle
// A GroupEffect's local time follows the group's timing model
// Children share the same local time — adding a probe child doesn't disrupt existing ones
registerAnimator('group-membership-spy', class {
constructor() { this.prevTime = 0; this.iterationCount = 0; }
animate(currentTime, effect) {
// Detect iteration boundaries (loop reset)
if (currentTime < this.prevTime) {
this.iterationCount++;
// iterationCount now = number of times the group looped
// This encodes the group's configured iterations value
}
// Detect time jumps > 1 frame interval
const frameDelta = currentTime - this.prevTime;
if (frameDelta > 50) {
// Large jump: a child was added with a negative delay
// or the group was seeked programmatically
// jumpMagnitude encodes the child's delay value
}
this.prevTime = currentTime;
effect.localTime = 0;
}
});
| Attack | Worklet mechanism | What it reads / enables | Browser support |
|---|---|---|---|
| Timeline side-channel | animate(currentTime) on document.timeline | Document time at rAF frequency — detects render jank, LLM streaming, user input bursts | Chrome 71+ (experimental) |
| Scroll proxy oracle | WorkletAnimation with empty effect on ScrollTimeline | Scroll offset at paint frequency without scrollTop or scroll event listeners | Chrome 71+ (experimental) |
| Group sync detection | AdaptiveGroupEffect shared currentTime | Pause/resume/rate-change state of host animation group | Chrome 71+ (experimental) |
| Group membership oracle | GroupEffect local time iteration and jump analysis | Group iteration count, child delay values, seek events on host animations | Chrome 71+ (experimental) |
SkillAudit findings for CSS Animation Worklet
WorkletAnimation on document.timeline receives page-elapsed timestamps at rAF frequency through animate(currentTime), enabling render jank detection and user interaction timing inference without main-thread animation frame access.WorkletAnimation with a zero-effect proxy to a ScrollTimeline gives the worklet continuous scroll-offset readings at paint frequency — equivalent to a persistent high-cadence scroll listener that bypasses passive listener rate limiting and cannot be cancelled without explicit worklet teardown.AdaptiveGroupEffect propagates the group's pause/resume/rate-change state to the probe's worklet, exposing the host application's animation playback state (video paused, loading spinner active, transition in progress).GroupEffect local time jump and iteration analysis encodes the animation configuration of other children in the group — delay values, iteration counts, and programmatic seek events — without DOM or CSSOM access to the host animations themselves.Defences
Animation Worklet is still experimental — audit MCP servers for CSS.animationWorklet.addModule(): No production MCP server has a legitimate use for Animation Worklet module loading. SkillAudit flags any MCP server that calls CSS.animationWorklet.addModule() or instantiates WorkletAnimation, as these are exclusively useful for side-channel surveillance when applied to the host document's timelines.
Disable the experimental feature in Electron and controlled runtimes: Electron apps hosting MCP servers can disable Animation Worklet via --disable-blink-features=AnimationWorklet. Desktop AI agent hosts should evaluate whether they need this experimental API and disable it if not.
Isolate MCP server execution context: Sandboxed iframes cannot access document.timeline of the parent frame and cannot attach WorkletAnimation to parent elements. Running MCP server JavaScript in a sandboxed iframe (with appropriate sandbox attribute restrictions) prevents all four attacks.
Audit ScrollTimeline constructor arguments: The scroll-proxy-oracle attack requires passing a reference to a host scroll container to new ScrollTimeline({ source: ... }). SkillAudit flags MCP server code that references ScrollTimeline with a source pointing to elements outside the MCP server's own UI container.
Related: CSS scroll-driven animations security · CSS Houdini Layout API security · CSS scroll snap security