Security Guide

MCP server CSS scroll-timeline-name security — wrong name prevents animation from progressing, none removes timeline, cascade override changes name after bind, JS mousedown renames timeline

CSS scroll-timeline-name assigns a named timeline to a scrollable container, allowing descendant elements to reference it via animation-timeline. When the animation's timeline reference and the container's timeline name do not match, the scroll-driven animation observes no progress — the element stays frozen at its initial keyframe. An MCP server can exploit this mismatch to prevent a scroll-triggered consent reveal from ever completing, regardless of how far the user scrolls.

CSS scroll-timeline-name — property overview

scroll-timeline-name is a CSS property applied to a scrollable container to define the name of its scroll progress timeline. Descendant elements reference this name using animation-timeline: --name to drive their animations based on scroll position. The name must be a custom identifier beginning with --. If no ancestor has a matching scroll-timeline-name, the animation has no timeline to observe and stays frozen at its initial state. Related: scroll-timeline-axis, scroll-timeline shorthand, animation-fill-mode.

Attack 1: wrong name — animation references --bar but container defines --foo

The consent dialog's scroll container defines scroll-timeline-name: --consent-scroll. The consent button's animation is driven by animation-timeline: --consent-scroll — a correctly authored reference. An MCP server overrides the container's property to scroll-timeline-name: --wrong-name. The button's animation-timeline still references --consent-scroll, but no ancestor in scope defines a timeline with that name. The animation's effective timeline is null — it receives no scroll progress signal. The animation stays at its 0% keyframe: the button is invisible at opacity: 0. The user can scroll to the bottom of the container and no reveal occurs.

/* Intended: scroll drives reveal animation via named timeline */
.consent-container {
  scroll-timeline-name: --consent-scroll; /* container defines timeline */
  overflow-y: scroll;
  height: 400px;
}
.approve-btn {
  animation: reveal-btn linear;
  animation-timeline: --consent-scroll; /* references the container's timeline */
  animation-fill-mode: forwards;
}
@keyframes reveal-btn {
  0%   { opacity: 0; pointer-events: none; }
  100% { opacity: 1; pointer-events: auto; }
}

/* Attack: MCP server overrides the container's timeline name */
.consent-container {
  scroll-timeline-name: --wrong-name; /* button's animation-timeline:--consent-scroll finds no match */
  /* The animation receives no scroll progress — stays frozen at 0%: opacity:0.
     Button never becomes visible or clickable regardless of scroll position.
     The approve button exists in the DOM with correct display and visibility values
     but pointer-events:none remains and opacity stays 0 — inaccessible. */
}
// Detection: verify animation-timeline name matches an ancestor's scroll-timeline-name
function auditScrollTimelineBinding(el) {
  const cs = getComputedStyle(el);
  const animTimeline = cs.getPropertyValue('animation-timeline');
  if (!animTimeline || animTimeline === 'none' || animTimeline === 'auto') return;
  // animTimeline is a custom ident like '--consent-scroll'
  const timelineName = animTimeline.trim();
  // Walk ancestors looking for a matching scroll-timeline-name
  let ancestor = el.parentElement;
  let found = false;
  while (ancestor) {
    const acs = getComputedStyle(ancestor);
    const stn = acs.getPropertyValue('scroll-timeline-name');
    if (stn && stn.trim() === timelineName) { found = true; break; }
    ancestor = ancestor.parentElement;
  }
  if (!found) {
    console.warn('[SkillAudit] animation-timeline', timelineName,
      'has no matching scroll-timeline-name ancestor — animation frozen at initial keyframe:', el);
  }
}

No browser error for mismatched timeline names: CSS does not throw an error or produce any console warning when animation-timeline references a name that doesn't exist. The animation silently has no timeline — it stays at its initial keyframe state. Scroll-driven animations that never progress can be visually identical to correctly authored animations that haven't been triggered yet. Audit tools must explicitly verify the name binding chain from animation to ancestor.

Attack 2: none — explicitly removes the timeline name from the container

An MCP server sets scroll-timeline-name: none on the container. The value none is the initial value — it means the container has no named timeline. Any descendant element with animation-timeline: --any-name will find no matching ancestor. The effect is equivalent to the wrong-name attack but uses the explicit initial value rather than a custom mismatch. This is harder to detect purely from the container's perspective because none is a valid, non-suspicious-looking value — an auditor scanning only the container CSS sees nothing unusual; the container "correctly" has no timeline name. The attack is only visible when tracing the descendant element's animation back to the expected ancestor.

/* Attack: scroll-timeline-name:none removes the named timeline from the container */
.consent-container {
  scroll-timeline-name: none; /* initial/reset value — no named timeline defined */
  /* Original stylesheet set scroll-timeline-name:--consent-scroll.
     MCP server injects a higher-specificity or later rule with none.
     All descendants' animation-timeline references resolve to nothing.
     Animations stay frozen at initial keyframe. */
  overflow-y: scroll; /* container still scrolls — user can scroll, nothing changes */
  height: 400px;
}

Detecting none requires descendant correlation: The container with scroll-timeline-name: none looks like a normal scrollable container. The attack only becomes apparent when you also observe that a descendant element has animation-timeline referencing a name that no ancestor provides. Detection must cross-correlate the descendant's timeline reference with all its ancestors' scroll-timeline-name values.

Attack 3: cascade override after animation binds

The page initially loads with the correct scroll-timeline-name: --consent-scroll on the container. The scroll-driven animation on the consent button binds to this timeline correctly and begins observing scroll progress. After a short delay — or triggered by a user action such as opening the dialog — an MCP-injected rule overrides the container's timeline name to a different value. When the scroll-timeline-name changes on a container that an animation is already observing, the animation loses its timeline reference and freezes at its current scroll-progress position. If the user has not yet scrolled far enough for the button to be fully revealed, it freezes at a partially visible or invisible state.

/* Attack: cascade override changes scroll-timeline-name after animation binds */

/* Initial (correct) stylesheet — animation binds at page load */
.consent-container {
  scroll-timeline-name: --consent-scroll;
}

/* MCP-injected rule applied 500ms after page load (via setTimeout or mutation) */
/* Override: higher specificity or !important overrides the timeline name */
.consent-wrapper .consent-container {
  scroll-timeline-name: --different-name !important;
  /* Animation was observing --consent-scroll. Now that name is gone.
     The animation freezes at its current scroll-progress position.
     If user has scrolled 30% of the container, opacity is ~0.3 — button is
     nearly invisible and pointer-events is still none.
     User continues scrolling but the animation no longer responds. */
}
// Detection: observe scroll-timeline-name changes on consent container ancestors
const observer = new MutationObserver(mutations => {
  for (const m of mutations) {
    if (m.attributeName !== 'style' && m.attributeName !== 'class') continue;
    const el = m.target;
    const cs = getComputedStyle(el);
    const stn = cs.getPropertyValue('scroll-timeline-name');
    if (m.oldValue) {
      // Check if scroll-timeline-name changed
      const prev = m.oldValue;
      if (prev.includes('--') && (!stn || stn === 'none')) {
        console.warn('[SkillAudit] scroll-timeline-name removed from container after animation bind:', el);
      }
    }
  }
});
// Observe the consent container and its ancestors
document.querySelectorAll('.consent-container, .consent-wrapper').forEach(el => {
  observer.observe(el, { attributes: true, attributeFilter: ['style', 'class'], attributeOldValue: true });
});

Attack 4: JS mousedown renames timeline — breaks scroll-driven animation during interaction

The scroll-driven reveal animation is progressing correctly as the user scrolls. When the user positions their mouse over the button (which may already be partially visible at some intermediate scroll position), a mousedown listener fires. The handler sets scroll-timeline-name on the container to a different value via inline style. The animation immediately loses its timeline binding and freezes at its current opacity (which may be less than 1). The button's pointer-events is still none (only fully revealed at 100% scroll progress). The click fires on a semi-visible or invisible button — the consent is not given but the click event is captured by the MCP server's handler below.

/* Attack: JS mousedown renames scroll-timeline-name — freezes animation at partial reveal */
document.addEventListener('mousedown', () => {
  const container = document.querySelector('.consent-container');
  if (!container) return;
  container.style.setProperty('scroll-timeline-name', '--hijacked-name');
  /* Timeline name changes — animation loses its scroll observer.
     Animation freezes at current scroll-progress opacity (e.g. 0.4).
     pointer-events is still none (only set to auto at 100% progress).
     Click fires but the consent button is not interactive — click goes to
     underlying element instead. */
});
document.addEventListener('mouseup', () => {
  const container = document.querySelector('.consent-container');
  if (!container) return;
  container.style.removeProperty('scroll-timeline-name');
  /* Timeline name restored at mouseup — animation resumes from frozen position.
     User sees the button "unfreeze" but the click has already been captured. */
});
// Detection: MutationObserver on consent container during mousedown
const inMousedown = { v: false };
document.addEventListener('mousedown', () => { inMousedown.v = true; }, true);
document.addEventListener('mouseup',   () => { inMousedown.v = false; }, true);

new MutationObserver(mutations => {
  if (!inMousedown.v) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    const stn = m.target.style.getPropertyValue('scroll-timeline-name');
    if (stn !== null && stn !== undefined) {
      console.warn('[SkillAudit] scroll-timeline-name changed during mousedown:',
        stn, m.target);
    }
  }
}).observe(document.body, {
  attributes: true, attributeFilter: ['style'], subtree: true
});

Findings summary

High Wrong scroll-timeline-name — container defines --wrong-name but animation-timeline references --consent-scroll; animation receives no scroll signal; frozen at 0% opacity with pointer-events:none; user can scroll to end of container and consent button never becomes visible or clickable; no browser error or warning.
High scroll-timeline-name:none — explicitly removes named timeline; initial value looks benign in isolation; attack only visible when correlating descendant animation-timeline references with ancestor scroll-timeline-name values; all scroll-driven animations on descendants freeze.
Medium Cascade override after animation bind — higher-specificity or !important rule changes scroll-timeline-name after animation has already attached; animation freezes at current scroll progress position; partially scrolled = partially revealed = still not interactive; MutationObserver on container attributes detects the override.
High JS mousedown timeline rename — synchronous inline style change during mousedown window renames the container's scroll-timeline-name; animation freezes at sub-100% opacity and pointer-events:none; click fires on non-interactive button; MutationObserver with mousedown flag required for detection.

SkillAudit traces scroll-timeline-name bindings from animation-timeline references to ancestor containers, verifies animation progress reaches 100%, and monitors container attributes during mousedown. Run a free audit on your MCP server.