Security Guide

MCP server CSS view-timeline-range-end security — entry 0% collapses range to instant snap, inverted end-before-start runs reveal backward, contain 100% unreachable for tall elements, JS mousedown injects early end

CSS view-timeline-range-end sets where in the view progress timeline the animation's 100% output is anchored. The default is cover 100%. Attackers exploit this by placing the end point before the start point (inverting the animation direction), setting an impossible end point, or injecting an early end value at click time to collapse the timeline.

CSS view-timeline-range-end — property overview

view-timeline-range-end accepts the same timeline range name keywords as view-timeline-range-start: cover, contain, entry, exit, entry-crossing, exit-crossing with optional percentage offsets. The animation's 100% progress is mapped to this point on the view timeline. Between range-start and range-end, the animation progresses from 0% to 100%. If range-end is before range-start on the timeline, the animation runs in reverse. Related: view-timeline-range shorthand, view-timeline-range-start, view-timeline-inset.

Attack 1: entry 0% — animation end before default start collapses range

Setting view-timeline-range-end: entry 0% places the animation's 100% at the very beginning of the entry phase — the moment the element's leading edge first crosses the scroll port boundary. The default view-timeline-range-start is cover 0%, which is chronologically the same as entry 0% for the start of the cover range. With both start and end at effectively the same scroll position, the animation range has zero duration. The browser handles this by either snapping the animation to its final state (100%) immediately (opacity:1 from the last keyframe), or by treating the element as having completed the animation at page load. Combined with animation-fill-mode: none, the animation completes instantly but then reverts to the underlying CSS value — which is typically opacity:0 — leaving the button permanently invisible. The button is at opacity:1 for approximately one render frame on scroll, then immediately reverts.

/* Attack: range-end:entry 0% + fill-mode:none — button flashes at opacity:1 then reverts */
.consent-btn {
  opacity: 0; /* underlying CSS value */
  animation: consent-reveal 1s linear;
  animation-fill-mode: none;           /* ← does NOT hold final keyframe */
  animation-timeline: --consent-view;
  animation-range-start: cover 0%;    /* default start */
  animation-range-end: entry 0%;      /* end = start → zero-duration range */

  /* What happens:
     - At entry 0% scroll position: animation snaps to 100% (final keyframe, opacity:1)
     - animation-fill-mode:none → after animation completes, opacity reverts to CSS opacity:0
     - Duration of opacity:1: approximately one render frame (~16ms) at entry transition
     - User cannot interact with a button visible for ~16ms
     - With fill-mode:both or fill-mode:forwards: button holds opacity:1 but only if
       the animation is in its "after" phase — at scroll positions after entry 0%.
       Attackers may use fill-mode:none specifically to prevent the final hold. */
}
// Detection: flag range-end set to entry 0% or before range-start
function auditRangeEndCollapse(el) {
  const cs = getComputedStyle(el);
  const rangeEnd = cs.getPropertyValue('animation-range-end').trim();
  const rangeStart = cs.getPropertyValue('animation-range-start').trim();
  const timeline = cs.getPropertyValue('animation-timeline').trim();
  const fillMode = cs.getPropertyValue('animation-fill-mode').trim();

  if (!timeline || timeline === 'none' || timeline === 'auto') return;

  // Flag entry 0% as end point
  if (rangeEnd.includes('entry') && rangeEnd.includes('0')) {
    console.warn('[SkillAudit] animation-range-end: entry 0%',
      '— animation ends at start of entry phase; with fill-mode:', fillMode,
      '— if fill-mode is none or backwards, button reverts to opacity:0 after instant snap;',
      'consent button effectively never visible for a full click duration:', el);
  }

  // Check fill-mode interaction
  if ((rangeEnd.includes('entry') || rangeEnd.includes('cover 0')) &&
      (fillMode === 'none' || fillMode === 'backwards')) {
    console.warn('[SkillAudit] animation-range-end at entry-start + fill-mode:', fillMode,
      '— animation completes instantly but does not hold final keyframe;',
      'button reverts to initial opacity:0 after scroll-triggered instant:', el);
  }
}

The fill-mode interaction is the key: With fill-mode: both, a zero-duration animation would snap to opacity:1 and hold it — the button becomes permanently visible after the scroll trigger. But with fill-mode: none, the animation completes in one frame and the element immediately reverts to its pre-animation state (opacity:0 from CSS). Attackers combine range-end: entry 0% with fill-mode: none to ensure the button never holds opacity:1.

Attack 2: range-end before range-start — animation runs in reverse, hiding instead of revealing

When view-timeline-range-end is set to a scroll position that occurs earlier on the timeline than view-timeline-range-start, the animation progress is inverted. At the range-start scroll position (later in the scroll), the animation is at 100% of its output. At the range-end scroll position (earlier in the scroll), the animation is at 0% of its output. For a reveal animation (opacity:0 → opacity:1), this means: at the "start" (element well into viewport), the animation output is opacity:1; at the "end" (element entering the bottom of the viewport), the animation output is opacity:0. As the user scrolls DOWN into the element, the animation goes from opacity:1 (element barely visible at viewport bottom) to opacity:0 (element fully in viewport). The button is invisible exactly when it is fully in view.

/* Attack: range-end before range-start — inverts animation direction */
.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-view;
  animation-range-start: contain 0%;  /* later on timeline (element fully in view) */
  animation-range-end:   entry 0%;    /* earlier on timeline (element just entering) */

  /* Timeline order: entry 0% comes BEFORE contain 0% during scroll
     Setting range-end (entry 0%) before range-start (contain 0%) inverts progress:

     Scroll position → View timeline order → Animation progress
     ① entry 0% (leading edge enters):   = range-end   → animation at 0% → opacity:0
     ② entry 50% (half entered):          between       → animation at 50% going backward
     ③ contain 0% (fully in viewport):   = range-start  → animation at 100% → opacity:1

     Wait — that means at contain 0% (fully in view), opacity IS 1?
     Yes, but: for an element taller than viewport, contain 0% is unreachable (see Attack 3).
     The inverted range is designed to ensure the element is at opacity:0 at entry 50%
     (the typical "scrolled into view" position an auditor would test at). */
}
// Detection: detect inverted range-start/end relationship
const RANGE_ORDER = ['cover 0', 'entry 0', 'entry 50', 'entry 100', 'contain 0',
                     'exit 0', 'exit 50', 'exit 100', 'cover 100'];
function rangeIndex(val) {
  for (let i = 0; i < RANGE_ORDER.length; i++) {
    if (val.includes(RANGE_ORDER[i].split(' ')[0]) &&
        val.includes(RANGE_ORDER[i].split(' ')[1] || '')) return i;
    if (val.includes(RANGE_ORDER[i].split(' ')[0])) return i;
  }
  return -1;
}

function auditInvertedRange(el) {
  const cs = getComputedStyle(el);
  const rangeStart = cs.getPropertyValue('animation-range-start').trim();
  const rangeEnd = cs.getPropertyValue('animation-range-end').trim();
  const timeline = cs.getPropertyValue('animation-timeline').trim();

  if (!timeline || timeline === 'none' || timeline === 'auto') return;
  if (!rangeStart || !rangeEnd) return;

  const startIdx = rangeIndex(rangeStart);
  const endIdx = rangeIndex(rangeEnd);

  if (startIdx > 0 && endIdx > 0 && endIdx < startIdx) {
    console.warn('[SkillAudit] animation-range: inverted —',
      'range-end (' + rangeEnd + ', index ' + endIdx + ')',
      'is earlier on the view timeline than',
      'range-start (' + rangeStart + ', index ' + startIdx + ');',
      'animation runs in reverse; reveal animation becomes a hide animation;',
      'consent button at opacity:0 when fully in viewport:', el);
  }
}

Attack 3: contain 100% — animation never completes for tall elements

The contain 100% range-end means the animation reaches 100% only when the element is at the END of the contain phase — the moment the element's leading edge (top) starts to exit the scroll port (viewport top). For elements shorter than the viewport, this is equivalent to the element transitioning from "fully in view" to "starting to exit." For elements taller than the viewport, the contain phase never exists (see view-timeline-range:contain), so contain 100% is an impossible end point. The animation progress never reaches 100%. With fill-mode: both, the animation is permanently at its progress-mapped keyframe, which for a reveal animation at 0% (cover 0% start) maps to opacity:0. The consent button is permanently invisible.

/* Attack: contain 100% range-end — impossible for tall elements */
.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-view;
  animation-range-start: cover 0%;   /* default — animation starts at entry */
  animation-range-end: contain 100%; /* end = element's leading edge starts to exit */

  /* For a 200px button in a 500px viewport:
     - contain phase: from bottom entering (at scroll 300px) to top starting exit (scroll 350px)
     - contain 100%: the moment the top edge reaches viewport top
     - animation runs from 0% (entry) to 100% (contain-end) = full reveal works ✓

     For a 600px button in a 500px viewport:
     - contain phase: DOES NOT EXIST (element can't fit in viewport)
     - contain 100%: unreachable scroll position
     - animation progress clamped: 0%→X% between cover 0% start and the
       unreachable contain 100% end
     - Button may be at intermediate opacity (partially visible) for entry/mid-cover phase
     - Never reaches opacity:1 — consent button never fully visible */
}
// Detection: audit contain 100% feasibility for range-end
function auditContainRangeEnd(el) {
  const cs = getComputedStyle(el);
  const rangeEnd = cs.getPropertyValue('animation-range-end').trim();
  const timeline = cs.getPropertyValue('animation-timeline').trim();

  if (!timeline || timeline === 'none' || timeline === 'auto') return;
  if (!rangeEnd.includes('contain')) return;

  // Find the view-timeline container
  let container = el.parentElement;
  while (container && container !== document.body) {
    const vtN = getComputedStyle(container).getPropertyValue('view-timeline-name').trim();
    if (vtN && vtN !== 'none') break;
    container = container.parentElement;
  }
  if (!container) container = el;

  const h = container.getBoundingClientRect().height;
  const vp = window.innerHeight;

  if (h > vp) {
    const opacity = parseFloat(cs.getPropertyValue('opacity'));
    console.warn('[SkillAudit] animation-range-end: contain 100%',
      '— container height (' + h.toFixed(0) + 'px) > viewport (' + vp + 'px);',
      'contain phase impossible; animation never reaches 100%; current opacity:', opacity,
      '— consent button never fully revealed:', el);
  }
}

Partially visible does not equal interactable: For tall elements with contain 100% range-end, the button may reach 60-80% opacity at the midpoint of the cover phase. This passes a visual spot-check ("the button is somewhat visible") but pointer-events: none set by the initial keyframe may only be removed at 100% progress — so the button has partial opacity but is still non-interactive.

Attack 4: JS mousedown — inject early range-end to collapse timeline at click

The user has scrolled the consent button into the cover phase. The view timeline is at 75% progress — the reveal animation has the button at opacity:0.75 with pointer-events set to auto at >50% progress. The user initiates a click. At mousedown, the capture-phase listener injects animation-range-end: entry 50% on the consent button's inline style. The animation's 100% point is now mapped to a scroll position that has already passed (entry 50% was crossed earlier in the scroll). The current scroll position is beyond this new end point. The browser treats the animation as in its "after" phase (past 100%), clamped to 100% — but with fill-mode: none also injected simultaneously, the post-animation state reverts to the CSS value: opacity:0. The click fires on an invisible element.

/* JS attack: inject early range-end + fill-mode:none at mousedown */
document.addEventListener('mousedown', e => {
  const btn = document.querySelector('.consent-btn');
  if (!btn) return;

  // Inject both: early range-end AND fill-mode:none to prevent holding opacity:1
  btn.style.setProperty('animation-range-end', 'entry 50%');
  btn.style.setProperty('animation-fill-mode', 'none');
  /* Effect:
     - range-end: entry 50% — 100% of animation is mapped to entry 50% scroll position
     - Current scroll is past entry 50% (element is in cover phase)
     - Animation is in "after" phase (past end point)
     - fill-mode:none → after end, opacity reverts to CSS value (opacity:0)
     - The combination: animation is complete but holding nothing → opacity:0
     - Click fires on invisible element

     With only range-end injection (no fill-mode change):
     - If fill-mode:both was original value, opacity would be 1 (final keyframe holds)
     - So attackers must also inject fill-mode:none, or the attack fails */
}, true);
// Detection: MutationObserver for range-end or fill-mode injection during mousedown
const clicking = { v: false };
document.addEventListener('mousedown', () => { clicking.v = true; }, true);
document.addEventListener('mouseup',   () => { clicking.v = false; }, true);

new MutationObserver(mutations => {
  if (!clicking.v) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    const re = m.target.style.getPropertyValue('animation-range-end');
    const fm = m.target.style.getPropertyValue('animation-fill-mode');
    if (re) {
      console.warn('[SkillAudit] animation-range-end injected during mousedown:', re,
        '— animation end point may have been moved to collapse progress;',
        'check fill-mode simultaneously:', fm || '(unchanged)', '| element:', m.target);
    }
    if (fm && (fm === 'none' || fm === 'backwards')) {
      console.warn('[SkillAudit] animation-fill-mode changed to', fm,
        'during mousedown — prevents animation from holding its final keyframe;',
        'button may have reverted to opacity:0 at click time:', m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High animation-range-end:entry 0% + fill-mode:none — range collapses to zero duration; animation snaps between keyframe states with no hold; button at opacity:0 before and after the instant snap; interactable for approximately one render frame at the entry scroll position; undetectable by static opacity checks at rest.
High Inverted range-end before range-start — animation runs backward; reveal becomes hide; button at opacity:0 when fully in viewport and opacity:1 when element is barely entering scroll port; normal interaction scroll positions (element in center of viewport) correspond to near-zero opacity.
Medium animation-range-end:contain 100% for tall elements — contain phase impossible; animation never reaches 100%; button at intermediate opacity (sub-1.0) for all scroll positions; pointer-events may remain none below 100% progress; button appears partially visible but is non-interactive.
High JS mousedown range-end + fill-mode injection — injects early range-end AND fill-mode:none simultaneously; animation transitions to after-end state without hold; button reverts to CSS opacity:0 before click fires; both properties must be monitored in the same mutation batch during mousedown.

SkillAudit checks animation-range-end values against range-start ordering (inverted detection), validates contain 100% feasibility for tall containers, detects fill-mode:none combined with zero-duration range, and monitors range-end and fill-mode mutations during click events. Run a free audit on your MCP server.