CSS Interaction-Layer Consent Attacks: cursor:none, pointer-events Hijacking, :hover/:active/:focus Erasure, and HTML Accessibility Attribute Manipulation
Interaction-layer consent attacks occupy a structurally distinct threat category from text-erasure attacks. In a text-erasure attack (color:transparent, opacity:0, z-index:-1), the consent text pixels themselves are destroyed or hidden. In an interaction-layer attack, the text pixels are correct — WCAG contrast ratio passes, getComputedStyle returns the right color — but the user's ability to see the text at the critical moment, to click the intended button, to inspect the text, or to receive it via an assistive technology is precisely degraded. These attacks exploit state, timing, and the interaction model itself. Sixteen patterns across SA-CSS-CUR, SA-CSS-PSC, SA-CSS-ANO, and SA-HTML-ARH demonstrate the full attack surface, and explain why static CSS auditors cannot detect any of them without dynamic state simulation.
The critical distinction: erasure attacks vs. interaction-layer attacks
First-generation MCP consent audit engines work by reading computed styles at document load time. They query getComputedStyle(consentTextEl) and check properties like color, opacity, visibility, display, and z-index. This methodology correctly detects simple erasure attacks — consent text whose styles are wrong at rest. It misses the entire interaction-layer threat category, where styles are correct at rest and wrong only in specific interaction states.
The interaction-layer threat model can be divided into four sub-categories by the mechanism of attack:
- Cursor/pointer manipulation (SA-CSS-CUR): The consent UI renders correctly, but cursor feedback is suppressed, click routing is hijacked via invisible overlays, or touch events are blocked. The user cannot tell which button does what, or their taps are silently redirected.
- Pseudo-class state erasure (SA-CSS-PSC): Consent text is correct at rest, but CSS rules trigger at exactly the state when the user is about to act:
:hoverhides text while the user is reading with the mouse positioned over it;:activehides text during the 100–300ms the user is pressing the mouse button;:focushides text when a keyboard user navigates to the consent element. - Animation timing erasure (SA-CSS-ANO): Consent text fades to invisible at the exact click moment via
animation-play-state:paused+ JavaScriptmousedowntrigger, or is erased by an animation that completes while the user is still reading or before the user reaches the dialog. - Accessibility attribute suppression (SA-HTML-ARH): The consent text is visually present but removed from the accessibility tree via
aria-hidden="true", thehiddenattribute, or theinertattribute. Screen reader and keyboard users receive a fundamentally different (or empty) consent proposition.
WCAG passes, user consent fails: Every attack in this synthesis can be constructed such that the W3C WCAG 2.1 Level AA automated checker reports zero violations. Contrast ratio is fine (text pixels are correct when not in the attacked state). Keyboard navigation is technically present (focus moves to buttons). Screen reader output may be non-zero (other elements in the dialog are accessible). WCAG compliance is a necessary but deeply insufficient condition for honest consent UI.
SA-CSS-CUR: Cursor and pointer manipulation attacks
The SA-CSS-CUR family exploits the gap between visual rendering of consent text and the input model that processes user interaction. Four distinct patterns exist, targeting cursor feedback, click routing, text inspection, and mobile touch events.
SA-CSS-CUR-001: cursor:none on consent dialog root
cursor: none on the consent dialog root element (or any ancestor that covers the consent UI area) removes all cursor feedback from the entire consent interaction zone. The user moves their mouse over the Deny button and sees no pointer cursor, no hand icon, no affordance that the button is interactive. The most common deceptive variant applies cursor:none only to the Deny button while the Allow button retains cursor: pointer — creating an asymmetry that signals “Allow is clickable, Deny is not” without any text manipulation.
.consent-dialog {
cursor: none; /* removes all cursor feedback for entire consent zone */
}
/* Asymmetric variant — more deceptive: */
.deny-btn { cursor: none; } /* "looks inactive" */
.allow-btn { cursor: pointer; } /* "looks clickable" */
/* Detection:
getComputedStyle(denyBtn).cursor === 'none' while
getComputedStyle(allowBtn).cursor !== 'none' → CRITICAL asymmetry
getComputedStyle(consentRoot).cursor === 'none' → HIGH */
cursor: none on Deny button only: asymmetric cursor feedback implies Deny is non-interactive. Detection: cursor:none on Deny + cursor:pointer on Allow in same dialog → CRITICAL.SA-CSS-CUR-002: pointer-events:none on Deny button with transparent overlay
This attack places pointer-events: none on the visible Deny button and positions a transparent position: absolute element at identical screen coordinates, whose click handler calls accept() instead of deny(). From the user's perspective: they click the Deny button label, but the click is captured by an invisible overlay that triggers consent acceptance. The Deny button renders correctly — correct text, correct color, correct dimensions — but is entirely bypassed for input.
<div class="consent-dialog" style="position:relative">
<p class="consent-text">Authorize file system access?</p>
<button class="deny-btn" style="pointer-events:none">Deny</button>
<button class="allow-btn">Allow</button>
<div class="click-hijack" style="
position: absolute;
/* coordinates matching deny-btn exactly */
pointer-events: auto;
background: transparent;
z-index: 9999;
" onclick="grantAccess()"></div>
</div>
/* Detection:
getComputedStyle(denyBtn).pointerEvents === 'none' → HIGH
+ document.elementsFromPoint(denyCenter.x, denyCenter.y) contains
an element that is NOT denyBtn and has an onclick that calls accept → CRITICAL */
elementsFromPoint() at Deny center returns non-Deny element with onclick → CRITICAL.SA-CSS-CUR-003 and SA-CSS-CUR-004: inspection blocking and mobile tap disabling
user-select: none on consent text (SA-CSS-CUR-003) prevents the user from selecting, copying, and pasting consent text into another context for careful reading or comparison. On its own this is a MEDIUM finding — the text is still visually present — but combined with small font size, light contrast, or overflow clipping, the compound severity escalates to HIGH. touch-action: none on the consent button (SA-CSS-CUR-004) suppresses the synthesized click event on mobile: a tap generates a touchstart + touchend sequence, and the browser synthesizes a click event if touch-action permits it. Setting touch-action: none requires a JavaScript touchend handler to process the tap — an opportunity to invert the button's intended action.
SA-CSS-PSC: Pseudo-class state-triggered erasure
The pseudo-class state attacks are the category most thoroughly invisible to static analysis. CSS pseudo-class rules are not properties of elements; they are rules that apply conditionally based on interaction state. getComputedStyle at document load time resolves styles in the element's resting state: no element is hovered, no element is focused, no element is active. The resting state is exactly the state that static auditors observe. The attacked states are precisely the states that static auditors never reach.
SA-CSS-PSC-001: :hover{color:white} text erasure at reading position
A rule .consent-text:hover { color: white; background: white; } causes the consent text to become invisible at the exact moment the user is reading it with the mouse positioned over it. The cognitive model of the attack is: hovering over text and reading text are simultaneous user actions for mouse-using adults who read by pointing. The most careful readers are therefore the most severely affected. The text is correct (dark, high-contrast) at mouse-away position and becomes invisible (white on white) at mouse-hover position — the reverse of normal hover highlighting.
/* Attack: */
.consent-text:hover {
color: white; /* text pixels match background: invisible */
background: white; /* background enforced at hover */
}
/* Resting state: correct high-contrast text (static auditor sees this) */
.consent-text {
color: #111;
background: white;
}
/* Why static auditors miss this:
getComputedStyle(el) at document load → color: "rgb(17, 17, 17)" (correct)
The :hover rule is only applied when the browser resolves hover state.
To detect this programmatically:
1. Simulate hover: el.dispatchEvent(new MouseEvent('mouseover', {bubbles:true}))
2. Re-query: getComputedStyle(el).color
3. If color changed to match background → CRITICAL SA-CSS-PSC-001
OR: Parse stylesheet CSSRules for :hover selectors on consent elements
and check if the property/value pair constitutes erasure */
Why careful readers are most affected: The behavioral model of mouse users who carefully read consent text is: move mouse to text, read text, then move mouse to button. The hover attack precisely targets the “move mouse to text, read text” phase — the phase that distinguishes careful readers from fast-clickers. A user who clicks without reading is unaffected (they skip the hover phase). A user who reads carefully by pointing the cursor at each line is fully affected.
.consent-text:hover { color: white }: text invisible when mouse positioned for reading. Detection: simulate mouseover → re-query color; or parse stylesheet :hover rules on consent elements → CRITICAL.SA-CSS-PSC-002: :active{opacity:0} transparent during click
.consent-container:active { opacity: 0 } makes the entire consent UI transparent during the 100–300ms browser active state triggered by pressing the mouse button. The attack's timing is precise: the user's visual perception of the consent text is available before and after the click, but the last visual state they observe while committing the action is zero. This pattern exploits the change blindness phenomenon — users do not notice a brief opacity change during the active state when they are focused on the physical act of clicking. For the consent dialog itself, the text disappears at the exact moment the user is pressing the button, removing any last-second visual confirmation.
.consent-container:active {
opacity: 0; /* transparent during 100-300ms mouse-button-down state */
}
/* Timeline:
t=0: User sees consent text correctly
t=T (mousedown): :active triggers → opacity:0 → everything invisible
t=T+200ms: User releases button → click event fires → opacity:1
t=T+200ms: Consent UI becomes visible again, now showing "accepted" state
The user's cognitive experience:
"I saw the consent text, pressed the button,
and it briefly blipped — but it worked fine."
(Common UX misread as a rendering glitch)
Detection: Simulate mousedown event on consent container
→ check getComputedStyle(el).opacity → '0' → CRITICAL */
:active { opacity: 0 } on consent container: zero opacity during 100–300ms click state; user's last visual before click commits is invisible. Detection: simulate mousedown, observe opacity change → CRITICAL.SA-CSS-PSC-003 and SA-CSS-PSC-004: keyboard and focus-visible targeting
.consent-text:focus { display: none } (SA-CSS-PSC-003) removes the consent text from the layout when a keyboard user's focus lands on it. The Tab key moves focus to the consent text element, and at the exact moment it receives focus, the text disappears. The keyboard user navigating the consent dialog — arguably the most deliberate and careful class of user — receives the exact consent text they are trying to read, removed. The :focus-visible variant (SA-CSS-PSC-004) refines this attack to target only keyboard-initiated focus (not pointer-initiated focus), exploiting the behavioral difference between :focus and :focus-visible that many auditors are not yet testing for.
Keyboard users are the highest-intent readers: Keyboard-primary users (power users, accessibility users, users of assistive technology) navigate consent dialogs with Tab/Enter rather than mouse clicks. They are statistically more likely to read consent text carefully before acting. The :focus attacks specifically target these users: the more carefully someone navigates the consent UI via keyboard, the more completely the attack activates.
SA-CSS-ANO: Animation-play-state timing attacks
The animation attacks documented in SA-CSS-ANO deserve special mention in the interaction-layer context because one pattern — SA-CSS-ANO-004 — is a pure interaction-layer attack: the animation does not run at all until the user begins clicking. At rest, the consent text is fully visible. The animation-play-state is paused. A JavaScript mousedown listener on the consent container sets animation-play-state: running at the exact moment the user presses the mouse button. A 0.3–0.5s fade-to-transparent animation begins, and the click event fires approximately 200ms later with the consent text now at 40% opacity (mid-fade).
/* Setup: animation defined but PAUSED */
.consent-text {
animation: fadeOut 0.3s ease-in forwards;
animation-play-state: paused; /* visible at rest */
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* Trigger: JavaScript mousedown handler */
document.querySelector('.consent-container').addEventListener('mousedown', () => {
document.querySelector('.consent-text').style.animationPlayState = 'running';
/* Fade begins at mousedown; click event fires ~200ms later */
/* At click time: opacity ~= 1 - (200/300) = 0.33 */
/* User's last visual state: semi-transparent text */
});
/* Why static auditors miss this:
- animation-play-state: paused → getAnimations() returns the animation,
but it is paused at t=0 with opacity:1 → no anomaly detected
- The trigger is a JavaScript addEventListener, not a CSS rule
- Only dynamic analysis (simulate mousedown, observe computed opacity change)
or source analysis (look for addEventListener('mousedown') + style assignment)
can detect this pattern */
SA-HTML-ARH: HTML accessibility attribute suppression
The accessibility attribute attacks are conceptually different from visual consent attacks: they operate on the accessibility tree rather than the visual render tree. A screen reader user and a visual user receive fundamentally different consent information. The visual user sees the consent text; the screen reader user either hears nothing (aria-hidden, hidden, inert) or hears a different, less alarming description (aria-label mismatch).
SA-HTML-ARH-001: aria-hidden="true" — accessibility tree removal
aria-hidden="true" on a consent text element causes screen readers to skip the element entirely. The text is visually present, correctly styled, and fully readable by sighted users. The WCAG contrast check passes. But the element is absent from the accessibility tree: any user relying on a screen reader (blind users, low-vision users, users with motor impairments using voice control) does not hear the consent proposition at all. They hear the dialog title (if present), then the Allow and Deny buttons. The consent text that explains what they are authorizing is silently removed from their experience.
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Authorize access</h2>
<p class="consent-text" aria-hidden="true">
Clicking Allow grants permanent file system access.
</p>
<button>Allow</button>
<button>Deny</button>
</div>
/* Screen reader experience:
"Dialog, Authorize access. Allow. Deny."
(Consent text paragraph completely absent from spoken output)
Visual experience:
Consent text is visible, correctly styled.
Detection:
el.getAttribute('aria-hidden') === 'true' on consent text element → CRITICAL
Note: aria-hidden is a DOM attribute; getComputedStyle does not reflect it.
CSS-only auditors miss this entirely. */
SA-HTML-ARH-003: inert attribute (WHATWG 2022)
The inert attribute, standardized in the WHATWG HTML Living Standard in 2022 and now supported across all major browsers, makes all descendants of the inerted element non-focusable, non-interactive, and invisible to the accessibility tree simultaneously. It is a single attribute that achieves the compound effect of pointer-events: none + aria-hidden: true + tab-index removal. Applied to a consent text element or its container, inert prevents keyboard focus, blocks click events (via pointer-events), and removes screen reader output. Because it is a relatively new attribute (2022), many audit tools lack specific checks for it, making it currently the lowest-detection-rate accessibility attack vector.
<div class="consent-text-wrapper" inert>
<p>By clicking Allow you authorize permanent file access to all user documents.</p>
</div>
<button>Allow</button>
<button>Deny</button>
/* Effects of inert on .consent-text-wrapper and all descendants:
1. Non-focusable: Tab key skips all descendants
2. Non-interactive: clicks, mousedown, keypress blocked
3. Accessibility-hidden: screen readers skip
4. getComputedStyle: NO change — color/opacity/visibility all appear normal
5. Layout: unchanged — text occupies space, getBoundingClientRect() works
The inert attribute is a DOM attribute; it has no corresponding CSS property.
CSS-only auditors and getComputedStyle-based auditors miss it entirely.
Detection:
el.closest('[inert]') !== null on consent text → CRITICAL
OR: el.inert === true (DOM property) → CRITICAL */
inert is a 2022 API with limited audit coverage: The inert attribute achieved cross-browser support in 2023 (Chrome 102, Firefox 112, Safari 15.5). Most existing MCP consent audit tools were written before this attribute was widely supported and do not check for it. el.closest('[inert]') is a one-line detection that currently misses most field-deployed audit implementations.
el.closest('[inert]') → CRITICAL.SA-HTML-ARH-004: aria-label mismatch — screen reader substitution
aria-label mismatch attacks provide a different consent text to screen reader users than to visual users. The visual consent text reads “Clicking Allow grants permanent file system access to all user documents.” The element carries aria-label="Confirm your choice". Screen readers speak the aria-label, not the text content. Visually impaired users receive a vague, non-alarming label instead of the specific consent proposition. This attack is particularly dangerous because it passes every visual check: the visual consent text is correct. It also passes basic screen reader presence checks: the dialog has speech output. Only an audit that compares visual text content against aria-label value will detect the mismatch.
Why static CSS auditors miss the entire interaction-layer category
The fundamental limitation of static CSS audit methodology is that getComputedStyle resolves element styles at the time of the query, in the element's current state. At document-load audit time, no user interactions have occurred. The state of every element is: not hovered, not active, not focused. This is exactly the resting state in which interaction-layer attacks are invisible.
| Attack type | Static CSS audit | Why missed | Required detection method |
|---|---|---|---|
| cursor:none asymmetry (SA-CSS-CUR-001) | Detectable | cursor property is in computed style at rest | getComputedStyle(el).cursor at rest |
| pointer-events:none + overlay (SA-CSS-CUR-002) | Partial | pointer-events:none visible; overlay onclick not | pointerEvents check + elementsFromPoint() analysis |
| :hover erasure (SA-CSS-PSC-001) | Missed | :hover not active at audit time | Simulate mouseover + re-query, OR parse CSSRules |
| :active opacity (SA-CSS-PSC-002) | Missed | :active not active at audit time | Simulate mousedown + observe opacity |
| :focus display:none (SA-CSS-PSC-003) | Missed | :focus not active at audit time | el.focus() + re-query display property |
| :focus-visible hidden (SA-CSS-PSC-004) | Missed | :focus-visible not active; requires keyboard simulation | Playwright keyboard Tab simulation, not .focus() call |
| Animation mousedown trigger (SA-CSS-ANO-004) | Missed | Animation paused at rest; JS listener not in CSS | Simulate mousedown + observe animation running state |
| aria-hidden suppression (SA-HTML-ARH-001) | Missed | aria-hidden is a DOM attribute, not a CSS property | el.getAttribute('aria-hidden') check |
| inert attribute (SA-HTML-ARH-003) | Missed | inert is a DOM attribute; no CSS equivalent | el.closest('[inert]') or el.inert check |
| aria-label mismatch (SA-HTML-ARH-004) | Missed | Visual text ≠ aria-label; requires text content comparison | Compare textContent vs aria-label on consent text el |
Detection methodology: dynamic state simulation
Detecting interaction-layer attacks requires that the audit engine simulate each interaction state and re-observe computed styles. The core algorithm for pseudo-class attacks:
async function auditInteractionLayerAttacks(consentEl) {
const findings = [];
const baseline = captureStyles(consentEl);
// 1. Hover simulation
consentEl.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
await microtask();
const hoverStyles = captureStyles(consentEl);
if (isErased(hoverStyles, baseline)) {
findings.push({ id: 'SA-CSS-PSC-001', severity: 'CRITICAL',
desc: ':hover state erases consent text' });
}
consentEl.dispatchEvent(new MouseEvent('mouseout', {bubbles: true}));
// 2. Active simulation
consentEl.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}));
await microtask();
const activeStyles = captureStyles(consentEl);
if (isErased(activeStyles, baseline)) {
findings.push({ id: 'SA-CSS-PSC-002', severity: 'CRITICAL',
desc: ':active state erases consent text' });
}
consentEl.dispatchEvent(new MouseEvent('mouseup', {bubbles: true}));
// 3. Focus simulation
consentEl.focus();
await microtask();
const focusStyles = captureStyles(consentEl);
if (isErased(focusStyles, baseline)) {
findings.push({ id: 'SA-CSS-PSC-003', severity: 'CRITICAL',
desc: ':focus state erases consent text' });
}
consentEl.blur();
// 4. Accessibility attribute checks (no simulation needed — DOM state)
if (consentEl.getAttribute('aria-hidden') === 'true')
findings.push({ id: 'SA-HTML-ARH-001', severity: 'CRITICAL',
desc: 'aria-hidden="true" removes element from accessibility tree' });
if (consentEl.closest('[inert]'))
findings.push({ id: 'SA-HTML-ARH-003', severity: 'CRITICAL',
desc: 'inert attribute disables all interaction and accessibility' });
const ariaLabel = consentEl.getAttribute('aria-label');
if (ariaLabel && !consentEl.textContent.trim().includes(ariaLabel.trim()))
findings.push({ id: 'SA-HTML-ARH-004', severity: 'HIGH',
desc: 'aria-label does not match visual text content' });
return findings;
}
function captureStyles(el) {
const cs = getComputedStyle(el);
return { color: cs.color, opacity: cs.opacity, visibility: cs.visibility,
display: cs.display, pointerEvents: cs.pointerEvents };
}
function isErased(current, baseline) {
// returns true if key erasure properties changed from baseline
return current.opacity !== baseline.opacity ||
current.visibility !== baseline.visibility ||
current.display !== baseline.display ||
(current.color !== baseline.color &&
isBackgroundMatchingColor(current.color));
}
:focus-visible requires Playwright, not .focus(): The :focus-visible pseudo-class applies based on the browser's heuristic for whether focus was keyboard-initiated. Calling el.focus() programmatically from a script is typically treated as pointer-equivalent focus and does not trigger :focus-visible. Detecting SA-CSS-PSC-004 reliably requires a real keyboard Tab key simulation via Playwright or Puppeteer's page.keyboard.press('Tab') until focus reaches the consent element, then observing computed visibility.
Complete SA-CSS-CUR, SA-CSS-PSC, SA-CSS-ANO, SA-HTML-ARH summary
| ID | Severity | Family | Attack mechanism | Static CSS auditor |
|---|---|---|---|---|
| SA-CSS-CUR-001 | CRITICAL | CUR | cursor:none on Deny, pointer on Allow — asymmetric affordance | Detectable |
| SA-CSS-CUR-002 | CRITICAL | CUR | pointer-events:none Deny + transparent overlay accept handler | Partial |
| SA-CSS-CUR-003 | MEDIUM | CUR | user-select:none blocks select-copy inspection | Detectable |
| SA-CSS-CUR-004 | HIGH | CUR | touch-action:none mobile tap disabling + JS handler inversion | Partial |
| SA-CSS-PSC-001 | CRITICAL | PSC | :hover{color:white} — text invisible when mouse positioned for reading | Missed |
| SA-CSS-PSC-002 | CRITICAL | PSC | :active{opacity:0} — transparent during 100–300ms click state | Missed |
| SA-CSS-PSC-003 | CRITICAL | PSC | :focus{display:none} — consent removed on keyboard focus | Missed |
| SA-CSS-PSC-004 | HIGH | PSC | :focus-visible{visibility:hidden} — keyboard-only focus targeting | Missed |
| SA-CSS-ANO-004 | CRITICAL | ANO | animation-play-state:paused → JS mousedown trigger — 0.3s fade at click moment | Missed |
| SA-HTML-ARH-001 | CRITICAL | ARH | aria-hidden="true" — accessibility tree removal | Missed |
| SA-HTML-ARH-002 | HIGH | ARH | hidden attribute — visual + accessibility tree removal | Missed |
| SA-HTML-ARH-003 | CRITICAL | ARH | inert attribute — non-focusable + click-blocked + a11y-hidden | Missed |
| SA-HTML-ARH-004 | HIGH | ARH | aria-label mismatch — screen reader hears different consent proposition | Missed |
Implications for MCP server consent audits
An MCP server consent audit that relies solely on static CSS property inspection will report a clean bill of health on a consent UI that is entirely non-functional for keyboard users (SA-CSS-PSC-003, SA-CSS-PSC-004), entirely absent for screen reader users (SA-HTML-ARH-001, SA-HTML-ARH-003), actively deceptive for mouse users who hover to read (SA-CSS-PSC-001), and manipulated at the precise moment of click commitment (SA-CSS-PSC-002, SA-CSS-ANO-004). The attack surface is not edge-case or theoretical: every attack in this synthesis can be built in ten lines of CSS or five lines of JavaScript and will pass every existing automated WCAG compliance checker.
Comprehensive consent security requires three additional audit capabilities beyond static style inspection:
- Dynamic state simulation: the audit engine must simulate hover, active, focus, mousedown, and keyboard Tab navigation, and re-observe computed styles after each state transition.
- DOM attribute inspection:
aria-hidden,hidden,inert,aria-label, andaria-labelledbyon consent text elements must be checked independently of CSS computed styles. - Spatial click routing analysis:
document.elementsFromPoint()at the center coordinates of each consent button must return the consent button as the top interactive element, not a transparent overlay with a different handler.