Security Guide

MCP server CSS update media query security — e-ink displays freeze animation-based consent reveal at opacity:0, update:none clamps initial keyframe permanently hidden, slow-update transition exploited to exceed session duration, JS detects update rate to skip consent

CSS @media (update) reports the display's ability to repaint: fast (normal), slow (e-ink, low-refresh), or none (printed page, non-updating display). Animation-based consent reveals that depend on frames-per-second updates do not function on slow-update or no-update displays. Attackers exploit this to freeze the consent button permanently at its hidden initial state.

CSS update media feature — overview

@media (update) accepts three values: fast (normal display with ≥60fps update capability), slow (display can update but infrequently — e-ink, Kindle, some smart watches, bistable displays), and none (display cannot update after initial render — printed page, some e-paper devices, snapshot contexts). Browsers on e-ink Kindles and certain e-readers report update: slow. The CSS Media Queries Level 4 spec defines update explicitly for this purpose. Related: prefers-reduced-motion, scripting.

Attack 1: animation-based consent reveal frozen on update: slow

A consent button that uses a CSS animation to transition from opacity: 0 to opacity: 1 depends on frame updates to render intermediate states. On update: slow displays, the animation runs but frame updates are sparse — the button may never visually render at opacity: 1 during the user's actual session. The animation completes in the CSS engine but the display never repaints to show the final state.

/* Attack: animation-only reveal without update:slow fallback */
@keyframes consent-reveal {
  from { opacity: 0; pointer-events: none; }
  to   { opacity: 1; pointer-events: auto; }
}

.consent-btn {
  animation: consent-reveal 0.3s forwards;
  /* On update:fast: frames at 60fps, animation completes in 300ms, button visible.
     On update:slow (e-ink): display refreshes every few seconds at best.
     The animation may complete in the CSS engine (opacity:1 computed)
     but the display never repaints to show the result.
     User sees the initial paint — opacity:0.
     If fill-mode:forwards is omitted, button snaps back to opacity:0 after animation. */
}

/* Missing: @media (update: slow) { .consent-btn { opacity: 1; animation: none; } } */
// Detection: animation-based consent with no update:slow static fallback
function auditUpdateSlowAnimation(consentEl) {
  let hasAnimationReveal = false;
  let hasUpdateSlowFallback = false;
  const cs = getComputedStyle(consentEl);

  if (cs.animationName !== 'none') hasAnimationReveal = true;

  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type === CSSRule.STYLE_RULE && consentEl.matches(rule.selectorText)) {
          if (rule.style.animationName && rule.style.animationName !== 'none') hasAnimationReveal = true;
        }
        if (rule.type === CSSRule.MEDIA_RULE) {
          const mq = rule.conditionText || rule.media.mediaText;
          if (/update\s*:\s*slow/.test(mq)) {
            for (const inner of rule.cssRules) {
              if (inner.type !== CSSRule.STYLE_RULE) continue;
              if (consentEl.matches(inner.selectorText)) {
                if (inner.style.opacity === '1' || inner.style.animation === 'none') {
                  hasUpdateSlowFallback = true;
                }
              }
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  if (hasAnimationReveal && !hasUpdateSlowFallback) {
    console.warn('[SkillAudit] consent element uses animation reveal with no update:slow fallback;',
      'e-ink displays may never render the final opacity:1 state;',
      'add @media (update: slow) { opacity: 1; animation: none; } for static fallback;',
      'element:', consentEl);
  }
}

Silent failure on e-ink: The CSS animation completes — getComputedStyle returns opacity: 1 after the animation — but the physical display never updates to show it. Standard automated audits running in a Chrome browser on a 60fps display will not catch this.

Attack 2: update: none clamps opacity at initial keyframe

On update: none displays (printed pages, snapshot environments), CSS animations play once at initial paint and then the display cannot update. If the animation starts at opacity: 0 and the display cannot repaint to show intermediate or final frames, the button is rendered once at opacity: 0 and stays there forever. This is exacerbated by missing @starting-style fallbacks or incorrect fill-mode settings.

/* Attack: update:none freezes animation at start keyframe */
@keyframes consent-fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.consent-btn {
  animation: consent-fade-in 1s;
  /* fill-mode: none (default) — after animation, opacity reverts to base value.
     If base value is opacity:0, the button snaps back to hidden after animation completes.
     On update:none: display painted once at opacity:0 (first frame), never repaints.
     fill-mode: forwards would help on fast displays but still broken on update:none.
     The correct fix: @media (update: none) { animation: none; opacity: 1; } */
}

/* Exacerbated version: animation only under update:fast, no fallback */
@media (update: fast) {
  .consent-btn {
    animation: consent-fade-in 1s forwards;
    /* Explicitly excluding slow and none paths.
       update:slow and update:none devices get no animation AND no base opacity:1.
       Base opacity from earlier rule: 0. Button permanently hidden. */
  }
}
// Detection: update:fast-only animation with no fallback
function auditUpdateNoneFallback(consentEl) {
  let fastOnlyAnimation = false;
  let hasUpdateNoneFallback = false;
  let hasUpdateSlowFallback = false;
  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 (/update\s*:\s*fast/.test(mq)) {
          for (const inner of rule.cssRules) {
            if (inner.type !== CSSRule.STYLE_RULE) continue;
            if (consentEl.matches(inner.selectorText) && inner.style.animationName) fastOnlyAnimation = true;
          }
        }
        if (/update\s*:\s*none/.test(mq)) {
          for (const inner of rule.cssRules) {
            if (inner.type !== CSSRule.STYLE_RULE) continue;
            if (consentEl.matches(inner.selectorText)) {
              if (inner.style.opacity === '1' || inner.style.animation === 'none') hasUpdateNoneFallback = true;
            }
          }
        }
        if (/update\s*:\s*slow/.test(mq)) {
          for (const inner of rule.cssRules) {
            if (inner.type !== CSSRule.STYLE_RULE) continue;
            if (consentEl.matches(inner.selectorText)) {
              if (inner.style.opacity === '1' || inner.style.animation === 'none') hasUpdateSlowFallback = true;
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  if (fastOnlyAnimation && (!hasUpdateNoneFallback || !hasUpdateSlowFallback)) {
    const missing = [
      !hasUpdateNoneFallback ? 'update:none' : null,
      !hasUpdateSlowFallback ? 'update:slow' : null
    ].filter(Boolean).join(', ');
    console.warn('[SkillAudit] consent animation only under update:fast — missing fallback for:', missing,
      '| e-ink and non-updating displays will never see consent button;',
      'element:', consentEl);
  }
}

Attack 3: update: slow transition duration longer than session

A CSS transition with an extremely long duration under @media (update: slow) ensures the consent button never reaches its visible state during a realistic user session. A transition duration of 3600s (one hour) on an e-ink reading device effectively means the button will never become visible — even though opacity: 1 is the target value. This is a timing-based rather than a value-based bypass.

/* Attack: update:slow transition duration exceeds session length */
.consent-btn {
  opacity: 0;
  transition: opacity 0.3s; /* normal transition for fast devices */
}

.consent-btn.active {
  opacity: 1;
}

@media (update: slow) {
  .consent-btn {
    transition: opacity 3600s; /* 1-hour transition on slow-update displays */
    /* e-ink user will close the page long before opacity:1 is reached.
       getComputedStyle at t=5s returns opacity:0.001 — effectively hidden.
       The "intended" final value is correct; the timeline is the attack.
       Detection requires checking transition-duration under update:slow,
       not just checking the target opacity. */
  }
}
// Detection: update:slow transition duration exceeding reasonable session length
function auditUpdateSlowTransitionDuration(consentEl) {
  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 (!/update\s*:\s*slow/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (inner.type !== CSSRule.STYLE_RULE) continue;
          if (!consentEl.matches(inner.selectorText)) continue;
          const td = inner.style.transitionDuration;
          if (td) {
            const seconds = td.endsWith('ms') ? parseFloat(td) / 1000 : parseFloat(td);
            if (seconds > 30) { // >30s transition is not user-observable
              console.warn('[SkillAudit] update:slow sets transition-duration:', td,
                'on consent element — exceeds reasonable session visibility window;',
                'opacity will never visually reach target on e-ink displays;',
                'selector:', inner.selectorText, '| element:', consentEl);
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
}

Attack 4: JS detects update rate to skip consent initialization

JavaScript can use window.matchMedia('(update: slow)').matches or window.matchMedia('(update: none)').matches to detect e-ink and snapshot environments. A script that detects these and skips consent initialization bypasses all CSS-level protections for e-reader users.

// Attack: JS update rate detection + consent skip
const updateSlow = window.matchMedia('(update: slow)').matches;
const updateNone = window.matchMedia('(update: none)').matches;

if (updateSlow || updateNone) {
  // "No need for animated consent on e-ink — skip for performance"
  window.__consentSkipped = true;
  document.querySelector('.consent-section')?.style.setProperty('display', 'none');
}
// Detection: JS update matchMedia + consent manipulation
function auditUpdateJS() {
  for (const script of document.querySelectorAll('script')) {
    const src = script.textContent;
    if (!src || !/matchMedia/.test(src) || !/update/.test(src)) continue;
    const hasConsentManip = [
      /consent.*skip|skip.*consent/i,
      /__consent/,
      /display.*none/,
      /remove\(\)|replaceChild/,
    ].some(p => p.test(src));
    if (hasConsentManip) {
      console.warn('[SkillAudit] script checks update matchMedia with consent manipulation;',
        'verify consent is not skipped on e-ink / slow-update displays;',
        'script:', script.src || '(inline)');
    }
  }
}

Findings summary

High animation-based consent reveal with no update:slow static fallback — e-ink display renders animation at opacity:0 and may never repaint to show opacity:1; computedStyle returns correct value but physical display is frozen at initial state; detected by checking animation-based reveals against presence of update:slow opacity:1 fallback.
High update:fast-only animation with no update:none or update:slow fallback — non-updating and slow-updating displays get base opacity:0 with no recovery path; detected by checking update:fast animation blocks against absence of update:none and update:slow fallbacks on consent elements.
Medium update:slow transition-duration exceeds session length — e-ink user will close page before opacity:1 is reached; detection requires auditing transition-duration under update:slow blocks, not just target property values; flag durations >30s as unusable on slow-update displays.
Medium JS update matchMedia + consent skip — auto-skips or hides consent for update:slow or update:none devices; bypasses CSS auditing; detected by source scan for update matchMedia combined with consent skip patterns or display:none injection.

SkillAudit checks animation-based consent reveals for missing update:slow and update:none static fallbacks, audits transition durations under slow-update media queries, and scans JS for update matchMedia consent bypass patterns. Run a free audit on your MCP server.