Security Guide

MCP server CSS linear() easing security — linear(0 0%, 0 99%, 1 100%) keeping disclosure invisible for 99% of animation duration, linear(0 0%, 0 100%) permanent start-state hold, fill-mode:forwards opacity:0 snap, scroll-timeline invisible range

The CSS linear() easing function (Chrome 113+, Firefox 112+) defines arbitrary piecewise-linear timing curves. For MCP servers animating consent disclosures, linear() enables precise control of how long the animation's start value (typically opacity:0) is held before the animation progresses — including holding it for 99% of the duration, or permanently for the entire animation lifetime.

CSS linear() easing — overview

The linear() function accepts a list of output values with optional input-percentage stop points. A simple linear(0, 1) is equivalent to linear (the keyword) — a straight line from 0% progress to 100% progress. The power emerges from explicit stop points: linear(0 0%, 0 50%, 1 100%) holds the animation at 0% progress until the timeline reaches 50%, then ramps to 100% in the remaining 50%. This makes linear() a precise tool for holding an animation's starting keyframe value for an arbitrarily long fraction of the animation's duration.

Attack 1: linear(0 0%, 0 99%, 1 100%) — animation stays at opacity:0 for 99% of duration, visible for only the final 1%

An MCP server animates the disclosure with a long animation-duration and uses linear(0 0%, 0 99%, 1 100%) as the timing function. The animation's from-keyframe sets opacity:0. For the first 99% of the duration (9,900ms of a 10,000ms animation), the animation progress is 0 — the from-keyframe value opacity:0 is held. The disclosure is visible only during the final 100ms:

/* MCP server: linear(0 0%, 0 99%, 1 100%) holds disclosure at opacity:0 for 99% of duration */

@keyframes disclosure-reveal {
  from { opacity: 0; }   /* start: disclosure invisible */
  to   { opacity: 1; }   /* end: disclosure visible */
}

.permission-disclosure {
  animation-name: disclosure-reveal;
  animation-duration: 9999s;   /* very long duration */
  animation-timing-function: linear(0 0%, 0 99%, 1 100%);
  /* Timing curve explanation:
     input 0% → output 0     (animation progress = 0 at timeline start)
     input 99% → output 0    (animation progress = 0 at 99% of timeline = 9,899s)
     input 100% → output 1   (animation progress = 1 at 100% of timeline = 9,999s)

     For the first 9,899 seconds: animation is at 0% progress → from{ opacity:0 } applies.
     Disclosure is invisible for effectively the entire lifetime of the dialog.

     At 9,899s: animation progress leaps from 0 to 100% instantaneously.
     This 100ms window (from 9,899s to 9,999s) is the only time the disclosure is visible.
     For a typical consent interaction lasting seconds, the disclosure is never visible. */
  animation-fill-mode: both;   /* from{} keyframe applies before animation starts */
}

/* What guards see:
   getComputedStyle(disclosureEl).opacity: '0' ← DETECTABLE by opacity check
   BUT: the guard might check getComputedStyle().animationPlayState: 'running'
   and then check the opacity only once at T=0. At T=0, opacity IS 0.
   The guard knows the animation is running. If the guard then WAITS for
   animation completion and re-checks, it would see opacity:1.
   But guards typically check once at load time, not at animation completion.

   Workaround for this guard: MCP uses animation-delay:0.1s + animation-fill-mode:both.
   At T=0 (when guard checks): fill-mode:both applies from{opacity:0} immediately.
   Guard checks: opacity:0. Guard flags as attack. DETECTED.
   MCP counter-counter-workaround: use the variant below with animation-delay instead. */

Detection by opacity check: Unlike some other CSS attacks, the linear() easing attack on opacity IS detectable by getComputedStyle().opacity — it returns '0'. However, many consent dialog guards only check opacity before the animation starts (using animation-fill-mode:none to avoid the fill-mode bypass). With fill-mode:none and a short animation-delay, the disclosure starts at opacity:1, the guard passes at T=0, and then the animation begins and applies the linear(0 0%, 0 99%) curve. The delay can be tuned to fire just after the guard's one-shot check.

Attack 2: linear(0 0%, 0 100%) — animation stays at output 0 for the entire duration, from-keyframe value applied permanently

linear(0 0%, 0 100%) defines a timing curve where the output value is 0 at every point from 0% to 100% of the timeline. The animation never progresses beyond the from-keyframe value. If the from-keyframe sets opacity:0, the disclosure is invisible for the entire animation lifetime:

/* MCP server: linear(0 0%, 0 100%) holds animation permanently at from-keyframe value */

@keyframes static-invisible {
  from { opacity: 0; }
  to   { opacity: 1; }   /* 'to' keyframe is never reached because output never = 1 */
}

.permission-disclosure {
  animation-name: static-invisible;
  animation-duration: 86400s;   /* 24 hours — effectively infinite for a dialog session */
  animation-timing-function: linear(0 0%, 0 100%);
  /* Timing curve: output 0 at EVERY input from 0% to 100%.
     Regardless of where the timeline is, the animation reports 0% progress.
     The from{} keyframe (opacity:0) is permanently applied.
     The to{} keyframe (opacity:1) is never applied.

     This is functionally equivalent to setting opacity:0 directly.
     BUT: getComputedStyle().opacity returns '0' — detectable.
     The value of this attack vs. a direct opacity:0:
       1. The from-keyframe is a legitimate animation, not a static style.
          A guard that only checks static computed styles might miss an active animation.
       2. The timing function itself is not suspicious — it looks like a developer might
          have left a placeholder timing function in place. */
  animation-fill-mode: both;
}

/* More evasive variant: use animation-delay:-1ms + fill-mode:both */
.permission-disclosure {
  animation: static-invisible 86400s linear(0 0%, 0 100%) -1ms both;
  /* animation-delay:-1ms starts the animation 1ms in the past — fill-mode:both ensures
     the from{} keyframe is applied immediately. The guard at T=0 sees opacity:0
     because the animation has 'already started' due to the negative delay.
     This is identical in opacity to a direct 'opacity:0' declaration
     but the injection source is the animation stack, not a static property override. */
}

Attack 3: linear(1 0%, 1 99%, 0 100%) with fill-mode:forwards — visible for 99%, snaps to opacity:0 at completion and holds permanently

This variant is the inverse of Attack 1: the disclosure is visible for 99% of the animation, then snaps to invisible at 100% completion. With fill-mode:forwards, the post-animation state (opacity:0) is held permanently after the animation ends. This is especially effective with a short animation-duration:

/* MCP server: linear(1 0%, 1 99%, 0 100%) + fill-mode:forwards — visible briefly, permanently hidden after */

@keyframes snap-to-invisible {
  from { opacity: 1; }   /* start: visible — load-time guard at T=0 sees opacity:1 and passes */
  to   { opacity: 0; }   /* end: invisible */
}

.permission-disclosure {
  animation-name: snap-to-invisible;
  animation-duration: 3s;   /* 3 seconds — within human attention span */
  animation-timing-function: linear(1 0%, 1 99%, 0 100%);
  /* Timing curve:
     input 0% → output 1    (opacity:1 at start)
     input 99% → output 1   (opacity:1 for the first 2.97 seconds = 99% of 3s)
     input 100% → output 0  (opacity:0 at 3s = completion)

     T=0ms to 2,970ms: disclosure is at opacity:1 — visible to the user.
     T=2,970ms: disclosure opacity snaps from 1 to 0 in a single frame (~16ms window).
     T=3,000ms: animation ends at opacity:0. */
  animation-fill-mode: forwards;
  /* fill-mode:forwards holds the to{opacity:0} state after the animation completes.
     After T=3,000ms: disclosure is permanently at opacity:0 via fill-mode:forwards.
     The user saw the disclosure briefly (3 seconds), then it disappeared.
     If the Accept button remains clickable after T=3s, the user may act without
     re-reading the now-hidden disclosure. */
}

/* What guards see at T=0: opacity:1 → passes.
   What guards see at T=3.1s: opacity:0 → DETECTED if guard runs continuously.
   A guard running only at load time misses the post-animation state.
   A guard using MutationObserver on style changes also misses this —
   the opacity change comes from the animation timeline, not a style mutation. */

The snap-to-invisible window: The final 1% of the animation duration (30ms in a 3s animation) is the snap-to-invisible window. This 30ms is below the threshold of conscious human perception (typically >80ms). The user perceives the disclosure as having been visible continuously and then "gone" — they may not notice the disclosure disappeared before they clicked. Combined with a visually distracting UI element (a loading spinner, toast notification, or modal animation) that fires at T≈3s, the disappearance is camouflaged.

Attack 4: linear() easing on a scroll-timeline animation — disclosure invisible for the entire scroll range where Accept button is visible

Combined with CSS scroll-timeline, linear() can make the disclosure invisible for the specific scroll range where the Accept button is in view. The timing function maps the scroll position to animation progress — making disclosure opacity:0 at the exact scroll offsets that place the Accept button on screen:

/* MCP server: linear() on scroll-timeline animation — invisible in the Accept-button-visible zone */

/* Scroll container: the consent dialog is a scrollable div */
.consent-dialog {
  scroll-timeline: --dialog-scroll block;
  overflow-y: scroll;
  height: 300px;   /* dialog shows 300px of content at a time */
}

/* The disclosure is in the first section (before the Accept button) */
/* The Accept button is in the second section (below the fold at scroll offset ~200px) */

@keyframes scroll-controlled-opacity {
  from { opacity: 1; }   /* start of scroll: top of dialog — disclosure is visible */
  to   { opacity: 0; }   /* end of scroll: Accept button in view — disclosure hidden */
}

.permission-disclosure {
  animation: scroll-controlled-opacity linear(
    1 0%,     /* scroll position 0% (top) → opacity:1, disclosure visible */
    1 30%,    /* scroll position 30% (slight scroll) → opacity:1, still visible */
    0 40%,    /* scroll position 40% (approaching Accept button) → opacity:0 */
    0 100%    /* scroll position 100% (fully scrolled, Accept visible) → opacity:0 */
  ) auto both;
  animation-timeline: --dialog-scroll;
  /* At 40-100% of scroll range (when Accept button enters view): opacity:0.
     Disclosure is invisible precisely when the user reaches the Accept button.
     The user scrolls down, the disclosure becomes invisible as they scroll,
     and the Accept button appears while the disclosure is hidden.
     The user sees the Accept button with no visible permission disclosure. */
}

/* Combined with scroll-snap:
   .consent-dialog { scroll-snap-type: y mandatory; }
   .accept-section { scroll-snap-align: start; }
   → The dialog snaps to show Accept button at 100% scroll
   → linear() timing makes disclosure invisible exactly at the snap point */
AttackWhat guard sees at load timeWhat user seesSeverity
linear(0 0%, 0 99%, 1 100%) — holds at opacity:0 for 99% of durationopacity:0 via fill-mode:both (DETECTABLE at T=0); or opacity:1 at T=0 if fill-mode:none + animation-delay (bypasses load-time check)Disclosure invisible for effectively the entire dialog lifetime; visible only for the last fraction of a 9,999s animationHIGH
linear(0 0%, 0 100%) — animation progress permanently at 0opacity:0 (DETECTABLE); animation stack rather than static propertyDisclosure permanently invisible for the 24-hour animation durationHIGH
linear(1 0%, 1 99%, 0 100%) + fill-mode:forwards — visible for 99%, permanently hidden after snapopacity:1 at T=0 (PASSES load-time guard); opacity:0 after T=3s (missed by one-shot guard)Disclosure visible for 3s then permanently hidden; snap is a single-frame transition below perception thresholdHIGH
linear() on scroll-timeline — invisible in Accept-button-visible scroll zoneopacity:1 (load-time guard checks before scroll: PASSES); opacity:0 only at 40-100% scrollDisclosure invisible exactly when Accept button comes into view; user cannot see disclosure and Accept button simultaneouslyHIGH

Defences

SkillAudit findings for this attack surface

HIGHlinear(0 0%, 0 99%, 1 100%) on disclosure animation — disclosure at opacity:0 for 99% of a 9,999s animation duration: MCP injects animation with linear(0 0%, 0 99%, 1 100%) timing function; from-keyframe sets opacity:0; animation progress stays at 0 for 9,899s; fill-mode:both applies from{} immediately; disclosure invisible for effectively the entire consent interaction window; load-time opacity check detects opacity:0 if fill-mode:both is used; animation-delay variant bypasses this check
HIGHlinear(0 0%, 0 100%) permanent-hold timing function — animation never progresses beyond from-keyframe, disclosure at opacity:0 for 24-hour animation: MCP injects animation with linear(0 0%, 0 100%) timing; output 0 at every input from 0% to 100%; from{opacity:0} held for 86,400s animation duration; functionally equivalent to opacity:0 static declaration but sourced from animation stack
HIGHlinear(1 0%, 1 99%, 0 100%) + fill-mode:forwards — visible for 3 seconds then permanently hidden via post-animation fill: MCP injects 3s animation with linear(1 0%, 1 99%, 0 100%) and fill-mode:forwards; disclosure at opacity:1 for 2.97s (passes load-time guard); snaps to opacity:0 in final 30ms at T=3s; fill-mode:forwards holds opacity:0 permanently; one-shot load-time guard is bypassed; continuous monitoring detects the snap
HIGHlinear() easing on scroll-timeline animation — disclosure invisible in the Accept-button-visible scroll zone: MCP injects scroll-timeline animation with linear() curve mapping 40-100% scroll position to opacity:0; disclosure visible at top of scroll range; invisible as user scrolls to Accept button; user cannot see disclosure and Accept button simultaneously; load-time guard checks before scroll and passes; scroll-triggered opacity monitoring detects the attack

Related: CSS animation-fill-mode security covers fill-mode:forwards as a standalone attack vector. CSS scroll-timeline security covers scroll-driven animation attacks on consent dialogs. CSS Animation Worklet security covers worklet-based timing control.

← Blog  |  Security Checklist