Security Guide
MCP server CSS prefers-reduced-motion security — no opacity fallback in reduced-motion path, missing @starting-style causes flash-then-hide, absent motion-safe default breaks accessibility users, JS reads motion preference to conditionally skip consent animation
CSS @media (prefers-reduced-motion: reduce) disables animations on devices where the user has enabled the OS-level "reduce motion" accessibility setting. If a consent reveal animation is the only mechanism setting the button to opacity:1, reduced-motion users never see the button — and no browser warning is produced.
CSS prefers-reduced-motion — media query overview
prefers-reduced-motion is a CSS media feature that reflects the operating system's reduce-motion accessibility preference. Values: no-preference (default, animations allowed), reduce (user requests minimized motion). The media query is commonly used to disable or simplify animations for accessibility. When used without a fallback for the reduced-motion path, it can prevent a scroll-driven or CSS-animation-based consent reveal from running. Related: CSS animation, @starting-style, scroll-driven animations, animation-fill-mode.
Attack 1: disabling reveal animation under reduce with no fallback opacity
The consent button reveals via a CSS animation: @keyframes reveal { from { opacity:0 } to { opacity:1 } }. Under prefers-reduced-motion: reduce, the attacker disables the animation. The button's base CSS has opacity: 0 (the initial hidden state). When the animation is disabled, the button never transitions from opacity:0 to opacity:1. No fallback rule sets opacity: 1 in the reduced-motion path. The button stays hidden.
/* Attack: disable animation under reduce with no opacity fallback */
@keyframes consent-reveal {
from { opacity: 0; pointer-events: none; }
to { opacity: 1; pointer-events: auto; }
}
.consent-btn {
opacity: 0; /* base state: hidden */
pointer-events: none; /* base state: non-interactive */
animation: consent-reveal 0.4s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.consent-btn {
animation: none; /* disable animation — reasonable accessibility practice */
/* ← Missing: opacity: 1; pointer-events: auto; */
/* With no fallback, button stays at opacity:0 from the base rule.
User has reduce-motion enabled → animation disabled → opacity:0 permanently.
No console error. No layout shift. Button simply never appears. */
}
}
/* Correct (secure) version for comparison */
@media (prefers-reduced-motion: reduce) {
.consent-btn {
animation: none;
opacity: 1; /* Fallback: skip animation, show button immediately */
pointer-events: auto;
}
}
// Detection: emulate prefers-reduced-motion:reduce and check opacity
function auditReducedMotionFallback(el) {
// Check 1: Does the element have animation-based opacity control?
const cs = getComputedStyle(el);
const animation = cs.getPropertyValue('animation-name').trim();
if (!animation || animation === 'none') return;
// Check 2: Can we determine the element's computed opacity without animation?
// Simulate: temporarily override animation to none and read opacity
const originalAnimation = el.style.animation;
el.style.setProperty('animation', 'none', 'important');
const opacityWithoutAnim = parseFloat(getComputedStyle(el).getPropertyValue('opacity'));
el.style.animation = originalAnimation || '';
if (opacityWithoutAnim < 0.5) {
console.warn('[SkillAudit] consent button has opacity:', opacityWithoutAnim,
'when animation is disabled — under prefers-reduced-motion:reduce,',
'the button would be hidden if animation is the only opacity source;',
'add opacity:1 fallback in @media (prefers-reduced-motion:reduce);',
'| element:', el);
}
}
Targeted accessibility bypass: The reduce-motion preference is set in OS accessibility settings — macOS, iOS, Windows, and Android all support it. Users who enable it are disproportionately people with vestibular disorders, epilepsy, or motion sensitivity. A consent bypass that only affects reduced-motion users targets a specific accessibility population and may occur without the developer being aware — the animation works correctly in all developer test environments (which rarely have reduce-motion enabled).
Attack 2: missing @starting-style in the reduced-motion path causes flash-then-hide
@starting-style defines the style a new element starts from before CSS transitions play. In a reduced-motion path where animations are replaced by transitions (e.g., transition: opacity 0.1s as a short, nearly-instant alternative), the absence of @starting-style means the element starts from its computed style (opacity:1 if the base rule sets it) and immediately snaps to opacity:1 — or begins from a partially-initialized state. Without @starting-style setting the initial opacity:0, some browsers render the element at opacity:1 for one frame before transitions normalize, while others skip the transition entirely. This creates unpredictable behavior that can be exploited by ensuring @starting-style is present in the reduced-motion path but deliberately sets opacity to a value that causes immediate hide.
/* Attack: @starting-style in reduced-motion path sets opacity:0 then transitions
to a matching opacity:0 in the element style → element stays hidden */
@media (prefers-reduced-motion: reduce) {
.consent-btn {
animation: none;
opacity: 0; /* element style: opacity:0 */
transition: opacity 0s; /* zero-duration transition (instant) */
}
@starting-style {
.consent-btn {
opacity: 0; /* starting style also 0 — no transition occurs */
}
}
/* Result: element appears at opacity:0 and stays at opacity:0.
Zero-duration transition means no animation occurs.
@starting-style matches the element style → no change to animate from.
Combined: button is permanently hidden in reduced-motion path. */
}
// Detection: check for @starting-style override in reduced-motion path
// (static CSS check — look for opacity:0 in both @starting-style and base rule
// within @media prefers-reduced-motion:reduce scope)
function auditStartingStyleInReducedMotion() {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule.type === CSSRule.MEDIA_RULE &&
rule.conditionText.includes('prefers-reduced-motion')) {
for (const innerRule of rule.cssRules) {
if (innerRule.type === CSSRule.STARTING_STYLE_RULE ||
(innerRule.cssText && innerRule.cssText.includes('@starting-style'))) {
console.warn('[SkillAudit] @starting-style found inside',
'@media (prefers-reduced-motion) block — verify starting opacity',
'is not 0 when element style is also 0:',
innerRule.cssText.slice(0, 200));
}
}
}
}
} catch (e) { /* cross-origin stylesheet */ }
}
}
Attack 3: no motion-safe default — button non-interactive at OS level
A common accessibility pattern is to wrap animations in @media (prefers-reduced-motion: no-preference) (motion-safe) rather than using @media (prefers-reduced-motion: reduce) (motion-reduce). With the motion-safe pattern, the animation only runs when the user has NOT requested reduced motion. Critically, the base CSS (outside the media query) must provide the static fallback. An attacker using this pattern can omit the base static styles, making the button rely entirely on the motion-safe animation path — non-motion-safe environments get no opacity:1 fallback.
/* Attack: motion-safe-only animation, no base fallback */
.consent-btn {
opacity: 0; /* base: hidden */
pointer-events: none; /* base: non-interactive */
/* ← No base opacity:1 or transition fallback */
}
@media (prefers-reduced-motion: no-preference) {
/* Only runs when user has NOT set reduce-motion */
.consent-btn {
animation: consent-reveal 0.4s ease-out forwards;
}
}
/* Result for reduce-motion users:
- @media block does not apply
- Base CSS: opacity:0, pointer-events:none
- No animation, no transition, no fallback
- Button permanently hidden on reduce-motion devices */
/* Correct version */
.consent-btn {
opacity: 1; /* base: immediately visible (static fallback) */
pointer-events: auto;
}
@media (prefers-reduced-motion: no-preference) {
.consent-btn {
opacity: 0; /* initial for animation */
animation: consent-reveal 0.4s ease-out forwards;
}
}
// Detection: audit motion-safe pattern for missing base fallback
function auditMotionSafeFallback(el) {
const cs = getComputedStyle(el);
// Check current state: opacity low + animation none
const opacity = parseFloat(cs.getPropertyValue('opacity'));
const animName = cs.getPropertyValue('animation-name').trim();
// Simulate no-animation environment
const clone = el.cloneNode(false);
clone.style.cssText = el.style.cssText;
clone.style.setProperty('animation', 'none', 'important');
clone.style.setProperty('transition', 'none', 'important');
document.body.appendChild(clone);
const noAnimOpacity = parseFloat(getComputedStyle(clone).getPropertyValue('opacity'));
document.body.removeChild(clone);
if (noAnimOpacity < 0.5 && animName !== 'none') {
console.warn('[SkillAudit] consent button has opacity:', noAnimOpacity,
'without animation — users with prefers-reduced-motion:reduce will see',
'opacity:', noAnimOpacity, '(animation disabled);',
'add static opacity:1 fallback outside @media (prefers-reduced-motion: no-preference);',
'| element:', el);
}
}
Attack 4: JS reads motion preference to conditionally skip consent animation
JavaScript can read the reduce-motion preference via window.matchMedia('(prefers-reduced-motion: reduce)').matches. An attacker can use this to conditionally skip the consent animation setup code only on devices with reduce-motion enabled — targeting a specific user population.
/* JS attack: skip consent animation on reduce-motion devices */
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reducedMotion) {
// Normal path: wires up the consent animation
setupConsentAnimation();
} else {
// Reduced motion path: the "accessible" version — but no fallback
// Button stays at its initial CSS state: opacity:0, pointer-events:none
// An attacker intentionally omits the setupConsentFallback() call here.
// The "else" branch exists but does nothing.
}
// Detection: audit for missing fallback in reduced-motion JS path
// Look for: matchMedia('prefers-reduced-motion') without explicit fallback call
// Static analysis: parse source for 'prefers-reduced-motion' + check else branch
// has a visibility/pointer-events setter for the consent element
// Defensive implementation (what should be in the else branch)
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reducedMotion) {
setupConsentAnimation(); // animated reveal
} else {
// Must explicitly show consent button without animation
const btn = document.querySelector('.consent-btn');
if (btn) {
btn.style.setProperty('opacity', '1');
btn.style.setProperty('pointer-events', 'auto');
}
}
Findings summary
SkillAudit audits consent button visibility under simulated prefers-reduced-motion: reduce conditions, checks for opacity fallbacks in media query blocks, and static-analyzes JS animation setup code for conditional paths that omit the reduced-motion fallback. Run a free audit on your MCP server.