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