MCP server CSS timeline-scope security: scroll timeline propagation hijack, consent animation remote control, descendant timeline scope leak, and layer-priority scope override

Published 2026-07-24 — SkillAudit Research

CSS timeline-scope (CSS Scroll-Driven Animations Level 1, shipping Chrome 116+) solves a scope problem in scroll-driven animations: by default, a named scroll-timeline or view-timeline is only accessible to the element it's defined on and its descendants. The timeline-scope property expands this scope — when an ancestor element declares timeline-scope: --my-timeline, any descendant that defines a timeline with that name makes the timeline available to all elements within the ancestor's subtree, including siblings of the timeline-owning element.

This powerful feature creates a serious attack surface: an MCP server can expand the scope of its own scroll timelines to cover the entire page, making them available for any element — including the consent framework's animated consent disclosure — to bind as their animation-timeline. When the consent framework's animation is driven by an MCP-controlled scroll timeline that never advances, the animation stays at 0% progress (opacity:0) indefinitely.

Browser support: timeline-scope is supported in Chrome 116+ and Edge 116+. Firefox and Safari implementations are in progress as of mid-2026. This attack applies to Chromium-based browsers today — the same environment used by most MCP server test environments.

Attack 1: timeline scope propagation — MCP exposes its stalled scroll timeline to consent animation

The consent framework uses a scroll-driven animation to gradually reveal the consent panel as the page loads (an initial scroll progress of 0% corresponds to opacity: 0; progress of 1% corresponds to opacity: 1 with animation-range-start: 0%; animation-range-end: 1%). The timeline is named --page-scroll and points to the document scroller. An MCP server creates a hidden 1-pixel div with overflow: scroll; height: 1px; width: 1px and defines scroll-timeline-name: --page-scroll on it. Then it sets timeline-scope: --page-scroll on the <body>. Now the MCP's 1px hidden scroller's timeline (which is always at 0% because no user ever scrolls a 1px div) is the only --page-scroll timeline visible to elements within body. The consent animation binds to this stalled timeline and stays at opacity:0.

/* Consent framework: */
:root {
  scroll-timeline-name: --page-scroll;
  scroll-timeline-axis: block;
}

.consent-panel {
  animation-name: consent-reveal;
  animation-timeline: --page-scroll;
  animation-range-start: 0%;
  animation-range-end: 1%;
  animation-fill-mode: both;
}

@keyframes consent-reveal {
  from { opacity: 0; }
  to   { opacity: 1; }
}
/* Consent panel becomes visible at scroll progress 1% (almost immediately) */

/* MCP attack: create a stalled scroll container and expand its scope */
body {
  timeline-scope: --page-scroll;
  /* Expands the scope: any descendant of body can define --page-scroll,
     and it will be visible to all elements in body's subtree. */
}

/* MCP injects a hidden element that defines its own --page-scroll timeline */
/* HTML: <div class="mcp-stall" style="
     position:fixed; top:-10px; left:-10px;
     width:1px; height:1px; overflow:scroll;
     scroll-timeline-name:--page-scroll;
     scroll-timeline-axis:block">
     <div style="height:10000px"></div>
   </div> */
/* The hidden div has scroll position 0 always — user never scrolls a 1px fixed div.
   timeline progress: 0/10000 = 0%.
   Consent animation at 0% progress = opacity:0.
   animation-fill-mode:both keeps consent at opacity:0 indefinitely. */

// Detection: check if timeline-scope is set on a high-level ancestor
function detectTimelineScopeHijack(consentTimelineNames = ['--page-scroll', '--consent-timeline']) {
  const suspects = [document.body, document.documentElement, document.querySelector('main'), document.querySelector('article')];
  for (const el of suspects) {
    if (!el) continue;
    const cs = window.getComputedStyle(el);
    const scope = cs.getPropertyValue('timeline-scope') || '';
    for (const name of consentTimelineNames) {
      if (scope.includes(name)) {
        console.error('SECURITY: timeline-scope:', name, 'is set on a high-level ancestor — MCP may control this timeline', {
          element: el, timelineScope: scope
        });
        return true;
      }
    }
  }
  return false;
}

Attack 2: view-timeline scope leak — MCP element in subtree exposes view-timeline to consent sibling

A view-timeline (view-timeline-name: --consent-reveal) is defined on the consent panel element itself — when the panel enters the viewport, the view-timeline advances and the animation progresses. MCP sets timeline-scope: --consent-reveal on a common ancestor, then inserts an MCP-controlled element in a different part of the subtree that also defines view-timeline-name: --consent-reveal but is positioned off-screen (position: fixed; top: -1000px) — the MCP element's view-timeline never advances because it's always out of the viewport. The consent panel's animation-timeline now binds to the MCP's off-screen element's view-timeline instead of its own — it stays at 0% progress regardless of whether the consent panel itself is visible.

/* Consent framework: */
.consent-panel {
  view-timeline-name: --consent-reveal;
  animation-name: consent-fade-in;
  animation-timeline: --consent-reveal;
  animation-fill-mode: both;
}

/* When .consent-panel enters the viewport, the view-timeline advances.
   animation fills opacity from 0 to 1 as the element scrolls into view. */

/* MCP attack: expand scope and inject an off-screen view-timeline source */
body {
  timeline-scope: --consent-reveal;
  /* Now any element in body can define --consent-reveal,
     and it takes precedence over (or competes with) the local definition. */
}

/* MCP off-screen element — never enters viewport: */
/* HTML: <div style="position:fixed; top:-1000px; view-timeline-name:--consent-reveal;"></div> */

/* Because timeline-scope makes the MCP element's --consent-reveal
   available to the entire body subtree, and the MCP element's view-timeline
   has 0% progress (it's never in the viewport), the consent panel's
   animation-timeline binds to the stalled timeline.
   The consent animation stays at 0% = opacity:0. */

// Detection: check view-timeline values on consent panels
function detectViewTimelineHijack(panelEl) {
  const cs = window.getComputedStyle(panelEl);
  const timelineName = cs.getPropertyValue('animation-timeline') || '';
  if (!timelineName || timelineName === 'auto' || timelineName === 'none') return false;

  // If the element has animation-timeline but the animation progress is 0
  // even when the element is in the viewport, that's suspicious
  const animations = panelEl.getAnimations();
  for (const anim of animations) {
    if (anim.currentTime !== null && anim.currentTime === 0) {
      const rect = panelEl.getBoundingClientRect();
      if (rect.top < window.innerHeight && rect.bottom > 0) {
        // Element is in viewport but animation hasn't advanced
        console.error('SECURITY: consent panel is in viewport but animation-timeline progress=0 — possible timeline-scope hijack', {
          element: panelEl, animation: anim, currentTime: anim.currentTime
        });
        return true;
      }
    }
  }
  return false;
}

Attack 3: timeline-scope combined with scroll-timeline-axis flip — wrong axis stalls progress

The MCP server sets timeline-scope: --page-scroll on the <body> and injects an element that defines scroll-timeline-name: --page-scroll; scroll-timeline-axis: inline. The consent framework expects the timeline to use the block (vertical) axis — the page scrolls vertically and progress should advance as the user scrolls down. But the MCP's injected element uses the inline (horizontal) axis. If the page has no horizontal scrollbar (typical), the inline-axis scroll progress is always 0. The consent animation, bound to this timeline, stays at 0% progress and opacity:0. This extends the scroll-timeline axis attack with the additional mechanism of timeline-scope propagation.

/* MCP attack combining timeline-scope and wrong axis: */

body {
  timeline-scope: --page-scroll;
}

/* MCP injects element with horizontal-axis scroll timeline: */
/* <div style="overflow:scroll; scroll-timeline-name:--page-scroll;
     scroll-timeline-axis:inline; width:1px; height:1px; position:fixed;">
     <div style="width:10000px"></div>
   </div> */

/* No user ever scrolls this 1px div horizontally.
   Inline axis progress = 0 / 10000 = 0%.
   Consent animation at 0% = from-keyframe = opacity:0.
   The page may scroll vertically (block axis), but that scroll
   drives the original :root scroll-timeline-axis:block.
   The body's timeline-scope:--page-scroll makes the MCP's
   inline-axis element's timeline override the :root definition. */

Attack 4: cascade layer priority timeline-scope injection — defeating framework's timeline-scope:none

A security-aware consent framework may attempt to prevent timeline-scope attacks by explicitly setting timeline-scope: none on the body to disallow scope propagation. An MCP server uses cascade layer ordering to inject timeline-scope: --page-scroll !important in an earlier-declared layer, which beats the framework's timeline-scope: none !important in a later layer due to CSS layer importance reversal (earlier layer = higher !important priority). The framework's attempt to block scope propagation is defeated at the cascade level.

/* MCP layer declared early: */
@layer mcp-scope {
  body {
    timeline-scope: --page-scroll !important;
    /* mcp-scope is declared before consent-layer.
       In !important competition: earlier layer wins.
       This timeline-scope declaration beats the framework's
       timeline-scope:none !important from consent-layer. */
  }
}

/* Consent framework layer declared later: */
@layer consent-layer {
  body {
    timeline-scope: none !important;   /* attempt to block scope propagation — FAILS */
    /* consent-layer is later than mcp-scope.
       !important in later layer loses to !important in earlier layer.
       The framework's defense is ineffective. */
  }
}

// Unified detection
function detectTimelineScopeAttacks() {
  // Check for timeline-scope on ancestors
  const ancestors = [document.body, document.documentElement];
  for (const el of ancestors) {
    const cs = window.getComputedStyle(el);
    const scope = (cs.getPropertyValue('timeline-scope') || '').trim();
    if (scope && scope !== 'none') {
      // Check CSSRules to see if it's from a @layer
      for (const sheet of document.styleSheets) {
        try {
          const scanRules = (rules) => {
            for (const rule of rules) {
              if (rule.cssRules) scanRules(rule.cssRules);
              const sel = rule.selectorText || '';
              if ((sel === 'body' || sel === ':root' || sel === 'html') && rule.style) {
                const ts = rule.style.getPropertyValue('timeline-scope');
                const pri = rule.style.getPropertyPriority('timeline-scope');
                if (ts && ts !== 'none') {
                  console.error('SECURITY: timeline-scope set on root ancestor', {
                    selector: sel, timelineScope: ts, priority: pri,
                    inLayer: rule.parentRule?.constructor.name === 'CSSLayerBlockRule',
                    layerName: rule.parentRule?.name
                  });
                }
              }
            }
          };
          scanRules(sheet.cssRules);
        } catch (e) {}
      }
    }
  }
}

Why timeline-scope is particularly dangerous: timeline-scope is a relatively obscure property — it was added specifically to solve edge cases in scroll-driven animation scoping. Many consent framework authors are unaware of its existence. Security auditors who check for scroll-timeline attacks (axis, direction, range) often miss timeline-scope. The attack requires no direct modification of the consent element — only a property on an ancestor element and an off-screen scroll container injected elsewhere in the DOM.

Attack summary

Attack Mechanism Effect Severity
Stalled scroll timeline propagation timeline-scope on body + hidden stalled scroll container Consent animation-timeline binds to MCP's stalled timeline at 0% High
Off-screen view-timeline scope leak timeline-scope on ancestor + off-screen view-timeline element Consent view-timeline replaced by MCP's off-screen source at 0% progress High
Wrong-axis timeline injection timeline-scope + inline-axis scroll in non-scrolling direction Consent timeline always 0% progress because inline axis doesn't scroll High
Layer-priority scope override MCP timeline-scope !important in earlier layer beats framework's counter Framework's timeline-scope: none !important defense loses cascade High

Consolidated finding blocks

High CSS timeline-scope stalled scroll timeline propagation attack: MCP server sets timeline-scope: --page-scroll on <body> and injects a 1px fixed-position hidden div with scroll-timeline-name: --page-scroll. The hidden element's scroll position never changes (users can't interact with a 1px hidden scroller). The consent panel's animation-timeline: --page-scroll binds to this MCP-controlled timeline at 0% progress. The consent animation stays at its from-keyframe (opacity:0) indefinitely. getAnimations()[0].currentTime reports a valid ScrollTimeline object but progress is 0.
High CSS timeline-scope off-screen view-timeline source injection: MCP expands view-timeline-name: --consent-reveal scope to the body via body { timeline-scope: --consent-reveal } and inserts an off-screen element (position: fixed; top: -1000px) that defines a view-timeline with the same name. The off-screen element never enters the viewport — its view-timeline progress is always 0. The consent panel's animation-timeline: --consent-reveal binds to the MCP's off-screen source instead of its own local view-timeline. Panel stays at opacity:0.
High CSS timeline-scope cascade layer injection defeating framework defense: MCP applies timeline-scope: --page-scroll !important in an early-declared @layer. A security-aware consent framework's timeline-scope: none !important counter-rule in a later-declared layer loses due to CSS cascade layer importance reversal. The MCP's scope propagation persists. Detected by checking layer order of any timeline-scope rule on root-level ancestors.

← Blog  |  CSS scroll-timeline attacks  |  Security Checklist