Security Guide

MCP server CSS view-timeline-name security — wrong name mismatch freezes animation at opacity:0, none removes timeline, cascade override renames mid-scroll, JS mousedown renames container

CSS view-timeline-name assigns a custom identifier to a scroll-driven view progress timeline. Descendant elements with animation-timeline: --name reference this identifier to drive their animations from the scroll container's view progress. If the name doesn't match — whether by typo, cascade override, or JS injection — the descendant's animation has no timeline and is permanently frozen at its initial state: the consent button stays at opacity:0 with no browser error and no indication of the break.

CSS view-timeline-name — property overview

view-timeline-name assigns a dashed-ident name (e.g., --my-timeline) to a view progress timeline rooted at the element. The element creates a timeline whose progress is driven by how much of the element is visible in its scroll container. A descendant element with animation-timeline: --my-timeline drives its animation from this scroll-based progress. If no ancestor declares the matching name, the animation-timeline reference resolves to nothing and the animation is frozen at t=0. Related: view-timeline shorthand, scroll-timeline-name, timeline-scope.

Attack 1: name mismatch — container defines wrong name, animation-timeline reference finds no ancestor

The scroll container is intended to define view-timeline-name: --consent-scroll. The consent button has animation-timeline: --consent-scroll, driving its reveal animation from the container's view progress. An MCP server changes the container's name to --wrong-name. The button's animation-timeline: --consent-scroll now searches the ancestor chain for an element defining that name. It finds nothing. The animation has no timeline and is permanently frozen at its normalized time of 0 — the initial keyframe state: opacity: 0; pointer-events: none. No browser error is emitted. Scrolling the container has no visible effect on the button.

/* Intended configuration */
.scroll-container {
  view-timeline-name: --consent-scroll; /* names this element's view timeline */
  overflow-y: scroll;
}
.approve-btn {
  animation: reveal linear forwards;
  animation-timeline: --consent-scroll; /* driven by container's view progress */
}
@keyframes reveal {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}

/* Attack: MCP server renames the container's view-timeline-name */
.scroll-container {
  view-timeline-name: --wrong-name; /* does not match --consent-scroll */
  /* button's animation-timeline: --consent-scroll has no matching ancestor
     animation is permanently frozen at t=0: opacity:0, pointer-events:none
     scrolling the container produces no change in button opacity
     no browser console error — mismatched timeline name is silently ignored */
}
// Detection: verify animation-timeline name matches an ancestor's view-timeline-name
function auditViewTimelineName(el) {
  const cs = getComputedStyle(el);
  const animTimeline = cs.getPropertyValue('animation-timeline').trim();

  if (!animTimeline || animTimeline === 'none' || animTimeline === 'auto') return;
  if (!animTimeline.startsWith('--')) return; // not a named timeline reference

  // Walk ancestors to find an element defining this view-timeline-name
  let ancestor = el.parentElement;
  let found = false;
  while (ancestor) {
    const acs = getComputedStyle(ancestor);
    const vtName = acs.getPropertyValue('view-timeline-name').trim();
    const stName = acs.getPropertyValue('scroll-timeline-name').trim();
    if (vtName === animTimeline || stName === animTimeline) {
      found = true;
      break;
    }
    ancestor = ancestor.parentElement;
  }

  if (!found) {
    console.warn('[SkillAudit] animation-timeline:', animTimeline,
      '— no ancestor element found with matching view-timeline-name or scroll-timeline-name;',
      'animation frozen at t=0; consent button permanently at opacity:0:', el);
  }

  // Also check current opacity
  const opacity = parseFloat(cs.getPropertyValue('opacity'));
  if (opacity < 0.1) {
    console.warn('[SkillAudit] element with scroll-driven animation has opacity near-zero:',
      opacity, '— view-timeline-name mismatch or timeline missing:', el);
  }
}

Mismatched timeline names produce no browser error: A view-timeline-name: --wrong-name on the container and animation-timeline: --consent-scroll on the button is silently handled — the animation simply has no driver. The browser does not log a warning. The consent button stays at its initial state indefinitely. Detection requires an explicit ancestor walk to verify the named timeline is actually provided.

Attack 2: none keyword — removes named timeline, animation frozen

The view-timeline-name: none value removes any view progress timeline name from the element. If the container previously defined --consent-scroll via the cascade, a higher-specificity rule or a sub-property override setting view-timeline-name: none removes the timeline. The button's animation-timeline: --consent-scroll reference finds no matching ancestor and the animation freezes at t=0. This attack is particularly simple to apply: a single view-timeline-name: none rule on the container, placed after the named declaration in the cascade, is sufficient to break the scroll-driven animation completely.

/* Attack: override view-timeline-name to none on the container */
/* The shorthand may have set the name; overriding just the name sub-property removes it */
.scroll-container.mcp-override {
  view-timeline-name: none; /* removes --consent-scroll; animation has no driver */
  /* The button's animation-timeline: --consent-scroll now finds no match
     The scroll-container still scrolls visually
     The button's animation still has animation-name, animation-duration set correctly
     Only view-timeline-name: none breaks the scroll-driven link
     Result: button stays at opacity:0 regardless of scroll position */
}

/* Alternative: via CSS custom property */
:root {
  --timeline-name-override: none; /* MCP server injects this */
}
.scroll-container {
  view-timeline-name: var(--timeline-name-override, --consent-scroll);
  /* With --timeline-name-override: none → view-timeline-name becomes none */
}
// Detection: check for 'none' view-timeline-name on scroll containers
function auditScrollContainersForNoneTimeline() {
  const scrollers = document.querySelectorAll('[style*="overflow"], .scroll-container');
  scrollers.forEach(el => {
    const cs = getComputedStyle(el);
    const vtName = cs.getPropertyValue('view-timeline-name').trim();
    // Check if was expected to have a timeline name but has none
    if (vtName === 'none') {
      // Look for descendants with animation-timeline referencing a name
      const descendants = el.querySelectorAll('*');
      for (const d of descendants) {
        const dcs = getComputedStyle(d);
        const at = dcs.getPropertyValue('animation-timeline').trim();
        if (at && at.startsWith('--')) {
          console.warn('[SkillAudit] scroll container has view-timeline-name:none',
            'but descendant has animation-timeline:', at,
            '— timeline broken; descendant animation frozen:', d);
        }
      }
    }
  });
}

Attack 3: cascade override renames timeline mid-scroll — animation freezes at current opacity

The user has been scrolling down the page. The consent button's scroll-driven animation has progressed from opacity:0 to opacity:0.4 (the user is 40% through the required scroll distance). At this point, a higher-specificity CSS rule activates — triggered by a class added to the container — that renames the container's view-timeline-name from --consent-scroll to a different identifier. The button's animation-timeline reference immediately loses its timeline. The animation freezes at its current progress: opacity:0.4. The user continues scrolling but the button's opacity no longer changes. The button remains at 40% opacity indefinitely — partially visible but not fully visible, and depending on the button's CSS, potentially still at pointer-events: none (since the keyframe sets pointer-events: auto only at 100%).

/* Attack: class added to container triggers view-timeline-name override */
/* This could be added when user scrolls past 40%, when focus changes, etc. */
.scroll-container.mid-progress {
  view-timeline-name: --stale-name; /* different from --consent-scroll */
  /* button's animation-timeline: --consent-scroll finds no match
     animation frozen at current progress: opacity:0.4
     button is partially visible but NOT interactive (pointer-events:none until 100%)
     the class .mid-progress may have been added by MCP server on scrollend event */
}

/* Combined with scroll-driven pointer-events:
   @keyframes reveal {
     from { opacity: 0; pointer-events: none; }
     to   { opacity: 1; pointer-events: auto; }
   }
   At opacity:0.4, animation-iteration-composite:replace means:
   pointer-events is still resolved from the 'none' value at t=0 → 'auto' at t=1
   At t=0.4: interpolated pointer-events = none (non-interpolatable, stays 'none' until 50%+)
   Button is partially visible but unclickable */
// Detection: monitor view-timeline-name for cascade changes during scroll
let lastTimelineNames = new WeakMap();

function monitorTimelineNames() {
  const containers = document.querySelectorAll('[style*="view-timeline"], .scroll-container');
  containers.forEach(el => {
    const cs = getComputedStyle(el);
    const name = cs.getPropertyValue('view-timeline-name').trim();
    const prev = lastTimelineNames.get(el);
    if (prev !== undefined && prev !== name) {
      console.warn('[SkillAudit] view-timeline-name changed from', prev, 'to', name,
        '— scroll-driven animations referencing', prev,
        'are now frozen at current progress:', el);
    }
    lastTimelineNames.set(el, name);
  });
  requestAnimationFrame(monitorTimelineNames);
}

Attack 4: JS mousedown — renames container view-timeline-name via inline style

An MCP server attaches a mousedown listener in capture phase. When the user clicks anywhere near the consent dialog, the listener sets view-timeline-name: --broken on the scroll container's inline style. The button's animation-timeline reference immediately loses its driver. The animation freezes at whatever opacity it had reached at the time of the click. If the button was at opacity:0.9 when the user clicked — 90% through the scroll reveal — the animation freezes at 0.9. The click may or may not land on the button at this opacity, depending on whether pointer-events was set to auto at 90% of the keyframe progression.

/* JS attack: rename container's view-timeline-name on mousedown */
document.addEventListener('mousedown', e => {
  const container = document.querySelector('.scroll-container');
  if (!container) return;
  container.style.setProperty('view-timeline-name', '--broken');
  /* animation-timeline: --consent-scroll on the button now has no matching ancestor
     animation freezes at current scroll-driven opacity (whatever it was at mousedown)
     if freeze happens at sub-1.0 opacity, button may be visible but unclickable
     if freeze happens at opacity:0.0 (user hasn't scrolled enough), button is invisible */
}, true);
// Detection: MutationObserver for view-timeline-name changes 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 name = m.target.style.getPropertyValue('view-timeline-name');
    if (name) {
      console.warn('[SkillAudit] view-timeline-name mutated during mousedown to:', name,
        '— scroll-driven animations on descendants may be frozen:', m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

The scroll container must be identified, not just the animated element: The attack is applied to the container — the element defining the view-timeline-name — not to the button. Audits that only check the button's animation-timeline value see the correct reference name and pass. The attack is invisible from the button's computed styles. Only an ancestor walk that verifies the named timeline is actually provided by some element in the DOM tree reveals the mismatch.

Findings summary

High view-timeline-name mismatch — container defines --wrong-name; button's animation-timeline: --consent-scroll finds no ancestor; animation permanently at t=0; opacity:0; no browser error; scrolling has no effect; detection requires ancestor walk matching animation-timeline reference to view-timeline-name providers.
High view-timeline-name: none — cascade override removes named timeline from container; any descendant scroll-driven animations with named timeline references freeze at t=0; CSS custom property injection can set this without touching the element's declared style; check scroll containers for none value combined with descendant animation-timeline references.
Medium Cascade rename mid-scroll — higher-specificity class renames container's view-timeline-name after partial scroll progress; animation freezes at current progress opacity (e.g., 0.4); button may be partially visible but pointer-events still none (non-interpolatable, stays none below 50% progress); requires monitoring view-timeline-name for changes during user scroll.
High JS mousedown view-timeline-name rename — inline style renames container at click time; animation freezes at current scroll-driven opacity; if opacity at freeze was sub-interactive threshold, button is inaccessible; MutationObserver during mousedown captures the container style mutation, not the button's styles.

SkillAudit performs ancestor walks to match animation-timeline references to their view-timeline-name providers, monitors scroll containers for cascade overrides that change timeline names, and watches for mousedown-triggered timeline mutations. Run a free audit on your MCP server.