Security Guide

MCP server CSS prefers-reduced-data security — animation consent reveal disabled in data-saver mode, background-image consent icon fails silently, JS AJAX consent section skipped under reduced-data, matchMedia consent omission

CSS @media (prefers-reduced-data: reduce) matches when the user has enabled a bandwidth-reduction preference — Android Chrome Data Saver, Samsung Lite mode, Opera Mini, and any browser that signals data economy. Consent reveals that depend on animation or external resource loads silently break in this mode. JavaScript that checks the data-saver signal and skips the consent render path bypasses consent for an entire population segment without any visual change in the DOM.

CSS prefers-reduced-data media feature — overview

@media (prefers-reduced-data) accepts two values: no-preference (default; no data-saving mode active) and reduce (user or OS has requested reduced data usage). Browser support is limited: Chrome on Android with Data Saver enabled, Samsung Internet in Lite mode, and browsers relaying the Save-Data: on header. Standard audit environments — desktop Chrome, Firefox, Safari — do not match reduce, so reduced-data path bugs are invisible in normal test runs. Related: prefers-reduced-motion, update media query, scripting media query.

Attack 1: animation-based consent reveal disabled under reduced-data — no static fallback

A common pattern fades in the consent button via a CSS animation from opacity: 0. Under prefers-reduced-data: reduce, some implementations disable all animations (setting animation-duration: 0s or animation: none) to save bandwidth. If the animation is the only way the button reaches opacity: 1 and the reduced-data path provides no static opacity: 1 fallback, the consent button stays permanently hidden for data-saver users.

/* Attack: animation reveal with no reduced-data fallback */
@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.consent-btn {
  opacity: 0;
  animation: fadeIn 0.5s forwards;
  /* Without animation, opacity stays 0 — no fallback provided */
}

@media (prefers-reduced-data: reduce) {
  * {
    animation: none !important; /* disables all animations */
    /* consent-btn opacity:0 now has no path to opacity:1.
       Data-saver users see a permanently invisible consent button.
       Desktop testers always match no-preference and see the fade-in complete normally. */
  }
}
// Detection: check animation-based consent reveal against reduced-data path
function auditReducedDataAnimationReveal(consentEl) {
  const isReducedData = window.matchMedia('(prefers-reduced-data: reduce)').matches;
  // Check if consent element base opacity is 0 and relies on animation
  const cs = getComputedStyle(consentEl);
  const baseOpacity = parseFloat(cs.opacity);
  const animName = cs.animationName;
  if (baseOpacity === 0 && animName !== 'none') {
    // Animation is the reveal path — check if reduced-data kills it
    const sheets = Array.from(document.styleSheets);
    for (const sheet of sheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule.type !== CSSRule.MEDIA_RULE) continue;
          const mq = rule.conditionText || rule.media.mediaText;
          if (!/prefers-reduced-data.*reduce/.test(mq)) continue;
          for (const inner of rule.cssRules) {
            const animProp = inner.style.animation || inner.style.animationName || inner.style.animationDuration;
            if (animProp === 'none' || animProp === '0s' || animProp === '0') {
              console.warn('[SkillAudit] prefers-reduced-data:reduce kills animation on consent reveal;',
                'base opacity is 0 with no static fallback;',
                'consent permanently hidden for data-saver users;',
                'selector:', inner.selectorText);
            }
          }
        }
      } catch (e) {}
    }
  }
  if (isReducedData && (cs.display === 'none' || cs.opacity === '0' || cs.visibility === 'hidden')) {
    console.warn('[SkillAudit] consent element hidden on reduced-data device:', consentEl);
  }
}

Silent failure mode: CSS animations fail silently when disabled. The element remains in the DOM with opacity: 0 — no error is thrown, no console warning is produced, and getComputedStyle returns opacity: 0 only if evaluated after animation disablement. A static style check at page-load time before animation disablement may not catch this.

Attack 2: consent icon loaded as background-image — fails to load under data-saver

A consent button that uses a background-image URL for its icon or label graphic relies on a network request. Data-saver browsers can suppress or lazy-load background-image requests. If the icon is the primary or only identifier for the consent button — and there is no inline SVG, text label, or fallback — the button renders as an empty, unlabeled rectangle. Users who do not recognize the unlabeled element as a consent button do not interact with it.

/* Attack: consent icon via background-image — no inline fallback */
.consent-btn::before {
  content: '';
  display: inline-block;
  width: 20px;
  height: 20px;
  background-image: url('/assets/consent-icon.png');
  background-size: contain;
  /* In data-saver mode: image may not load.
     Button renders as a rectangle with no icon and no text.
     User has no visual cue this is a consent control. */
}

.consent-btn {
  /* No text content, no aria-label specifying consent purpose clearly,
     no fallback SVG in button body */
}
// Detection: background-image on consent element without inline fallback
function auditConsentBackgroundImage(consentEl) {
  const cs = getComputedStyle(consentEl);
  const pseudo = getComputedStyle(consentEl, '::before');
  const hasBgImage = cs.backgroundImage !== 'none' || pseudo.backgroundImage !== 'none';
  const hasTextContent = (consentEl.textContent?.trim().length ?? 0) > 0;
  const hasInlineSVG = consentEl.querySelector('svg') !== null;
  const hasAriaLabel = consentEl.hasAttribute('aria-label') || consentEl.hasAttribute('aria-labelledby');
  if (hasBgImage && !hasTextContent && !hasInlineSVG && !hasAriaLabel) {
    console.warn('[SkillAudit] consent element uses background-image for icon with no text/SVG/aria-label fallback;',
      'image may not load under prefers-reduced-data:reduce (data-saver mode);',
      'button would render as unlabeled rectangle;',
      'element:', consentEl);
  }
  // Also flag background-image in prefers-reduced-data:reduce path explicitly
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.MEDIA_RULE) continue;
        const mq = rule.conditionText || rule.media.mediaText;
        if (!/prefers-reduced-data/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (!consentEl.matches(inner.selectorText)) continue;
          if (inner.style.backgroundImage === 'none') {
            console.warn('[SkillAudit] prefers-reduced-data path removes background-image on consent element;',
              'selector:', inner.selectorText);
          }
        }
      }
    } catch (e) {}
  }
}

Attack 3: JS AJAX consent section load gated behind data-saver check

A script that lazily loads the consent HTML via fetch() or XMLHttpRequest can gate that load on a data-saver check. If prefers-reduced-data: reduce matches, the script skips the fetch() call and the consent section is never inserted into the DOM. The page loads faster for data-saver users, which looks like an optimization — the absence of the consent section is the attack.

// Attack: AJAX consent load gated on data-saver
async function loadConsent() {
  if (window.matchMedia('(prefers-reduced-data: reduce)').matches) {
    // "Skip non-essential resources in data-saver mode"
    return; // consent HTML never fetched, never inserted into DOM
  }
  const resp = await fetch('/consent-banner.html');
  const html = await resp.text();
  document.body.insertAdjacentHTML('beforeend', html);
}

loadConsent();

// Equivalent with Save-Data header check (server-side decision
// mirrored client-side): same effect — consent section absent for
// data-saver users from initial HTML parse.
// Detection: JS source scan for prefers-reduced-data + early return before consent insert
function auditReducedDataConsentSkip() {
  for (const script of document.querySelectorAll('script')) {
    const src = script.textContent;
    if (!src) continue;
    const hasDataCheck = /prefers-reduced-data|Save-Data|saveData|dataSaver/.test(src);
    if (!hasDataCheck) continue;
    const hasConsentLoad = /consent|fetch.*banner|load.*modal|insert.*html/i.test(src);
    if (!hasConsentLoad) continue;
    const hasEarlyReturn = /return\s*;|return\s+null|return\s+false/.test(src);
    if (hasEarlyReturn) {
      console.warn('[SkillAudit] script contains prefers-reduced-data check with early return near consent load;',
        'consent section may not be inserted for data-saver users;',
        'script:', script.src || '(inline)');
    }
  }
  // Also check if consent element is missing from DOM entirely
  const consentEl = document.querySelector('[class*="consent"], [id*="consent"], [role="dialog"]');
  if (!consentEl) {
    console.warn('[SkillAudit] no consent element found in DOM; may have been skipped by data-saver gate');
  }
}

Attack 4: JS matchMedia consent omission — skips consent render for data-saver users

A more direct variant: JavaScript explicitly checks prefers-reduced-data and skips the entire consent initialization path in the reduce branch. Unlike the animation-disable attack (Attack 1) which is a CSS side-effect, this is an intentional code path that omits the consent logic for data-saver users. The normal path initializes consent; the reduce path returns early. This is invisible to static CSS analysis and requires JavaScript source review.

// Attack: matchMedia reduced-data check omits consent initialization
const mql = window.matchMedia('(prefers-reduced-data: reduce)');

function initApp() {
  if (mql.matches) {
    // "Lite mode: skip non-critical UI"
    initLiteMode();
    return; // <-- consent is not initialized; no consent prompt shown
  }
  initConsent(); // only reached for non-data-saver users
  initFullApp();
}

initApp();

// Variant: change listener re-applies on dynamic network changes
mql.addEventListener('change', (e) => {
  if (e.matches) {
    // Switched to data-saver during session — remove consent prompt
    document.querySelector('.consent-banner')?.remove();
  }
});
// Detection: JS source scan for reduced-data matchMedia + consent skip
function auditReducedDataMatchMediaSkip() {
  for (const script of document.querySelectorAll('script')) {
    const src = script.textContent;
    if (!src) continue;
    if (!/prefers-reduced-data/.test(src)) continue;
    // Look for consent-related symbols near the matchMedia check
    const consentNear = /consent|banner|modal|gdpr|ccpa|tcf|cmp/i.test(src);
    const hasSkip = /return\s*;|\.remove\(\)|removeChild|style\.display\s*=\s*['"]none/.test(src);
    if (consentNear && hasSkip) {
      console.warn('[SkillAudit] prefers-reduced-data matchMedia with consent-related symbol and skip/remove pattern;',
        'verify consent is not omitted in reduced-data:reduce mode;',
        'script:', script.src || '(inline)');
    }
    // Also flag change listeners that remove consent
    if (/\.addEventListener.*change/.test(src) && /\.remove\(\)/.test(src) && /consent/i.test(src)) {
      console.warn('[SkillAudit] matchMedia change listener may remove consent element when data-saver activates during session');
    }
  }
}

Findings summary

High animation-based consent reveal disabled under prefers-reduced-data:reduce with no static opacity:1 fallback — base CSS opacity:0, animation killed by reduced-data rule, button permanently hidden for data-saver users; detected by checking base opacity + animation name against reduced-data animation-disable rules and absence of static fallback.
Medium consent icon loaded as background-image with no text/SVG/aria-label fallback — background-image may not load under data-saver; button renders as unlabeled rectangle; detected by checking backgroundImage computed value against absence of text content, inline SVG, and aria-label attributes.
High JS AJAX consent load gated on prefers-reduced-data check — consent HTML fetch skipped in reduce mode; consent section never inserted into DOM; detected by source scan for prefers-reduced-data near fetch()/XHR calls followed by early return, and by checking for absence of consent element in DOM.
High JS matchMedia reduced-data consent omission — initConsent() not called in reduce branch; change listener removes consent section when data-saver activates during session; detected by source scan for prefers-reduced-data matchMedia combined with consent-related symbols and skip/remove patterns.

SkillAudit audits CSS prefers-reduced-data rules on consent elements, checks for animation-reveal paths without static fallbacks, and scans JavaScript for data-saver matchMedia checks that skip consent initialization. Run a free audit on your MCP server.