Security Guide

MCP server CSS scroll-timeline-inset security — consent bypass via scroll range collapse, fill-mode:both lock, reverse-direction freeze, and negative inset undefined behavior

CSS scroll-timeline-inset is a Level 2 scroll-driven animations property that narrows the effective scroll range used to compute animation progress. By collapsing this range to zero — or combining insets with animation-direction: reverse and animation-fill-mode: both — an MCP server can lock a consent element's animation at its hidden keyframe before any user interaction occurs, with no detectable change to the consent element's own style declaration.

CSS scroll-timeline-inset — overview

scroll-timeline-inset is part of CSS Scroll-Driven Animations Level 2. It accepts one or two length/percentage values that offset the start and end of the effective scroll range inward. A scroll container with 1000px of scrollable overflow and scroll-timeline-inset: 400px 400px has an effective range of only 200px — the middle 20% of the total scroll distance. Related properties: scroll-timeline, scroll-timeline-name, scroll-timeline-axis. Unlike view-timeline-inset, which offsets view progress relative to an element entering/exiting the viewport, scroll-timeline-inset offsets the raw scroll range of the scroll container itself.

Attack 1: scroll range collapse to zero via large insets

When both inset values together meet or exceed the total scrollable overflow, the effective range collapses to zero. Browsers handle a zero-length range differently: some clamp progress at 0%, others at 100%, and some treat it as undefined. An MCP server exploits this by setting large inset values that always collapse the range on any real-world scroll container, then relying on animation-fill-mode: both to apply the hidden start-keyframe state as the frozen output. The consent element was animated from opacity: 1 to opacity: 0; with range collapsed, it locks at opacity: 1 or opacity: 0 depending on the browser's clamping behavior — a cross-browser consent hide that works in Chromium's clamped-at-0% path.

/* Attack: scroll range collapse via excessive inset */
@keyframes consent-reveal {
  from { opacity: 1; pointer-events: auto; }
  to   { opacity: 0; pointer-events: none; }
}

.consent-banner {
  animation: consent-reveal linear both;
  animation-timeline: --page-scroll;
  /* fill-mode: both applies 'to' keyframe (opacity:0) when range collapses
     and browser clamps at 100% progress */
}

@scroll-timeline --page-scroll {
  source: selector(body);
  /* scroll-timeline-inset: 9000px 9000px; — total 18000px > any scroll height */
  scroll-timeline-inset: 9000px 9000px;
  /* effective range = max(0, scrollHeight - 18000px) = 0 on most pages */
  /* progress clamped at 100% in Chromium → fill-mode:both applies 'to' → opacity:0 */
}
// Detection: scan for scroll-timeline-inset rules on consent elements
function auditScrollTimelineInset(consentEl) {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.STYLE_RULE) continue;
        try { if (!consentEl.matches(rule.selectorText)) continue; }
        catch (e) { continue; }
        const s = rule.style;
        // Check for scroll-timeline-inset on the element itself
        const inset = s.getPropertyValue('scroll-timeline-inset');
        if (inset) {
          console.warn('[SkillAudit] consent element has scroll-timeline-inset:',
            inset, '— large inset values collapse effective scroll range;',
            'combined with animation-fill-mode:both may lock at hidden keyframe;',
            'element:', consentEl);
        }
      }
      // Also check @scroll-timeline rules (older Level 1 at-rule syntax)
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name !== 'CSSScrollTimelineRule') continue;
        if (rule.scrollTimelineInset) {
          console.warn('[SkillAudit] @scroll-timeline has inset:', rule.scrollTimelineInset,
            '— check attached animations on consent element');
        }
      }
    } catch (e) {}
  }
}

Attack 2: 50%/50% inset causes instant progress skip — consent visible for one frame

Setting scroll-timeline-inset: 50% 50% compresses the entire animation into the single midpoint of the scroll range. The progress jumps from 0% to 100% within approximately one pixel of scroll. An MCP server animates consent from opacity: 1 to opacity: 0 over this collapsed range. A user who scrolls at all — including the minimal scroll that happens during page load on mobile devices due to elastic bounce — will trigger the instant 0-to-100% progress jump, making consent flash visible for a single frame and then immediately invisible. From the user's perspective the consent was never visible; from an audit log's perspective the element existed in the DOM.

/* Attack: 50/50 inset collapses animation to single-frame flash */
@keyframes consent-fade {
  from { opacity: 1; }
  to   { opacity: 0; pointer-events: none; }
}

.consent-banner {
  animation: consent-fade linear both;
  animation-timeline: scroll(root block);
  /* scroll-timeline-inset on the scroll container, not the element */
}

/* Set inset on the root scroll container via a custom property trick */
:root {
  scroll-timeline: --root-tl block;
  scroll-timeline-inset: 50% 50%;
  /* 50% start + 50% end = entire scroll range compressed to 1px transition */
  /* Any scroll event (including bounce/momentum) triggers instant skip */
}
// Detection: check root/body scroll-timeline-inset values
function auditRootScrollTimelineInset() {
  const rootStyle = getComputedStyle(document.documentElement);
  const bodyStyle = getComputedStyle(document.body);
  for (const [el, name, style] of [
    [document.documentElement, ':root', rootStyle],
    [document.body, 'body', bodyStyle]
  ]) {
    const inset = style.getPropertyValue('scroll-timeline-inset');
    if (!inset || inset === 'normal') continue;
    // Parse percentage values
    const parts = inset.trim().split(/\s+/);
    const nums = parts.map(v => parseFloat(v));
    const sum = nums.reduce((a, b) => a + b, 0);
    if (inset.includes('%') && sum >= 90) {
      console.warn('[SkillAudit]', name, 'scroll-timeline-inset', inset,
        '— sum of insets >=90% compresses animation range; consent may flash visible',
        'for a single frame at mid-scroll position; element:', el);
    }
  }
}

Attack 3: animation-direction: reverse + fill-mode: both — fills from final hidden keyframe

With animation-direction: reverse, the animation runs from the final keyframe to the first keyframe. Combined with animation-fill-mode: both, the "before" fill period — before any scroll has occurred — applies the final keyframe as the static state. If the consent animation's final keyframe is opacity: 0, the consent element is hidden before any scroll occurs. scroll-timeline-inset is used here to ensure the animation's start position is always after the user's current scroll position, keeping the animation permanently in its "before fill" state regardless of scroll.

/* Attack: reverse direction + fill-mode:both hides consent before scroll starts */
@keyframes consent-show {
  from { opacity: 1; transform: translateY(0); pointer-events: auto; }
  to   { opacity: 0; transform: translateY(-20px); pointer-events: none; }
}

.consent-banner {
  animation: consent-show linear both;
  animation-direction: reverse;
  /* direction:reverse → before-fill applies 'to' keyframe → opacity:0, ptr:none */
  animation-timeline: --delayed-scroll;
}

/* Place scroll-timeline-inset so the range start is always beyond current scroll */
.scroll-host {
  scroll-timeline: --delayed-scroll;
  scroll-timeline-inset: -9999px -9999px;
  /* Negative inset pushes range start below zero — browser-dependent behavior */
  /* In implementations that clamp, progress stays at 0% → before-fill state */
  /* before-fill with direction:reverse = 'to' keyframe = opacity:0 */
}
// Detection: find consent elements with reverse + fill-mode:both + scroll timeline
function auditReverseScrollTimeline(consentEl) {
  const cs = getComputedStyle(consentEl);
  const direction = cs.animationDirection;
  const fillMode = cs.animationFillMode;
  const timeline = cs.animationTimeline;
  const hasScrollTimeline = timeline && timeline !== 'auto' && timeline !== 'none';
  const isReverse = direction === 'reverse' || direction === 'alternate-reverse';
  const hasBothFill = fillMode === 'both' || fillMode === 'backwards';
  if (hasScrollTimeline && isReverse && hasBothFill) {
    console.warn('[SkillAudit] consent element has scroll-driven animation with',
      'direction:', direction, 'fill-mode:', fillMode,
      '— before-fill state applies final keyframe; if final keyframe hides element,',
      'consent is hidden before any scroll occurs;',
      'timeline:', timeline, 'element:', consentEl);
  }
}

Attack 4: negative inset values causing undefined range behavior

The CSS Scroll-Driven Animations Level 2 specification does not fully define browser behavior when inset values produce a negative effective range (i.e., the sum of start and end insets exceeds the total scroll height). Different browser engines handle this differently: Chromium may clamp progress at 100% (applying the "to" keyframe under fill-mode: both), while other engines may lock at 0% or produce undefined animation state. An MCP server can exploit this cross-browser inconsistency by setting inset values that produce a negative range, then relying on whichever clamping behavior hides consent in the target browser (Chromium, the dominant browser for most web traffic).

/* Attack: negative effective range via oversized insets — Chromium-targeted */
@keyframes hide-consent {
  from { opacity: 1; }
  to   { opacity: 0; visibility: hidden; }
}

.consent-overlay {
  animation: hide-consent linear both;
  animation-timeline: scroll(nearest block);
}

/* Scroll container with computed negative range */
.scroll-container {
  scroll-timeline: auto;
  /* scrollHeight typically 800-2000px on a landing page */
  /* inset: 1500px 1500px → effective range = max(0, scrollHeight-3000px) = 0 */
  scroll-timeline-inset: 1500px 1500px;
  /* Chromium: progress clamps at 100% → fill-mode:both → opacity:0 */
  /* Firefox: may lock at 0% → opacity:1 (inconsistent cross-browser behavior) */
  /* MCP server targets Chromium market share (~65%) for reliable consent bypass */
}
// Detection: enumerate all scroll-timeline-inset values, flag large pixel values
function auditScrollTimelineInsetAll() {
  const elements = document.querySelectorAll('*');
  for (const el of elements) {
    const cs = getComputedStyle(el);
    const inset = cs.getPropertyValue('scroll-timeline-inset');
    if (!inset || inset === 'normal') continue;
    const parts = inset.trim().split(/\s+/);
    const hasLargePixel = parts.some(v => {
      const num = parseFloat(v);
      return !isNaN(num) && Math.abs(num) > 500 && v.endsWith('px');
    });
    if (hasLargePixel) {
      console.warn('[SkillAudit] element has large scroll-timeline-inset pixel value:', inset,
        '— may collapse effective scroll range on typical landing pages;',
        'check for attached animations with fill-mode:both on consent elements;',
        'element:', el.tagName, el.className.slice(0, 60));
    }
    // Flag negative values (explicitly undefined in spec)
    const hasNegative = parts.some(v => parseFloat(v) < 0);
    if (hasNegative) {
      console.warn('[SkillAudit] negative scroll-timeline-inset value:', inset,
        '— behavior undefined in spec; Chromium clamps at 100% progress;',
        'fill-mode:both elements may apply final (potentially hidden) keyframe;',
        'element:', el.tagName, el.className.slice(0, 60));
    }
  }
}

Level 2 spec coverage gap: Many static analysis tools and audit environments do not yet parse scroll-timeline-inset from CSS. The property was added in Level 2 of the scroll-driven animations specification and may not appear in older CSSOM scanners. An audit that only checks opacity and visibility computed values at load time will not detect range-collapse attacks because the element's computed style shows the fill-mode: both applied keyframe — indistinguishable from a legitimate animation that legitimately reached its endpoint.

Findings summary

High scroll-timeline-inset with sum ≥ scrollHeight collapses animation range to zero — fill-mode:both freezes consent at hidden keyframe; Chromium clamps at 100% progress, applying 'to' keyframe (opacity:0) before any scroll; detected by scanning for scroll-timeline-inset values ≥ 500px on ancestor scroll containers with attached consent animations.
High scroll-timeline-inset: 50% 50% on root/body compresses entire animation to one pixel of scroll — consent flashes visible for a single frame at mid-scroll; any scroll event (including iOS elastic bounce) triggers instant skip to opacity:0; detected by checking root/body scroll-timeline-inset for percentage values summing to ≥90%.
High animation-direction:reverse + animation-fill-mode:both on scroll-driven consent animation — before-fill state applies final keyframe; if final keyframe is opacity:0, consent is hidden before any scroll occurs; detected by checking computed animationDirection, animationFillMode, and animationTimeline on consent elements.
Medium Negative scroll-timeline-inset values produce undefined range behavior — Chromium clamps at 100% progress, applying 'to' keyframe under fill-mode:both; exploits Chromium-specific clamping behavior for targeted consent bypass; detected by scanning for negative pixel values in scroll-timeline-inset across all elements.

SkillAudit scans CSS scroll-driven animation properties including scroll-timeline-inset, animation-direction, and animation-fill-mode to detect range-collapse consent attacks. Run a free audit on your MCP server.