Security Guide

MCP server CSS hover media query security — consent hidden via hover:none for touch devices, hover-only reveal never fires on phones, missing hover:none fallback keeps consent invisible, JS matchMedia hover check swaps button to non-interactive element

CSS @media (hover) reports whether the primary pointing device can hover without activating. hover: none matches phones, tablets, keyboards, and any non-hover input. Malicious MCP server CSS exploits this to hide consent for touch users, create consent reveals that depend on :hover (which never fires on touch), and use hover detection to swap interactive buttons for non-interactive clones.

CSS hover media feature — overview

@media (hover) accepts two values: hover (primary device can hover) and none (primary device cannot hover or hover is not the main interaction mode). Touch-primary devices report hover: none because tapping activates an element — there is no intermediate hover state. Keyboards, switch access, and joysticks also report hover: none. Only mouse, trackpad (on most platforms), and stylus when hovering report hover: hover. Related: any-hover, pointer, any-pointer.

Attack 1: hover:none hides consent button for touch devices

A direct display: none or opacity: 0 under @media (hover: none) removes the consent button for all devices that cannot hover. This covers phones and tablets — the majority of mobile users. Desktop mouse users see the correct consent UI; touch users see nothing.

/* Attack: hide consent for non-hover devices */
.consent-btn {
  display: block; /* visible for hover-capable devices */
}

@media (hover: none) {
  .consent-btn {
    display: none;
    /* Affected: all phones, all tablets, keyboard-only users,
       joystick / game controller users.
       Desktop mouse auditors see no problem.
       Mobile users see no consent UI. */
  }
}
// Detection: CSSOM scan for hover:none hide on consent elements
function auditHoverNoneHide(consentEl) {
  const isNoneHover = window.matchMedia('(hover: none)').matches;
  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 (!/hover\s*:\s*none/.test(mq)) continue;
        for (const inner of rule.cssRules) {
          if (inner.type !== CSSRule.STYLE_RULE) continue;
          if (!consentEl.matches(inner.selectorText)) continue;
          const d = inner.style.display;
          const o = inner.style.opacity;
          const v = inner.style.visibility;
          if (d === 'none' || o === '0' || v === 'hidden') {
            console.warn('[SkillAudit] hover:none hides consent element:',
              d ? 'display:' + d : '', o ? 'opacity:' + o : '', v ? 'visibility:' + v : '',
              '| targets all touch + keyboard-only devices',
              '| selector:', inner.selectorText);
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  // Also check computed on current device if it reports hover:none
  if (isNoneHover) {
    const cs = getComputedStyle(consentEl);
    if (cs.display === 'none' || cs.opacity === '0' || cs.visibility === 'hidden') {
      console.warn('[SkillAudit] consent element hidden on hover:none device:', consentEl);
    }
  }
}

Wide blast radius: hover: none matches all touch-primary devices — phones, tablets, and basic touchscreen kiosks. In typical web traffic, this is 60–70% of users. Hiding consent for this group via a single media query is a high-severity finding.

Attack 2: consent reveal depends on :hover pseudo-class — never fires on touch

A CSS pattern that hides the consent button by default and only reveals it via :hover on a parent element creates an impossible interaction on touch devices. There is no hover state on touch — tapping immediately activates. The button is hidden in the default state and the reveal path does not exist for touch users.

/* Attack: consent only visible via :hover — touch devices cannot trigger this */
.consent-wrapper .consent-btn {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s;
}

.consent-wrapper:hover .consent-btn {
  opacity: 1;
  pointer-events: auto;
  /* This reveal requires hovering over .consent-wrapper.
     Touch tap immediately activates — there is no "hover" phase.
     The consent button never reaches opacity:1 on touch devices.
     A mouse audit sees the button appear on hover and passes.
     A touch audit sees permanent opacity:0. */
}

/* No @media (hover: none) fallback — touch users see nothing */
// Detection: :hover-gated reveal without hover:none fallback
function auditHoverOnlyReveal(consentEl) {
  let hasHoverReveal = false;
  let hasNoneFallback = false;
  const sheets = Array.from(document.styleSheets);
  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type === CSSRule.STYLE_RULE) {
          // Check for :hover rules that apply opacity:1 / display:block to consent
          if (/:hover/.test(rule.selectorText) && consentEl.matches(rule.selectorText.replace(/:hover[^\s,]*/g, '*'))) {
            const o = rule.style.opacity;
            const d = rule.style.display;
            const pe = rule.style.pointerEvents;
            if (o === '1' || d !== '' || pe === 'auto') hasHoverReveal = true;
          }
        }
        if (rule.type === CSSRule.MEDIA_RULE) {
          const mq = rule.conditionText || rule.media.mediaText;
          if (/hover\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.display !== '' || inner.style.visibility !== '') {
                  hasNoneFallback = true;
                }
              }
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  if (hasHoverReveal && !hasNoneFallback) {
    console.warn('[SkillAudit] consent element revealed only via :hover with no hover:none fallback;',
      'touch devices cannot hover — consent never becomes visible on phones/tablets;',
      'element:', consentEl);
  }
}

Attack 3: base opacity:0 with hover:hover-only restore — no hover:none path

A variation uses a base opacity: 0 rule (or display: none), then restores the consent button under @media (hover: hover). The hover: none path is never addressed. Touch devices get the default hidden state and there is no restoration for them. The structure looks like a legitimate responsive pattern — "show the hover-style button on hover-capable devices" — but the intent is to exclude touch users.

/* Attack: base hide + hover:hover restore, no hover:none restore */
.consent-btn {
  opacity: 0;           /* hidden by default */
  pointer-events: none;
}

@media (hover: hover) {
  .consent-btn {
    opacity: 1;          /* restored for mouse devices */
    pointer-events: auto;
  }
}
/* hover:none path: opacity stays 0. Touch users see nothing.
   This looks like "we're using hover-optimized UI" but
   the missing hover:none fallback is the attack vector. */
// Detection: base-hide without hover:none restore
function auditBaseHideNoFallback(consentEl) {
  let baseHidden = false;
  let hoverNoneRestores = false;
  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)) {
          // base rule hides the element
          if (rule.style.opacity === '0' || rule.style.display === 'none' || rule.style.visibility === 'hidden') {
            baseHidden = true;
          }
        }
        if (rule.type === CSSRule.MEDIA_RULE) {
          const mq = rule.conditionText || rule.media.mediaText;
          if (!/hover\s*:\s*none/.test(mq)) continue;
          for (const inner of rule.cssRules) {
            if (inner.type !== CSSRule.STYLE_RULE) continue;
            if (!consentEl.matches(inner.selectorText)) continue;
            if (inner.style.opacity === '1' || inner.style.display !== '' || inner.style.visibility !== '') {
              hoverNoneRestores = true;
            }
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  if (baseHidden && !hoverNoneRestores) {
    console.warn('[SkillAudit] consent element has base hide with no hover:none restore path;',
      'touch devices will never see the consent button;',
      'check for hover:hover restore and add hover:none fallback;',
      'element:', consentEl);
  }
}

Attack 4: JS reads matchMedia hover to swap consent button

JavaScript can query window.matchMedia('(hover: none)').matches to detect non-hover devices. A script that detects hover: none and replaces the interactive consent button with a non-interactive element, or sets pointer-events: none, bypasses consent for the entire touch population. This is a pure-JS variant that does not appear in CSS audits.

// Attack: JS hover:none detection + button swap
if (window.matchMedia('(hover: none)').matches) {
  const btn = document.querySelector('.consent-btn');
  if (btn) {
    const fake = document.createElement('div');
    fake.className = btn.className;
    fake.textContent = btn.textContent;
    // div has no click handler — visually identical, non-interactive
    btn.parentNode.replaceChild(fake, btn);
  }
}

// Or a more subtle variant: disable pointer events
if (window.matchMedia('(hover: none)').matches) {
  document.querySelector('.consent-btn')?.style.setProperty('pointer-events', 'none');
}
// Detection: JS source scan for hover matchMedia + element manipulation
function auditHoverJS() {
  for (const script of document.querySelectorAll('script')) {
    const src = script.textContent;
    if (!src || !/matchMedia/.test(src) || !/hover/.test(src)) continue;
    const hasManipulation = [
      /replaceChild|createElement|removeChild/,
      /pointer-events.*none/,
      /display.*none/,
      /style\.(opacity|display|visibility)\s*=/,
    ].some(p => p.test(src));
    if (hasManipulation) {
      console.warn('[SkillAudit] script uses hover matchMedia with element manipulation;',
        'verify consent button remains interactive on hover:none devices (phones, tablets);',
        'script:', script.src || '(inline)');
    }
  }
}

Findings summary

High hover:none hides consent button via display:none or opacity:0 — covers all touch-primary devices (phones, tablets) and keyboard-only inputs; desktop mouse auditors see no problem; detected by CSSOM scan for hover:none rules on consent elements and computed-style check on hover:none devices.
High consent revealed only via :hover pseudo-class with no hover:none fallback — touch devices cannot hover so the reveal never fires; button stays at opacity:0 / pointer-events:none on all phones and tablets; detected by checking :hover rules that restore consent visibility against absence of hover:none fallback block.
High base hide + hover:hover-only restore, no hover:none path — consent element hidden by default, restored for mouse devices, never restored for touch; the missing fallback is the attack vector; detected by auditing base hide rules against presence of hover:none restore block.
Medium JS hover:none matchMedia swap — replaces interactive consent button with non-interactive element on all touch devices; or sets pointer-events:none via script; detected by source scan for matchMedia hover combined with DOM manipulation or pointer-events injection.

SkillAudit audits hover and any-hover media query rules on consent elements, checking for hover:none hides, :hover-only reveals without fallbacks, and JS matchMedia hover manipulation. Run a free audit on your MCP server.