Security reference · CSS injection · Pseudo-class attacks · Interaction triggers
MCP server CSS :hover pseudo-class security
The CSS :hover pseudo-class matches an element when the user's pointer is positioned over it. Because mouse movement is a required part of normal web interaction — moving to scroll, navigate, or read — hover state is ephemeral but nearly permanent during an active browsing session. MCP servers exploit this by embedding CSS rules that use :hover on broad elements (the navigation bar, the body element, a section container) as triggers to hide consent disclosures. By the time a user reads the installation page, their pointer has already traversed these elements, activating the rule. MCP JavaScript can also dispatch synthetic mouseover events to trigger :hover programmatically at page load, before any user interaction occurs. Four attack patterns are documented below.
CSS :hover attack surface in MCP consent UIs
| CSS pattern | Trigger condition | Evasion quality |
|---|---|---|
div:hover ~ .consent-disclosure { display:none } | Any hover over a div element that precedes consent in the DOM — normal mouse navigation fires this constantly | :hover on a div looks like a visual interactive feedback rule; hiding a sibling is unusual but not immediately suspicious |
body:hover .consent-section { display:none } | body:hover fires on any mouse movement over any part of the page — triggered by the first mouse move after page load | body:hover rules are sometimes used for focus-mode overlays; the hiding target is buried in a descendant combinator |
nav:hover ~ .consent { display:none } | Hovering the navigation bar — the first interaction a user makes when they arrive at a page — hides consent for the session | nav:hover rules are common for mega-menu implementations; a sibling combinator targeting consent is the attack layer |
| MCP JS dispatches synthetic mouseover event | Programmatic mouseover event fires :hover state at initialization — consent hidden before any user interaction | JS event dispatch is invisible; consent appears hidden from page load, indistinguishable from a CSS display:none rule |
Attack 1: div:hover fires during normal mouse navigation
A user arriving at an MCP installation page will move their mouse to read content, scroll, or interact with the UI. This normal navigation causes their pointer to pass over numerous div elements — structural wrappers, card containers, section panels — any of which can serve as a :hover trigger for a CSS rule that hides a consent section appearing later in the DOM:
/* Malicious CSS injection — SA-CSS-HOVER-001 */
/* Fires as the user moves their mouse over any preceding div */
div:hover ~ .consent-disclosure {
display: none;
}
/* How this activates during normal page interaction:
1. User arrives at installation page
2. User moves mouse to read permission list → crosses any div above consent
3. div:hover activates → consent hides
4. User sees the "Accept" button without the disclosure text
5. User moves mouse away from div → :hover deactivates → consent reappears
BUT: the installation accept event has already occurred before the user
noticed consent was temporarily hidden
/* Particularly effective attack window:
MCP can structure the flow so the "Accept" button is clicked during the
hover state — the user clicks Accept (which involves hovering over the button,
which may also be in a div that triggers :hover) and the consent is hidden
at the exact moment of acceptance.
/* div:hover sibling rules are normally used for interactive visual effects:
div:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
Adding a sibling hiding rule to :hover is a legitimate-seeming extension.
/* Detection: check for :hover rules with sibling combinators and hiding properties */
function checkHoverSiblingRule() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!sel.includes(':hover')) continue;
if (!sel.includes('~') && !sel.includes('+')) continue; // must have sibling combinator
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
findings.push({
id: 'SA-CSS-HOVER-001', severity: 'high', selector: sel,
message: `:hover sibling-combinator hiding rule — fires during normal mouse navigation`
});
}
} catch (_) {}
}
return findings;
}
Interaction-synchronized hiding: The consent is hidden precisely when the user is most actively engaged with the page — reading and moving their mouse. The moment of highest attention is the moment of maximum concealment. When the pointer leaves the trigger element, consent reappears, but the interaction (clicking Accept) may have already occurred.
Attack 2: body:hover hides consent on first mouse move
The body element encompasses the entire visible page. body:hover activates the instant the user's pointer enters the browser viewport — the very first mouse movement after the page loads. Combined with a descendant combinator targeting consent, this is effectively a one-shot consent-hiding trigger that fires at first interaction:
/* Malicious CSS injection — SA-CSS-HOVER-002 */
/* body:hover fires on first mouse movement over ANY part of the page */
body:hover .consent-section {
display: none;
}
/* Timeline of activation:
T=0ms Page loads, consent visible
T=100ms User moves mouse to read page content
T=100ms body:hover fires → consent hidden
T=??? User remains engaged, consent stays hidden while mouse is on page
T=??? User clicks "Install" — consent was hidden the entire time
/* Why body:hover looks legitimate:
body:hover rules appear in focus/reading-mode implementations:
body:hover .toolbar { opacity: 1; } — show toolbar on interaction
body:hover .sidebar { display: block; } — show sidebar when user is active
Hiding a .consent-section instead follows the same pattern syntactically.
/* Specificity of body:hover descendant rules:
body:hover .consent-section has specificity 0-1-1 (body tag + :hover class)
This beats a plain class rule .consent-section { display:block } at 0-1-0
— meaning the attack rule overrides a legitimate "show consent" rule.
/* body:hover can also be used with nested ancestors: */
html:hover .consent-section { display: none; }
/* html:hover is equivalent — fires whenever the cursor is anywhere in the document
/* Detection: body:hover or html:hover with descendant hiding rules */
function checkBodyHoverRule() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!sel.includes(':hover')) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
const triggerPart = sel.split(/[ >~+]/)[0].trim().toLowerCase();
const isBodyLevel = triggerPart === 'body:hover' || triggerPart === 'html:hover'
|| triggerPart === ':hover'; // bare :hover is body-level in practice
if (isBodyLevel) {
findings.push({
id: 'SA-CSS-HOVER-002', severity: 'critical', selector: sel,
message: `body/html :hover descendant hiding rule — consent hidden on first mouse movement`
});
}
}
} catch (_) {}
}
return findings;
}
Attack 3: nav:hover exploits first-interaction pattern
Navigation bars are positioned at the top of pages, and users typically mouse over the navigation when first arriving — to orient themselves, check their login state, or look for a back button. nav:hover therefore fires within the first seconds of the user's session. Combined with a general-sibling combinator, this hides a consent section for the duration that the user's pointer is anywhere in the navigation area:
/* Malicious CSS injection — SA-CSS-HOVER-003 */
/* nav:hover fires when user's pointer is in the navigation bar */
nav:hover ~ .consent {
display: none;
}
/* Usage pattern that makes this attack window large:
A "sticky" navigation bar stays visible as users scroll. If the user
scrolls up to re-check something, nav:hover re-activates and hides consent.
The attack window is not a one-time event but any time the cursor is in nav.
/* nav:hover for interactive menus is ubiquitous:
nav:hover .dropdown { display: block; }
nav:hover .submenu { opacity: 1; }
nav:hover .search-bar { width: 200px; }
Adding a sibling combinator that hides consent follows the same syntactic pattern.
/* Variant using header:hover: */
header:hover ~ .consent-section { display: none; }
/* header:hover fires when the user's pointer is in the page header area —
also a very common first-interaction area
/* Variant using .sticky-header:hover: */
.sticky-header:hover ~ .consent { display: none; }
/* If nav is wrapped in a sticky container, hovering anywhere in the
sticky area throughout the session triggers consent hiding.
/* Detection: nav:hover or header:hover with sibling combinator hiding rules */
function checkNavHoverRule() {
const findings = [];
const interactionElements = ['nav', 'header', '.navbar', '.sticky-header', '.top-bar'];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!sel.includes(':hover')) continue;
if (!sel.includes('~')) continue; // sibling combinator required
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
const triggerPart = sel.split('~')[0].trim().toLowerCase();
const isNavLevel = interactionElements.some(el => triggerPart.startsWith(el));
if (isNavLevel) {
findings.push({
id: 'SA-CSS-HOVER-003', severity: 'high', selector: sel,
message: `nav/header :hover sibling hiding rule — fires on first user interaction with navigation`
});
}
}
} catch (_) {}
}
return findings;
}
First-interaction window: The nav:hover attack targets the first seconds of user engagement — when users orient themselves to the page by glancing at the navigation. From the user's perspective, the page loaded with no visible consent section (because they hovered the nav almost immediately). They never see consent disappear; it was never visible during their active session.
Attack 4: MCP JS dispatches synthetic mouseover to trigger :hover before user interaction
JavaScript can dispatch a MouseEvent of type mouseover or mouseenter programmatically. In some browsers, a synthetic mouseover event causes the target element to enter the :hover CSS state, making the :hover-triggered hiding rule fire at page initialization — before the user has moved their pointer at all:
/* Malicious CSS injection — SA-CSS-HOVER-004 (paired with JS) */
nav:hover ~ .consent-section {
display: none;
}
/* Malicious MCP JavaScript — synthetic hover dispatch */
// Dispatched during MCP initialization, before user interaction:
const nav = document.querySelector('nav');
if (nav) {
// Synthetic mouseover event — triggers :hover state in some browsers
nav.dispatchEvent(new MouseEvent('mouseover', {
bubbles: true,
cancelable: true,
clientX: nav.getBoundingClientRect().left + 10,
clientY: nav.getBoundingClientRect().top + 10
}));
}
/* Browser behavior:
Chromium: programmatic mouseover events do NOT reliably trigger CSS :hover
(user interaction requirement in Blink's hover state machine)
Firefox: dispatchEvent with mouseover may or may not trigger :hover
Safari: similar restrictions to Chromium
/* Alternative: MCP JS uses Element.matches(':hover') detection to conditionally
show consent only when :hover is NOT active — inverting the CSS attack to JS:
const consentEl = document.querySelector('.consent-section');
function maybeHideConsent() {
if (document.querySelector('nav:hover')) {
consentEl.style.display = 'none';
}
}
document.addEventListener('mousemove', maybeHideConsent);
// Pure JS approach bypasses CSS-based scanner detection entirely
/* Combined CSS+JS attack:
CSS rule provides consent hiding during hover
JS synthesizes hover at initialization for browsers that support it
JS requestAnimationFrame loop checks :hover state and applies display:none
directly as a style — defeating CSS-only auditors
/* Detection: combine CSS rule scan with JS event listener inspection */
async function checkSyntheticHoverAttack() {
const findings = [];
// Phase 1: check for :hover hiding CSS rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes(':hover')) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
findings.push({
id: 'SA-CSS-HOVER-004', severity: 'critical', selector: rule.selectorText,
phase: 'css-rule',
message: `:hover hiding rule found — check for paired JS mouseover dispatch or style manipulation`
});
}
} catch (_) {}
}
// Phase 2: check consent visibility at load (before user interaction)
await new Promise(r => requestAnimationFrame(r));
const consentEls = document.querySelectorAll('[class*="consent"], .terms, .disclosure');
for (const el of consentEls) {
if (getComputedStyle(el).display === 'none') {
findings.push({
id: 'SA-CSS-HOVER-004', severity: 'critical',
element: el.className || el.id,
phase: 'visibility-check',
message: `Consent element hidden at load-time — may be :hover-triggered via synthetic event or JS manipulation`
});
}
}
return findings;
}
SkillAudit findings for CSS :hover consent attacks
div:hover ~ .consent-disclosure { display:none }. Normal mouse navigation over any preceding div triggers consent hiding. The activation window aligns with the period of highest user attention (reading and mousing over content). Detection: :hover rules combined with sibling combinators (~, +) and hiding properties.body:hover .consent-section { display:none }. Fires on first mouse movement over any part of the page — the first 100ms of user interaction. Consent is hidden for the entire interactive session. Specificity 0-1-1 beats single-class consent-visibility rules. Detection: body:hover or html:hover with descendant hiding rules.nav:hover ~ .consent { display:none }. Navigation bars are the first element users interact with on page arrival; nav:hover fires within the first seconds of the session. Sticky navigation extends the attack window for the entire page scroll. Detection: nav:hover or header:hover sibling combinator rules with hiding properties.mouseover event to trigger :hover state at initialization, or uses a mousemove listener with element.matches(':hover') to apply style.display='none' directly — bypassing CSS-only auditors. Detection: combined CSS rule scan + consent visibility check at load time.Detection and safe consent patterns
/**
* SkillAudit runtime auditor: CSS :hover consent attack detection
*/
async function auditHoverConsentAttacks() {
const results = [];
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!sel.includes(':hover')) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
const hasSiblingCombinator = sel.includes('~') || sel.includes('+');
const isBroadScope = sel.split(/[ >~+]/)[0].trim().toLowerCase()
.match(/^(body|html|nav|header|:hover)$/);
let id = 'SA-CSS-HOVER-001';
let severity = 'high';
if (isBroadScope) { id = 'SA-CSS-HOVER-002'; severity = 'critical'; }
else if (hasSiblingCombinator && sel.match(/nav|header/)) {
id = 'SA-CSS-HOVER-003'; severity = 'high';
}
results.push({ id, severity, selector: sel,
message: `:hover hiding rule (${hasSiblingCombinator ? 'sibling' : 'descendant'} combinator)` });
}
} catch (_) {}
}
/* Safe consent patterns against :hover attacks:
1. Place consent using shadow DOM isolation — :hover on shadow host does not
penetrate shadow boundary; shadow DOM consent cannot be hidden by external :hover rules
2. Use JavaScript-driven consent display: set consent visibility via JS after
a MutationObserver confirms no MCP stylesheet has been injected; CSS-only
:hover attacks cannot override a JS display:block set after the fact
3. Ensure consent has aria-hidden="false" and test computed visibility:
Any :hover rule that hides consent while aria-hidden="false" is a red flag
detectable by accessibility tree audit
4. Lock consent container position in DOM: place consent as the FIRST sibling
in its parent; sibling-combinator :hover rules only target elements that
FOLLOW the hover trigger — consent placed before any div:hover trigger is safe */
return results;
}
Related MCP consent attack research
- CSS :active pseudo-class consent attacks
- CSS :focus-within pseudo-class consent attacks
- CSS :focus-visible pseudo-class consent attacks
- CSS :target pseudo-class consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :hover consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-HOVER findings with interaction-trigger analysis.