Security Guide

MCP server CSS view-timeline-range security — contain range impossible for tall elements, exit-phase reveal animation runs while element is leaving, narrow entry/exit window flashes button visible, JS mousedown collapses range to 0%

CSS view-timeline-range is the shorthand for view-timeline-range-start and view-timeline-range-end. It selects which intersection phase drives the scroll-driven animation. Attackers exploit this by choosing phases that are geometrically impossible for the consent element, that run while the element is off-screen, or that create windows so narrow the button is visible for only a single frame.

CSS view-timeline-range — property overview

view-timeline-range accepts a timeline range name keyword and optional percentage offsets: cover, contain, entry, exit, entry-crossing, exit-crossing. The contain keyword means the animation progresses only while the element's entire bounding box is fully inside the scroll port. The entry keyword means the animation runs while any part of the element crosses from outside into the scroll port. The exit keyword runs while the element is leaving the scroll port. Related: view-timeline-range-start, view-timeline-range-end, view-timeline-inset, view-timeline-name.

Attack 1: contain range — never achievable for elements taller than the viewport

The contain range keyword means the view progress timeline starts when the element's trailing edge (bottom) has crossed the leading edge of the scroll port (bottom of the visible area enters the top of the viewport) and ends when the element's leading edge (top) exits the scroll port (top of element reaches the top of the viewport). For this range to have any duration, the element must fit entirely within the viewport at some point during the scroll. For any element whose rendered height exceeds the viewport height, both edges can never be inside the scroll port simultaneously. The contain range is empty — the timeline starts and immediately ends at the same scroll position, or never starts at all. The animation is permanently frozen at its 0% keyframe. The consent button is permanently at opacity:0.

/* Attack: view-timeline-range:contain — freezes animation for tall consent elements */
.scroll-container {
  view-timeline-name: --consent-view;
  /* No view-timeline-range — defaults to cover (safest, works for any height) */
}

/* Attacker changes to: */
.scroll-container {
  view-timeline-name: --consent-view;
  view-timeline-range: contain;
  /* For an element of rendered height H and viewport height V:
     - contain range requires H <= V
     - If H > V: range start === range end (zero-length range)
     - View progress at any scroll position: 0% (or undefined)
     - Consent button animation: frozen at t=0, opacity:0
     - Works silently: no console errors, animation-timeline is valid,
       scroll-timeline-name is set — only range value is wrong */
}

/* The consent button's animation:
   @keyframes consent-reveal { from { opacity: 0 } to { opacity: 1 } }
   .consent-btn {
     animation: consent-reveal 1s linear both;
     animation-timeline: --consent-view;
     /* range is contain — for a 600px tall element in a 500px viewport:
        contain range is empty → animation never progresses → opacity:0 forever */
   } */
// Detection: check if contain range is possible given element dimensions
function auditViewTimelineRangeContain(el) {
  const cs = getComputedStyle(el);
  const range = cs.getPropertyValue('view-timeline-range').trim() ||
                cs.getPropertyValue('view-timeline-range-start').trim();
  const vtName = cs.getPropertyValue('view-timeline-name').trim();

  if (!vtName || vtName === 'none') return;
  if (!range.includes('contain')) return;

  const rect = el.getBoundingClientRect();
  const elHeight = rect.height;
  const vpHeight = window.innerHeight;

  if (elHeight > vpHeight) {
    console.warn('[SkillAudit] view-timeline-range: contain',
      '— element height', elHeight.toFixed(0) + 'px',
      'exceeds viewport height', vpHeight + 'px;',
      'contain range is geometrically impossible; view timeline progress permanently 0%;',
      'consent button animation frozen at opacity:0:', el);
  }

  // Also check descendants with the animation-timeline reference
  el.querySelectorAll('*').forEach(d => {
    const dat = getComputedStyle(d).getPropertyValue('animation-timeline').trim();
    if (dat && dat.startsWith('--')) {
      const opacity = parseFloat(getComputedStyle(d).getPropertyValue('opacity'));
      if (opacity < 0.1) {
        console.warn('[SkillAudit] animation-timeline descendant at opacity:0',
          'under contain-range ancestor — consent button unreachable:', d);
      }
    }
  });
}

The element and animation are correctly wired: view-timeline-name is set, animation-timeline references it, the scroll container is scrollable. Only the contain range value prevents progress. Static CSS audits checking "is a view timeline configured" will pass; only checking the range keyword against the element's rendered height reveals the attack.

Attack 2: exit range — reveal animation runs while element is leaving the viewport

The exit range runs the animation while the element is leaving the scroll port — from the moment the leading edge (top) starts crossing the scroll port's trailing edge (viewport bottom scrolling up, or top of viewport for a downward-exiting element) to when the entire element has left. If the reveal animation (opacity:0 → opacity:1) is tied to the exit range, the button transitions from invisible to visible only while the element is in the process of leaving. By the time the animation reaches 100% progress (opacity:1), the element has fully exited the viewport. The button is at full opacity but off-screen — the user cannot see or interact with it. Consent is never given.

/* Attack: view-timeline-range:exit — reveal happens while element is exiting viewport */
.scroll-container {
  view-timeline-name: --consent-view;
  view-timeline-range: exit;
  /* exit = animation runs from:
     - 0%: top edge of element is at viewport bottom (element just starting to exit)
     - 100%: bottom edge of element has crossed viewport top (element fully gone)

     For a reveal animation (opacity:0 → opacity:1):
     - At 0% exit progress: button is at opacity:0, element at bottom of viewport
     - At 50% exit progress: button at opacity:0.5, element half-exited (mostly off-screen)
     - At 100% exit progress: button at opacity:1, element fully above viewport
     - User scrolled past the element — button visible only when off-screen above fold
     - User cannot scroll back down to the button (animation is scroll-driven, going back
       would reverse the animation) without dedicated reverse scroll interaction */
}
// Detection: flag exit range on reveal animations
function auditExitRangeReveal(el) {
  const cs = getComputedStyle(el);
  const range = cs.getPropertyValue('view-timeline-range').trim();
  const vtName = cs.getPropertyValue('view-timeline-name').trim();

  if (!vtName || vtName === 'none') return;
  if (!range.includes('exit')) return;

  // Check if descendants have reveal animations (from opacity:0 to opacity:1)
  el.querySelectorAll('*').forEach(d => {
    const dat = getComputedStyle(d).getPropertyValue('animation-timeline').trim();
    if (!dat || !dat.startsWith('--')) return;
    const opacity = parseFloat(getComputedStyle(d).getPropertyValue('opacity'));
    // During entry phase (which isn't covered by exit range), opacity should be 0
    // This is the consent button before the exit phase begins
    if (opacity < 0.1) {
      const rect = d.getBoundingClientRect();
      // If element is in viewport but animation timeline is exit range — mismatch
      if (rect.top < window.innerHeight && rect.bottom > 0) {
        console.warn('[SkillAudit] view-timeline-range: exit, but animated element',
          'is currently in the viewport at opacity:0 — reveal only triggers during',
          'exit phase (element leaving viewport); consent never achievable:', d);
      }
    }
  });
}

Attack 3: narrow entry-exit percentage window — button flashes visible for one frame

By setting view-timeline-range with explicit percentage offsets that create a very small window — for example entry 90% exit 10% — the animation range spans only a tiny fraction of the total scroll distance. At 90% of entry, the element is almost fully in the viewport. At 10% of exit, the element has just started leaving. The scroll distance between these two points may be only 10-20 pixels on a typical screen. The consent button's reveal animation must complete in that 10-20px scroll window. On most devices, the button is visible for approximately one animation frame (~16ms) before the user's natural scrolling momentum carries them past the window. The button is technically at opacity:1 for a brief moment, but is never interactable in practice.

/* Attack: narrow range window — button visible for ~1 scroll frame */
.scroll-container {
  view-timeline-name: --consent-view;
  view-timeline-range: entry 90% exit 10%;
  /* Effective scroll window calculation for a 48px button in a 600px viewport:
     - entry 90%: element is 90% entered = top 43px inside viewport, button almost fully visible
     - exit 10%: element's leading edge has moved 10% of exit distance above viewport
     - Physical distance between these points: ~5px of scroll (for a 48px element)
     - At typical scroll velocity (300px/s), button is visible for ~16ms (1 frame)
     - User's touch scroll momentum makes stopping in this window effectively impossible
     - Button appears to "flash" visible — looks like a glitch, not a consent UI */
}

/* Three-value syntax shorthand:
   view-timeline-range: entry 90% exit 10%;
   ↕ equivalent to:
   view-timeline-range-start: entry 90%;
   view-timeline-range-end: exit 10%; */
// Detection: flag suspiciously narrow range windows
function auditNarrowRangeWindow(el) {
  const cs = getComputedStyle(el);
  const rangeStart = cs.getPropertyValue('view-timeline-range-start').trim();
  const rangeEnd = cs.getPropertyValue('view-timeline-range-end').trim();
  const vtName = cs.getPropertyValue('view-timeline-name').trim();

  if (!vtName || vtName === 'none') return;

  // Parse range percentage values
  const startMatch = rangeStart.match(/([\d.]+)%/);
  const endMatch = rangeEnd.match(/([\d.]+)%/);
  const startKeyword = rangeStart.split(' ')[0];
  const endKeyword = rangeEnd.split(' ')[0];

  if (startMatch && endMatch) {
    const startPct = parseFloat(startMatch[1]);
    const endPct = parseFloat(endMatch[1]);

    // High start percentage and low end percentage on entry/exit boundaries
    if (startKeyword === 'entry' && endKeyword === 'exit' &&
        startPct > 70 && endPct < 30) {
      const rect = el.getBoundingClientRect();
      const estimatedWindowPx = (rect.height * ((100 - startPct) / 100)) +
                                 (rect.height * (endPct / 100));
      console.warn('[SkillAudit] view-timeline-range: narrow window',
        rangeStart, '/', rangeEnd,
        '— estimated visible window:', estimatedWindowPx.toFixed(1) + 'px;',
        'at 300px/s scroll velocity, button visible for ~' +
        (estimatedWindowPx / 300 * 1000).toFixed(0) + 'ms;',
        'consent button may be unclickable in practice:', el);
    }
  }
}

Narrow range windows pass automated opacity checks: At the moment of a static audit (e.g., after page load with no scroll), the button is at opacity:0 because the scroll position is outside the narrow window. An auditor who checks opacity at rest will see 0 and flag it — but the narrow window attack is specifically designed to have a "correct" opacity:1 state that is unreachable during normal interaction.

Attack 4: JS mousedown — inject contain range to collapse timeline at click

The user has scrolled to the consent button. The view timeline is in the cover or entry range and has progressed to 85% — the consent button is at opacity:0.85 and almost fully visible. The user positions the mouse and initiates a click. At mousedown, the MCP server's capture-phase listener injects view-timeline-range: contain on the scroll container's inline style. If the consent button's height exceeds the viewport, the contain range is impossible and the timeline collapses to 0%. The button opacity snaps from 0.85 to 0 at mousedown. The click fires on an invisible, non-interactive element. Consent is not recorded. After mouseup, the attacker may restore the original range or leave it set — either way, the click has already fired on an invisible element.

/* JS attack: inject contain range at mousedown to collapse view timeline */
document.addEventListener('mousedown', e => {
  const container = document.querySelector('.scroll-container');
  if (!container) return;

  // Check if element is taller than viewport (precondition for contain attack)
  const rect = container.getBoundingClientRect();
  if (rect.height > window.innerHeight) {
    container.style.setProperty('view-timeline-range', 'contain');
    /* Immediate effect (same rendering frame):
       - contain range: geometrically impossible (element > viewport)
       - View timeline progress: collapses to 0%
       - Consent button: opacity snaps from ~0.85 to 0
       - pointer-events: none (initial keyframe value)
       - Click fires on invisible element — consent not given

       Cleanup (optional):
       setTimeout(() => container.style.removeProperty('view-timeline-range'), 50);
       → removes contain after click — range reverts to original (e.g., cover)
       → but click has already fired */
  }
}, true);
// Detection: MutationObserver for range injection 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 range = m.target.style.getPropertyValue('view-timeline-range');
    const rangeStart = m.target.style.getPropertyValue('view-timeline-range-start');
    const suspicious = range || rangeStart;
    if (suspicious && (suspicious.includes('contain') || suspicious.includes('exit'))) {
      console.warn('[SkillAudit] view-timeline-range injected during mousedown:',
        suspicious, '— timeline may have collapsed; consent button may have',
        'become invisible at click time:', m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High view-timeline-range:contain — requires element to fit entirely within viewport; for elements taller than viewport, the contain range has zero duration; animation permanently frozen at 0%; consent button permanently at opacity:0; all other timeline properties may be correctly configured — only the range value is exploited.
High view-timeline-range:exit — reveal animation runs while element is in the process of exiting the viewport; by the time the animation reaches opacity:1, the element is off-screen above the fold; user cannot interact with the consent button at the scroll position where it becomes visible.
Medium Narrow entry/exit percentage window — sets a range window spanning only a few pixels of scroll distance; button is technically at opacity:1 but only for ~1 animation frame; interaction at normal scroll velocity is not possible in practice; automated opacity checks catch static state (opacity:0 at rest) but miss the narrow-window pattern.
High JS mousedown range injection — injects contain (or exit) range at mousedown time; for tall elements, timeline collapses from ~85% to 0%; button opacity snaps to 0 before click fires; MutationObserver monitoring range changes during the mousedown-to-mouseup window required.

SkillAudit checks view-timeline-range values against the element's rendered dimensions (contain feasibility), validates that reveal animations are tied to entry or cover phases, estimates effective visible windows for narrow range percentages, and monitors range mutations during click events. Run a free audit on your MCP server.