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