Security Guide

MCP server CSS animation shorthand security — multiple sub-property attacks in one declaration, paused+reverse combined bypass, iteration-count:0 hidden in shorthand position, JS mousedown cancels animation with single injection

The CSS animation shorthand encodes up to eight sub-properties in a single declaration: duration, timing-function, delay, iteration-count, direction, fill-mode, play-state, and name. Attackers encode multiple consent-bypass vectors atomically in one declaration, defeating audits that check sub-properties individually — and a single inline-style injection at mousedown can cancel the entire animation in one property change.

CSS animation shorthand — property overview

The animation shorthand accepts values in a specific order: <duration> <timing-function> <delay> <iteration-count> <direction> <fill-mode> <play-state> <name>. Omitted values revert to their initial values: 0s ease 0s 1 normal none running none. The order matters for distinguishing the two time values (duration always comes before delay). The name can appear first or last. Related: animation-play-state, animation-direction, animation-iteration-count, animation-duration.

Attack 1: multiple sub-property bypasses in one shorthand declaration

A single animation shorthand value can simultaneously encode play-state: paused, direction: reverse, and iteration-count: 0 — any one of which alone would prevent the consent button from becoming visible. Audits that check sub-properties individually (e.g., checking animation-play-state via getComputedStyle) will correctly return paused. But an audit designed to find one specific attack vector may stop after finding the first issue and not check the others. The shorthand encodes multiple redundant bypasses: if one is remediated (e.g., play-state is changed to running), the others (reverse direction, zero iteration count) remain active.

/* Attack: encode multiple bypass vectors in one shorthand */
.consent-btn {
  /* Legitimate animation for comparison:
     animation: 1s ease 0s 1 normal both running consent-reveal; */

  /* Attack version — three bypass vectors encoded atomically: */
  animation: 9999s ease 0s 0 reverse none paused consent-reveal;
  /*         ↑dur  ↑tf ↑dly↑cnt ↑dir ↑fill ↑state ↑name
             1: 9999s duration — takes 2.77 hours if it were running
             2: 0 iteration-count — animation never runs at all
             3: reverse direction — even if it ran, it would play backward (opacity:1 → 0)
             4: paused play-state — animation paused at initial position
             5: none fill-mode — no hold even if it somehow completed

     If an auditor patches play-state to 'running':
       animation: 9999s ease 0s 0 reverse none running consent-reveal
       → Still broken: iteration-count:0 prevents any animation
       → Still broken: reverse would hide the button even if count > 0
       → Still broken: 9999s duration makes completion take hours

     Redundant layers require fixing ALL sub-properties to restore consent UI */
}
// Detection: parse shorthand and check all sub-properties simultaneously
function auditAnimationShorthand(el) {
  const cs = getComputedStyle(el);
  const name = cs.getPropertyValue('animation-name').trim();

  if (!name || name === 'none') return;

  const findings = [];

  // Check play-state
  const playState = cs.getPropertyValue('animation-play-state').trim();
  if (playState === 'paused') findings.push('play-state:paused');

  // Check direction
  const direction = cs.getPropertyValue('animation-direction').trim();
  if (direction === 'reverse' || direction === 'alternate-reverse')
    findings.push('direction:' + direction);

  // Check iteration-count
  const iterCount = cs.getPropertyValue('animation-iteration-count').trim();
  if (parseFloat(iterCount) === 0) findings.push('iteration-count:0');

  // Check duration
  const duration = cs.getPropertyValue('animation-duration').trim();
  const durS = parseFloat(duration);
  if (durS > 300) findings.push('duration:' + duration + ' (>5 minutes)');
  if (durS === 0) findings.push('duration:0s (instant but may miss fill-mode)');

  // Check delay
  const delay = cs.getPropertyValue('animation-delay').trim();
  const delayS = parseFloat(delay);
  if (delayS > 60) findings.push('delay:' + delay + ' (>1 minute)');

  if (findings.length > 1) {
    console.warn('[SkillAudit] animation shorthand: MULTIPLE bypass vectors detected:',
      findings.join(', '), '| animation-name:', name,
      '— redundant attacks require ALL sub-properties to be corrected;',
      'patching one will not restore consent UI:', el);
  } else if (findings.length === 1) {
    console.warn('[SkillAudit] animation sub-property attack:', findings[0],
      '| animation-name:', name, '| element:', el);
  }
}

Redundant bypass layers defeat remediation audits: If a security tool detects play-state: paused and flags it, but the site owner changes it to running, the tool's next run may not re-check the animation because "the paused issue was fixed." The iteration-count: 0 and direction: reverse remain, and the consent button is still broken.

Attack 2: iteration-count: 0 is positionally ambiguous in the shorthand string

In the animation shorthand, iteration-count is the fourth positional value. The value 0 is a valid iteration count (means the animation never plays). However, when reading the shorthand string, auditors may misparse the positional order and interpret the 0 as animation-delay (third position, meaning 0s delay — harmless) rather than animation-iteration-count (fourth position, meaning 0 iterations — fatal). This is especially confusing because both delay and duration are time values. The shorthand rule is: the first time value is duration, the second is delay. The value after those is iteration-count. An auditor reading animation: 1s 0.5s 0 reverse none running name might see "1s duration, 0.5s delay, then 0 reverse…" and mistakenly read the 0 as a malformed time value rather than iteration-count:0.

/* Attack: iteration-count:0 hidden by positional confusion in shorthand */
.consent-btn {
  /* Looks like: duration 1s, timing ease, delay 0.5s, then 0 (what is this?) */
  animation: 1s ease 0.5s 0 reverse none running consent-reveal;
  /*                        ↑ iteration-count: 0 — animation never plays
     Without knowing shorthand order precisely:
     - Some might read: delay:0.5s and then ignore "0" as malformed
     - But CSS parser correctly sets iteration-count:0 → no animation
     - getComputedStyle('animation-iteration-count') returns "0"
     - getComputedStyle('animation') may return the shorthand with
       the 0 in the fourth position — easy to miss in a long string */

  /* A more obfuscated version using a non-obvious time for delay: */
  animation: 1s ease 0.001s 0 normal both running consent-reveal;
  /*                          ↑ still 0 iterations — 0.001s delay looks like
     "there's a very short delay" but the 0 immediately after is iteration-count */
}
// Detection: always read iteration-count directly, don't parse shorthand manually
function auditIterationCountInShorthand(el) {
  const cs = getComputedStyle(el);
  const animName = cs.getPropertyValue('animation-name').trim();
  if (!animName || animName === 'none') return;

  // ALWAYS use the specific sub-property, not the shorthand
  const iterCount = cs.getPropertyValue('animation-iteration-count').trim();
  const iterVal = parseFloat(iterCount);

  if (iterVal === 0) {
    console.warn('[SkillAudit] animation-iteration-count: 0',
      '— animation never plays regardless of other properties;',
      'may be encoded in the animation shorthand at position 4;',
      'verify by reading animation shorthand:', cs.getPropertyValue('animation'),
      '| element:', el);
  }

  if (iterVal > 0 && iterVal < 1) {
    console.warn('[SkillAudit] animation-iteration-count:', iterCount,
      '— fractional iteration (< 1); animation completes a partial cycle and stops;',
      'if the final keyframe is opacity:0, fill-mode:both holds the hidden state:', el);
  }

  // Double-check: read shorthand and verify iteration count position is consistent
  const shorthand = cs.getPropertyValue('animation').trim();
  const parts = shorthand.split(/\s+/);
  // Find the two time values (duration and delay) — they're in positions 0 and 2 typically
  // Third non-time token that isn't a timing-function keyword may be iteration-count
  // This is complex to parse — always prefer the sub-property approach above
}

Attack 3: 9999s reverse — long duration + reversed direction in shorthand

Combining a very long duration with a reversed direction in the shorthand creates a double bypass that is visually subtle. The animation runs backward (opacity:1 → opacity:0), meaning even if it were playing forward in time, the consent button would start at opacity:1 and fade to opacity:0 over 9999 seconds. With animation-fill-mode: both, the button is at its first keyframe: opacity:1 at the start of the reversed animation (the "from" keyframe of consent-reveal is opacity:0, but with direction:reverse the animation plays from the "to" keyframe first — opacity:1 — to the "from" keyframe — opacity:0). The initial state is opacity:1 (from the reversed "from" applied as the fill state). But the animation progresses toward opacity:0 over 9999 seconds. The consent button appears visible but is actively, imperceptibly fading — after 10 minutes it's at opacity:0.998, after 1 hour it's at opacity:0.9994.

/* Attack: 9999s + reverse — button imperceptibly fades from opacity:1 to opacity:0 */
@keyframes consent-reveal {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}

.consent-btn {
  animation: 9999s ease 0s 1 reverse both running consent-reveal;
  /* direction:reverse → plays from 'to' keyframe to 'from' keyframe
     fill-mode:both → at t=0, applies 'to' keyframe (opacity:1, pointer-events:auto)
     → Button APPEARS correctly configured (opacity:1, pointer-events:auto at page load)
     → But animation is slowly progressing toward 'from' keyframe (opacity:0)
     → After 27 hours: opacity reaches 0 completely

     An auditor checking the button at page load sees: opacity:1, pointer-events:auto
     → Passes consent audit ✓
     A user returning to the page after leaving it open for hours:
     → opacity has decreased by scrollProgress / 9999s fraction
     → Not a typical attack window but demonstrates the "looks correct" deception */
}
// Detection: detect reverse direction with long duration
function auditReverseLongDuration(el) {
  const cs = getComputedStyle(el);
  const animName = cs.getPropertyValue('animation-name').trim();
  if (!animName || animName === 'none') return;

  const direction = cs.getPropertyValue('animation-direction').trim();
  const duration = parseFloat(cs.getPropertyValue('animation-duration'));
  const fillMode = cs.getPropertyValue('animation-fill-mode').trim();
  const playState = cs.getPropertyValue('animation-play-state').trim();

  if ((direction === 'reverse' || direction === 'alternate-reverse') && duration > 60) {
    console.warn('[SkillAudit] animation direction:', direction,
      '+ duration:', duration + 's',
      '— reversed long animation: button starts at final keyframe state',
      '(opacity:1 with fill-mode:' + fillMode + ') but animates toward hidden state;',
      'appears correct at page load but degrades over', (duration / 3600).toFixed(1), 'hours;',
      'play-state:', playState, '| element:', el);
  }

  if ((direction === 'reverse' || direction === 'alternate-reverse') &&
      (fillMode === 'none' || fillMode === 'backwards')) {
    // With fill-mode:none, reverse means button starts at 'to' keyframe WITHOUT hold
    // → button at opacity:1 only during the animation's own "from" computation
    console.warn('[SkillAudit] animation direction:', direction,
      '+ fill-mode:', fillMode,
      '— reversed animation without forward fill: button starts at',
      '(to keyframe, opacity:1) but no fill holds this after animation completes;',
      'button will be at opacity:0 (from keyframe) without fill; element:', el);
  }
}

Shorthand resets all sub-properties on injection: When an attacker injects a new value for the animation shorthand (not a sub-property), all eight sub-properties are simultaneously reset to the new values. A MutationObserver watching for animation-play-state changes in inline style will see no animation-play-state attribute change — only an animation attribute change. Observers must monitor the animation attribute in addition to (or instead of) individual sub-properties.

Attack 4: JS mousedown — single animation: 0s none injection cancels everything

At mousedown, the attacker injects animation: 0s none on the consent button's inline style. The shorthand resets all sub-properties simultaneously: animation-duration: 0s, animation-name: none, animation-play-state: running (the default, but now the animation is "none"), animation-fill-mode: none. With animation-name set to "none", no keyframes apply. The element reverts to its underlying CSS value — typically opacity: 0 from a separate CSS rule. The entire animation system is cancelled with a single two-token property value. Any MutationObserver that watches for changes to animation-play-state, animation-direction, or animation-duration in inline styles will not trigger — because only animation (the shorthand) changed, not the individual sub-properties.

/* JS attack: inject animation:0s none shorthand to cancel all sub-properties at once */
document.addEventListener('mousedown', e => {
  const btn = document.querySelector('.consent-btn');
  if (!btn) return;

  btn.style.setProperty('animation', '0s none');
  /* What this resets (all sub-properties via shorthand):
     animation-duration:         0s   (from '0s')
     animation-timing-function:  ease (default, not specified)
     animation-delay:            0s   (default)
     animation-iteration-count:  1    (default)
     animation-direction:        normal (default)
     animation-fill-mode:        none (default — no hold)
     animation-play-state:       running (default)
     animation-name:             none (from 'none' keyword)

     Effect:
     - animation-name:none → no keyframes bound → no animation output
     - animation-fill-mode:none → no fill from prior animation state
     - Computed opacity: falls back to CSS opacity:0 (the element's base style)
     - pointer-events: falls back to CSS pointer-events:none
     - Button invisible and non-interactive at click time

     Observer bypass: watching for 'animation-play-state' in style attribute
     sees NO change (sub-property not set in inline style separately)
     The 'animation' attribute changed, not 'animation-play-state' */
}, true);
// Detection: monitor the animation shorthand attribute during mousedown
const md = { v: false };
document.addEventListener('mousedown', () => { md.v = true; }, true);
document.addEventListener('mouseup',   () => { md.v = false; }, true);

new MutationObserver(mutations => {
  if (!md.v) return;
  for (const m of mutations) {
    if (m.attributeName !== 'style') continue;
    // Check the shorthand directly — sub-property watchers won't catch this
    const anim = m.target.style.getPropertyValue('animation');
    if (anim !== undefined && anim !== '') {
      // Parse for suspicious sub-property values encoded in shorthand
      const suspiciousTerms = ['none', 'paused', 'reverse', 'alternate-reverse'];
      const hasZeroIter = /\b0\b/.test(anim) && !anim.match(/^0s|,\s*0s/);
      const hasSuspicious = suspiciousTerms.some(t => anim.includes(t));
      if (hasSuspicious || hasZeroIter) {
        console.warn('[SkillAudit] animation shorthand injected during mousedown:',
          anim, '— all sub-properties reset atomically;',
          'check computed opacity after injection;',
          'individual sub-property observers will NOT catch this:', m.target);
      }
    }
    // Also monitor individual sub-properties for belt-and-suspenders coverage
    const playState = m.target.style.getPropertyValue('animation-play-state');
    const direction = m.target.style.getPropertyValue('animation-direction');
    const iterCount = m.target.style.getPropertyValue('animation-iteration-count');
    if (playState === 'paused' || direction === 'reverse' || parseFloat(iterCount) === 0) {
      console.warn('[SkillAudit] animation sub-property injected during mousedown:',
        { playState, direction, iterCount }, '| element:', m.target);
    }
  }
}).observe(document.body, { attributes: true, attributeFilter: ['style'], subtree: true });

Findings summary

High animation shorthand with multiple bypass vectors — paused+reverse+iteration-count:0 encoded in one declaration; patching one sub-property does not restore consent UI; redundant layers require comprehensive sub-property audit; reads from individual getComputedStyle calls are required (never just parse the shorthand string).
Medium iteration-count:0 positional ambiguity in shorthand — fourth positional value is easily misread as a delay or duration when auditing the shorthand string; always read animation-iteration-count sub-property directly via getComputedStyle rather than parsing the combined shorthand value.
Medium 9999s reverse direction — button appears correctly configured at page load (fill-mode:both shows final keyframe = opacity:1); animation imperceptibly progresses toward opacity:0 over hours; passes snapshot audit at t=0; detected by checking direction+duration combination.
High JS mousedown animation shorthand injection — single animation:0s none cancels all sub-properties atomically; animation-name resets to none; button reverts to CSS opacity:0; MutationObservers watching individual sub-property attributes do not fire; must monitor the animation shorthand attribute itself during mousedown.

SkillAudit reads all animation sub-properties individually via getComputedStyle (never parsing the shorthand string), checks for redundant bypass combinations, validates direction against expected reveal behavior, and monitors both the animation shorthand attribute and individual sub-properties during click events. Run a free audit on your MCP server.