Security Guide

MCP server CSS animation-range-end security — entry 0% collapses range to zero, inverted end before start reverses animation direction, contain 100% unreachable on tall elements, JS mousedown injects early end with fill-mode:none

CSS animation-range-end is the longhand sub-property that sets where on the scroll progress timeline the animation's output 100% is anchored. Shifting this anchor to before the default start, or to a scroll position that is physically unreachable, prevents the consent reveal animation from ever reaching its final opacity:1 keyframe.

CSS animation-range-end — property overview

animation-range-end is the second value set by the animation-range shorthand. It accepts a timeline range keyword and optional percentage: cover 100% (default), entry 0%, exit 100%, contain 100%, with percentage offsets within each named phase. When set to the default cover 100%, the animation reaches its 100% keyframe at the moment the element's trailing edge exits the scroll port. Related: animation-range-start, view-timeline-range-end, animation-range shorthand, animation shorthand.

Attack 1: entry 0% end — range collapses to zero or runs backward

Setting animation-range-end: entry 0% means the animation's 100% output is anchored to the start of the entry phase — the moment the element's leading edge first crosses the scroll port's trailing edge (first pixel of entry). The default start is cover 0%, which is the same moment the entry phase begins for most elements. When end equals start, the animation range has zero duration. When entry 0% resolves to a position before cover 0% (which can happen with some timeline configurations), the range is negative. In both cases, the animation is clamped to its fill state: with animation-fill-mode: both, the initial keyframe applies permanently — opacity:0.

/* Attack: animation-range-end:entry 0% — collapses range to zero */
.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-view;
  /* animation-range-start: cover 0% (default — start of cover phase) */
  animation-range-end: entry 0%;  /* end at entry 0% = start of entry phase */

  /* Timeline phases in order:
     entry 0%  → element leading edge just entered viewport  (earliest)
     entry 100% / cover 0% → element fully in viewport (slightly later)
     cover 50% → element centered in viewport
     cover 100% / exit 0% → element trailing edge at viewport trailing edge
     exit 100% → element trailing edge has fully exited

     With start:cover 0% and end:entry 0%:
     entry 0% is BEFORE cover 0% on the timeline.
     The end is before the start → range duration is zero or negative.
     Browser clamps animation progress: clamped to 0% (or 100% in reverse mode).
     With fill-mode:both → initial keyframe → opacity:0. */
}

/* More subtle variant: explicit start after end */
.consent-btn {
  animation-range-start: cover 20%; /* partway into cover phase */
  animation-range-end:   entry 80%; /* end in entry phase = before start */
  /* entry 80% is before cover 20% on the timeline → inverted range */
}
// Detection: check whether end is before start on the timeline
function rangePhaseOrder(keyword) {
  // Returns numeric order of each named phase
  const order = { 'entry': 0, 'cover': 1, 'contain': 1.5, 'exit': 2 };
  return order[keyword] ?? 1;
}

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

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

  const startKeyword = start.split(' ')[0];
  const endKeyword   = end.split(' ')[0];
  const startPct     = parseFloat((start.match(/([\d.]+)%/) || [])[1] ?? '0');
  const endPct       = parseFloat((end.match(/([\d.]+)%/) || [])[1] ?? '100');

  const startOrder = rangePhaseOrder(startKeyword) + startPct / 1000;
  const endOrder   = rangePhaseOrder(endKeyword)   + endPct   / 1000;

  if (endOrder <= startOrder) {
    console.warn('[SkillAudit] animation-range-end is before or equal to animation-range-start:',
      'start:', start, '| end:', end,
      '— range has zero or negative duration; animation clamped to fill state;',
      'computed opacity:', cs.getPropertyValue('opacity'), '| element:', el);
  }
}

Zero-duration range passes play-state checks: The animation is running (play-state: running), wired to a timeline (animation-timeline set), and references valid keyframes. It is not paused. But its range has zero duration, so it never progresses. An audit checking "is the animation configured correctly" — play state, timeline wiring, keyframe names — will pass. Only reading and comparing both range values reveals the collapse.

Attack 2: inverted range — end before start reverses animation direction

If animation-range-end is set to a position that resolves to a later scroll position than the default start (cover 0%), but still before what an auditor would consider "full visibility," the animation runs in reverse. With animation-range-end: cover 20% and the default start at cover 0%, the animation maps 100% to cover 20% and 0% to cover 0%. As scroll progresses from cover 0% to cover 20%, the animation runs from 0% to 100%: opacity:0 to opacity:1. But the button is only at opacity:1 at cover 20%, the very beginning of the cover phase. As the user continues scrolling to cover 50% or cover 100% (more natural "reading" positions), the animation has already passed its end and is clamped at 100% fill — opacity:1. In this case the effect actually shows the button briefly. But if the intent is reversed — animation-range-end: cover 10% with animation-range-start: cover 80% — the end is before the start, and the animation runs backward: opacity:1 at cover 10%, fading to opacity:0 at cover 80%, with opacity:0 during the entire deep-cover phase when the user is most engaged.

/* Attack: start at cover 80%, end at cover 10% → fully inverted */
.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-view;
  animation-range-start: cover 80%;
  animation-range-end:   cover 10%;
  /* cover 10% is before cover 80% on the timeline.
     As scroll progress moves from cover 10% to cover 80%,
     the animation runs from 100% to 0%: opacity:1 → opacity:0.
     At cover 50% (element centered, user most engaged): opacity:0.4
     At cover 80% and beyond: opacity:0 (animation at 0% or past end, fill:both → initial) */
}
// Detection: compare start/end relative positions and check current opacity
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;

  // Approximate scroll-position order for comparison
  const phases = { entry: 0, cover: 100, contain: 150, exit: 200 };
  const parse = s => {
    const [kw, pctStr] = s.split(' ');
    const pct = parseFloat(pctStr || '0');
    return (phases[kw] ?? 100) + pct;
  };

  const startPos = parse(rangeStart || 'cover 0');
  const endPos   = parse(rangeEnd   || 'cover 100');

  if (endPos < startPos) {
    const rect = el.getBoundingClientRect();
    const inViewport = rect.top < window.innerHeight && rect.bottom > 0;
    const opacity = parseFloat(cs.getPropertyValue('opacity'));
    console.warn('[SkillAudit] animation-range-end before animation-range-start:',
      'end:', rangeEnd, '< start:', rangeStart,
      '— animation runs in reverse; element in viewport:', inViewport,
      '| computed opacity:', opacity,
      '— button may be at opacity:0 during maximum visibility; element:', el);
  }
}

Attack 3: contain 100% — unreachable end for tall elements

The contain phase is the scroll window during which both the element's leading and trailing edges are simultaneously inside the scroll port. For elements shorter than the viewport, this phase exists and contain 100% is the end of that window. For elements taller than the viewport, the contain phase has zero duration — both edges can never simultaneously be inside the viewport. Setting animation-range-end: contain 100% on a tall element means the animation's 100% is at a position that does not exist on the scroll timeline. The animation's progress cannot reach 100%, so the opacity:1 keyframe is never applied. With animation-fill-mode: both, the animation is clamped to its last-computed progress value, which never reaches 1.

/* Attack: contain 100% end on tall element */
.consent-section {
  min-height: calc(100vh + 2px); /* just over viewport height */
  view-timeline-name: --consent-view;
}

.consent-btn {
  animation: consent-reveal 1s linear both;
  animation-timeline: --consent-view;
  /* animation-range-start: cover 0% (default) */
  animation-range-end: contain 100%;
  /* For element 1px taller than viewport:
     contain phase = max(0, viewportHeight - elementHeight) = -1px → no contain phase.
     contain 100% resolves to the same position as cover 0% or earlier.
     Animation range collapses to zero. Opacity permanently 0. */
}

/* Subtle 1px-overage variant */
.consent-section { min-height: calc(100vh + 1px); } /* passes visual inspection */
// Detection: flag contain range-end on elements that may exceed viewport height
function auditContainEndOnTallElement(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.startsWith('contain')) return;

  // Find the scroll container with this timeline name
  const tlName = cs.getPropertyValue('animation-timeline').trim();
  let container = null;
  document.querySelectorAll('[style*="view-timeline"]').forEach(c => {
    if (getComputedStyle(c).getPropertyValue('view-timeline-name').trim() === tlName) {
      container = c;
    }
  });

  const scrollport = container ? container : document.scrollingElement;
  const portHeight = scrollport.clientHeight || window.innerHeight;
  const elHeight   = el.getBoundingClientRect().height;

  if (elHeight >= portHeight) {
    console.warn('[SkillAudit] animation-range-end: contain on element taller than viewport:',
      'elementHeight:', elHeight + 'px', '| viewportHeight:', portHeight + 'px',
      '— contain phase has zero duration; animation range end is unreachable;',
      'opacity will never reach 1; current opacity:',
      getComputedStyle(el).getPropertyValue('opacity'), '| element:', el);
  }
}

1px overage is the minimum viable attack: An element only 1px taller than the viewport has no contain phase. The pixel difference is visually imperceptible. An audit comparing element height to viewport height as integers will catch it; a floating-point comparison with a 5px tolerance will miss it. Always compare with at least 1px precision.

Attack 4: JS mousedown — inject early animation-range-end with fill-mode:none

The consent button is at opacity:0.8 (animation 80% complete as the user has scrolled). At capture-phase mousedown, the attacker injects two style changes simultaneously: animation-range-end: cover 20% (early end, already past) and animation-fill-mode: none (no hold after end). With the new range end in the past, the animation is after its end. With fill-mode: none, there is no forwards fill — the element reverts to its underlying CSS value (opacity:0 from the keyframe's absence). The button snaps from opacity:0.8 to opacity:0 at mousedown. Critically, injecting only animation-range-end without removing fill-mode:both would still hold at opacity:1 after the new end — both properties must change simultaneously for the attack to snap to opacity:0.

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

  /* Must inject both properties together:
     - animation-range-end: past position → animation is after its end
     - animation-fill-mode: none → no fill holds opacity:1 after end
     Without fill-mode:none, the button stays at opacity:1 (fill forwards)
     Without early range-end, fill-mode:none alone doesn't snap to 0 */
  btn.style.setProperty('animation-range-end', 'cover 20%');
  btn.style.setProperty('animation-fill-mode', 'none');
  /* At time of mousedown, scroll progress is at ~80% of cover phase.
     New range-end is cover 20% — in the past.
     Animation has already passed its new end.
     fill-mode:none → no hold → element reverts to CSS opacity:0
     → button invisible before click fires */

  /* Evasion: inject via animation-range shorthand + animation shorthand */
  /* btn.style.setProperty('animation-range', 'cover 10% cover 20%'); */
  /* btn.style.setProperty('animation', 'consent-reveal 1s linear none running'); */
  /* → Changes animation-range-end and fill-mode via shorthands
     → Sub-property MutationObservers on animation-range-end don't fire */
}, true);
// Detection: watch both animation-range-end and animation-fill-mode during mousedown
const mouseState = { down: false };
document.addEventListener('mousedown', () => { mouseState.down = true; },  true);
document.addEventListener('mouseup',   () => { mouseState.down = false; }, true);

new MutationObserver(mutations => {
  if (!mouseState.down) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    const el = m.target;
    const rangeEnd  = el.style.getPropertyValue('animation-range-end');
    const fillMode  = el.style.getPropertyValue('animation-fill-mode');
    const animation = el.style.getPropertyValue('animation');
    const range     = el.style.getPropertyValue('animation-range');

    // Both properties changing in same mousedown batch = attack signature
    const endChanged  = !!rangeEnd || (animation && animation.includes('none'));
    const fillChanged = !!fillMode || (animation && animation.includes('none'));

    if (endChanged || fillChanged || range) {
      console.warn('[SkillAudit] animation-range-end or fill-mode injected during mousedown:',
        { 'animation-range-end': rangeEnd, 'animation-fill-mode': fillMode,
          'animation': animation, 'animation-range': range },
        '— computed opacity after injection:', getComputedStyle(el).opacity,
        '| element:', el);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High animation-range-end:entry 0% — end before default start (cover 0%); range collapses to zero or negative duration; animation clamped at initial fill state opacity:0; animation-play-state:running and timeline are both correctly wired; passes play-state and wiring checks; detected only by comparing end position to start position on the timeline.
High Inverted range (end before start) — animation runs backward; button at opacity:1 early in scroll then fades to opacity:0 at maximum visibility position; brief early visibility passes "is the button ever shown" audit; detected by calculating relative scroll positions of start and end and checking opacity at maximum-visibility scroll position.
High animation-range-end:contain 100% on tall element — contain phase has zero duration for element ≥ viewport height; end is unreachable; animation never reaches opacity:1; 1px overage is sufficient; detected by comparing element height to scroll port height with 1px precision.
High JS mousedown injection of animation-range-end + animation-fill-mode:none — both properties must change together; range-end alone with fill-mode:both leaves button at opacity:1; fill-mode:none alone with current end doesn't snap to 0; combined injection snaps to CSS opacity:0 before click fires; must also watch animation-range shorthand and animation shorthand for evasion variants.

SkillAudit reads animation-range-end alongside animation-range-start and validates their relative positions on the scroll timeline, estimates the animation window duration in pixels for tall-element contain attacks, and monitors both sub-properties and their shorthand forms during click events. Run a free audit on your MCP server.