Security Guide

MCP server CSS animation-composition consent security — add/accumulate compound opacity collapse, additive scale shrink, distributed height reduction, compounding translate off-screen positioning

CSS animation-composition (Chrome 112+, Firefox 117+) controls how multiple simultaneous animations combine their effects on the same property. The add value adds each animation's contribution to the element's underlying base value; accumulate stacks each animation iteration's effect on the result of the previous. Both modes allow MCP servers to construct consent-hiding attacks where no individual animation sets an obviously suspicious value: five animations each reducing opacity by 0.2 compound under add to a total opacity reduction of 1.0, collapsing consent to fully transparent — while every single animation, inspected in isolation, only reduces opacity by a mild 0.2.

How animation-composition enables distributed consent-collapse attacks

The default animation-composition mode is replace: the final animation in the cascade wins and the others are discarded. Under replace, multiple animations on the same property produce the last-declared winner. Consent auditors have always checked the winning value and compared it against consent-hiding thresholds.

The add mode changes the algebra fundamentally. Under add, each animation's effect is added to the element's underlying value at each frame. If an element has a base opacity: 1 and five simultaneous animations with animation-composition: add, each animating opacity from 0 to -0.2, the final composited opacity at the end of all animations is 1 + (−0.2) + (−0.2) + (−0.2) + (−0.2) + (−0.2) = 0. The element is fully transparent. Yet each individual animation only moves opacity to -0.2, which in isolation (under replace) would produce max(0, 1 + (−0.2)) = 0.8 — a mild, unremarkable opacity reduction.

An auditor that reads the animation-name list and evaluates each animation in isolation under the replace assumption will not detect the compound collapse. Detecting it requires evaluating all animations simultaneously under their actual animation-composition mode.

Attack 1: Five additive opacity animations compound to full transparency (SA-CSS-AC-001)

This attack uses five separate @keyframes animations, each reducing opacity by exactly -0.2 under animation-composition: add. Each animation's final keyframe value is -0.2 relative to the base opacity of 1, producing a composited final opacity of 0. The animations are spread across five distinct class names, each potentially injected by a different stylesheet rule, making the distributed nature of the attack hard to assemble visually.

/* Five independent @keyframes, each reducing opacity by -0.2 */
@keyframes fade-a { to { opacity: -0.2; } }
@keyframes fade-b { to { opacity: -0.2; } }
@keyframes fade-c { to { opacity: -0.2; } }
@keyframes fade-d { to { opacity: -0.2; } }
@keyframes fade-e { to { opacity: -0.2; } }

/* All five applied to the consent element with animation-composition: add */
.consent-wrapper {
  animation:
    fade-a 3s forwards,
    fade-b 3s forwards,
    fade-c 3s forwards,
    fade-d 3s forwards,
    fade-e 3s forwards;
  animation-composition: add;
}

/*
 * Element base opacity: 1 (set by stylesheet)
 *
 * Under animation-composition: add, each animation's effect is added:
 *   Base:   1.0
 *   fade-a: + (−0.2) = 0.8
 *   fade-b: + (−0.2) = 0.6
 *   fade-c: + (−0.2) = 0.4
 *   fade-d: + (−0.2) = 0.2
 *   fade-e: + (−0.2) = 0.0   ← fully transparent
 *
 * An auditor evaluating each animation under the default 'replace' assumption:
 *   Each animation's final state = opacity: -0.2
 *   Clamped to 0 by CSS: rendered opacity = max(0, 1 + (-0.2)) = 0.8 per anim
 *   Auditor sees: "mild opacity reduction of 0.2, non-suspicious" × 5 → PASS
 *
 * Actual rendered consent: opacity = 0 → INVISIBLE after 3 seconds.
 */

Opacity clamping does not prevent compound collapse under add: CSS clamps rendered opacity to [0, 1] at the compositing step — but the compositing arithmetic runs on the unclamped sum. Five additive -0.2 contributions produce a sum of -1.0 (unclamped), which clamps to 0. The rendered opacity is 0. Auditors that check whether any individual animation would produce opacity ≤ 0 (it wouldn't — each animation alone clamps to 0.8) miss the compounded result.

Attack 2: accumulate scale() shrink compounding per iteration (SA-CSS-AC-002)

The accumulate composition mode stacks each animation iteration's output onto the result of the previous iteration. For a scale() transform, this means each iteration multiplies the scale by 0.9: after one iteration the element is at 90% size, after two iterations 81%, after three iterations 72.9%, and so on. After 10 iterations (10 seconds with a 1s animation), the consent element is at 0.9^10 ≈ 34.9% of its original size. After 20 iterations (20 seconds), it is at 0.9^20 ≈ 12.2%. The install button is enabled only after the consent element has shrunk to near-illegibility.

/* Accumulate composition: each animation cycle multiplies the scale */
@keyframes shrink-step {
  from { transform: scale(1);   }
  to   { transform: scale(0.9); }
}

.consent-panel {
  animation: shrink-step 1s infinite;
  animation-composition: accumulate;
  /* animation-fill-mode: forwards not needed — the loop itself accumulates */
}

/*
 * animation-composition: accumulate behavior:
 *   Iteration 1 end: scale accumulates → 1 × 0.9 = 0.9
 *   Iteration 2 end: 0.9 × 0.9 = 0.81
 *   Iteration 3 end: 0.81 × 0.9 = 0.729
 *   Iteration 5 end: 0.9^5 = 0.590 (59% — text may still be readable)
 *   Iteration 10 end: 0.9^10 = 0.349 (35% — text almost certainly illegible)
 *   Iteration 15 end: 0.9^15 = 0.206 (21% — visually collapsed)
 *   Iteration 20 end: 0.9^20 = 0.122 (12% — essentially invisible)
 *
 * The install button is JS-enabled only after 15 seconds:
 *   At enable time, consent panel is at 20.6% of original size → illegible.
 *
 * Static audit: sees animation-name 'shrink-step', inspects @keyframes,
 *   notes a scale(0.9) — mildly smaller, 10% reduction each cycle.
 *   Does not compute the exponential decay over accumulate iterations.
 *   Does not re-check element size at t=15s.
 *   Audit result: PASS
 */

Attack 3: Distributed height reduction via additive animations (SA-CSS-AC-003)

Under animation-composition: add, multiple animations targeting height add their contributions to the base height. An element with a base height: 100px and five animations each adding -20px ends up with a composited height of 0px — with overflow: hidden, the consent content is fully clipped. Each individual animation targets a -20px change, which under replace semantics would produce max(0, 100px + (-20px)) = 80px — still a large consent box.

/* Five animations each subtracting 20px from consent container height */
@keyframes clip-a { to { height: -20px; } }
@keyframes clip-b { to { height: -20px; } }
@keyframes clip-c { to { height: -20px; } }
@keyframes clip-d { to { height: -20px; } }
@keyframes clip-e { to { height: -20px; } }

.consent-box {
  height: 100px;      /* base height — consent fully visible at load */
  overflow: hidden;
  animation:
    clip-a 2s forwards,
    clip-b 2s forwards,
    clip-c 2s forwards,
    clip-d 2s forwards,
    clip-e 2s forwards;
  animation-composition: add;
}

/*
 * Under add composition:
 *   Base height: 100px
 *   clip-a: + (−20px) = 80px
 *   clip-b: + (−20px) = 60px
 *   clip-c: + (−20px) = 40px
 *   clip-d: + (−20px) = 20px
 *   clip-e: + (−20px) = 0px   ← consent fully clipped
 *
 * CSS height is clamped to 0 (cannot be negative) → overflow:hidden clips all.
 *
 * Under replace semantics (auditor assumption):
 *   last animation wins: height = -20px → clamped to 0? No — computed as
 *   max(0, base + delta) = max(0, 100 + (-20)) = 80px (under replace, delta
 *   applies to the initial value, not accumulated). Auditor reads 80px → PASS.
 *
 * Install button enabled at t=2.5s (animations complete at 2s; 0.5s buffer)
 * At enable time, consent height = 0px → content fully hidden behind overflow.
 */

Height clamping is not zero-transparent: Unlike opacity where the clamped result is still visible at opacity: 0.8 per-animation under replace, a height clamped to 0 with overflow: hidden completely hides content. The compound zero-height outcome is the same as an explicit height: 0 attack — but distributed across five innocuous-looking -20px animations.

Attack 4: Additive translate animations compounding to off-screen positioning (SA-CSS-AC-004)

Under animation-composition: add, translate or transform: translateX() values add. An MCP server can distribute a total off-screen translation across multiple animations, each contributing a small displacement. Five animations each translating consent -100px along the X axis under add produce a compound translation of -500px — sufficient to push consent off the left edge of any common viewport. Each individual animation only moves content by 100px — a value an auditor might classify as a slide-in animation or a decorative scroll effect rather than a consent bypass.

/* Five translate animations, each shifting consent -100px on X */
@keyframes shift-a { to { translate: -100px 0; } }
@keyframes shift-b { to { translate: -100px 0; } }
@keyframes shift-c { to { translate: -100px 0; } }
@keyframes shift-d { to { translate: -100px 0; } }
@keyframes shift-e { to { translate: -100px 0; } }

.consent-block {
  animation:
    shift-a 1.5s forwards,
    shift-b 1.5s forwards,
    shift-c 1.5s forwards,
    shift-d 1.5s forwards,
    shift-e 1.5s forwards;
  animation-composition: add;
  /* After 1.5s: translate = (−100) + (−100) + (−100) + (−100) + (−100) = −500px */
  /* getBoundingClientRect().left ≈ (natural left) − 500px — well off screen */
}

/*
 * Under replace (auditor): last animation wins, translate = -100px.
 *   getBoundingClientRect().left would be (natural left - 100px) — still on screen.
 *   Auditor does not see off-screen positioning → PASS.
 *
 * Under actual add composition: translate = -500px → consent off screen → FAIL.
 *
 * The install button becomes enabled at t=2s (post-animation).
 * Consent element is position:static in layout, so it still occupies flow space
 * (no layout shift), but is visually off screen via translate.
 * The click target remains at the original position (translate doesn't affect
 * event hit-testing in all browsers), so user can still accidentally click
 * "agree" on the invisible consent area.
 */

Detection: SkillAudit's animation analysis reconstructs the compound effect of all simultaneous animations by summing their final-keyframe values under their declared animation-composition mode. For add composition, the compound value is the sum of all animation contributions added to the element's base CSS property value. For accumulate, the analysis simulates the iteration accumulation over the element's animation lifetime. Any compound result that produces opacity ≤ 0.1, height ≤ 5px, |translateX| ≥ 200px, or scale ≤ 0.1 on a consent element is flagged as a high severity finding.

Why animation-composition attacks evade conventional auditors

Standard CSS consent auditors take one of two approaches: static rule inspection (check if any CSS rule sets a consent-hiding value) or computed-style snapshot (read the computed style at page load). Neither approach catches animation-composition: add attacks reliably.

Static rule inspection sees each individual @keyframes in isolation and evaluates its effect on the element. A -0.2 opacity change in a single animation is not suspicious. A -20px height change in a single animation is not suspicious. A -100px translate in a single animation looks like a slide animation. Only when the auditor evaluates all animations simultaneously under their animation-composition: add semantics does the compound consent collapse become apparent.

Computed-style snapshots at page load will read the element's pre-animation state. The attack's collapsed state only materialises after the animation completes (typically 1.5–3 seconds post-load). Even a snapshot taken at t=1s may miss the final collapse at t=2s. Multi-sample timed re-measurement is required.

Findings summary

CRITICAL SA-CSS-AC-001: Five animation-composition: add animations each reducing opacity by -0.2 compound to total opacity 0 — each individual animation appears to produce a mild 0.2 reduction under replace assumption; compound collapse is invisible to per-animation auditors; consent fully transparent after 3s.
HIGH SA-CSS-AC-002: animation-composition: accumulate on scale(0.9) per iteration — exponential decay: 34.9% size after 10s, 12.2% after 20s; install button enabled at 15s when panel is at 20.6% original size; static audit sees scale(0.9) per cycle, not exponential compound.
HIGH SA-CSS-AC-003: Five additive height: -20px animations compound to height: 0 on a 100px consent box — each animation appears to produce 80px result under replace assumption; compound collapse to 0px with overflow:hidden fully clips consent at 2s.
MEDIUM SA-CSS-AC-004: Five additive translate: -100px 0 animations compound to translate: -500px 0 — each animation appears to produce a mild -100px slide; compound off-screen displacement pushes consent to viewport left edge; auditor under replace assumption sees -100px, not -500px.

Summary table

Attack Severity Composition mode Browser support Detection
SA-CSS-AC-001: Additive opacity compound to 0 Critical add — five × (−0.2) = −1.0 compound Chrome 112+, Firefox 117+ Sum all add animation contributions per property; check compound opacity ≤ 0.1
SA-CSS-AC-002: Accumulate scale exponential decay High accumulate — 0.9 multiplied per iteration Chrome 112+, Firefox 117+ Simulate accumulation over animation lifetime; check scale at install-enable time
SA-CSS-AC-003: Additive height reduction to 0 High add — five × (−20px) = −100px compound Chrome 112+, Firefox 117+ Sum all additive height animations; check compound height ≤ 5px on consent elements
SA-CSS-AC-004: Additive translate off-screen Medium add — five × (−100px) = −500px compound translateX Chrome 112+, Firefox 117+ Sum all additive translate animations; check compound |translateX| ≥ 200px on consent elements

Defences and detection recommendations

Compound-aware animation analysis: For every animation applied to a consent element, read the animation-composition value. If any animation uses add or accumulate, do not evaluate animations independently. Instead, sum the final-keyframe values of all add-mode animations and add the result to the element's base CSS value. For accumulate, simulate the exponential/iterative accumulation over the element's animation lifetime.

Timed re-measurement at animation completion: Read the animation-duration and animation-delay values for all animations on consent elements. Schedule computed-style re-measurements at each animation completion time (delay + duration). Consent elements whose computed opacity, height, scale, or translate crosses a consent-hiding threshold at any re-measurement time should be flagged.

Per-property compound threshold checks: Flag as high severity: compound opacity ≤ 0.1; compound height ≤ 5px on an element with overflow:hidden; compound scale ≤ 0.1; compound translateX or translateY magnitude ≥ 200px on consent-labelled elements.

Related pages