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
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.