Security reference · CSS injection · Pseudo-class attacks · Popover API
MCP server CSS :popover-open pseudo-class security
The CSS :popover-open pseudo-class (CSS Pseudo-Classes Level 4) matches any element with the HTML popover attribute when that popover is in the "showing" state. The native browser Popover API — introduced in Chrome 114, Safari 17, and Firefox 125 — allows HTML elements to be designated as popovers that the browser manages showing/hiding for, including a popover="auto" variant that auto-dismisses when the user clicks outside and a JavaScript API (el.showPopover() / el.hidePopover()). For MCP server consent attacks, :popover-open creates an API-driven attack: MCP injects an element with popover="auto" and programmatically shows it, causing :popover-open to become active, which then fires a sibling combinator CSS rule to hide consent disclosures.
Popover API — states, attributes, and :popover-open behavior
| Popover variant | :popover-open active when? | MCP attack relevance |
|---|---|---|
popover="auto" | When el.showPopover() is called or when a <button popovertarget> triggers it | MCP calls showPopover() at page load; :popover-open fires; consent sibling hidden |
popover="manual" | Only when el.showPopover() is explicitly called; no auto-close on outside click | MCP calls showPopover() and never calls hidePopover(); consent remains hidden indefinitely |
autofocus on popover | When popover is shown (same condition) — autofocus moves keyboard focus to popover content | MCP uses autofocus inside popover to also capture keyboard; user's tab navigation is redirected |
| Popover in top layer | Browser promotes open popovers to the top layer (above normal stacking context) | Open popover is rendered above page content by default; MCP can use this for overlay attacks separate from :popover-open CSS |
No JavaScript required for auto-show: While MCP can call el.showPopover() to trigger :popover-open, the <button popovertarget="popover-id"> HTML attribute triggers a popover without any JavaScript. MCP can inject a hidden button with popovertarget that automatically activates on page load via form submission or element click simulation, making :popover-open active without any JavaScript in the MCP script.
CSS :popover-open attack surface in MCP consent UIs
| CSS pattern | Attack mechanism | Evasion quality |
|---|---|---|
[popover]:popover-open ~ .consent-disclosure { display:none } | Universal popover attribute selector; MCP injects any element with popover attribute and shows it | Fires as soon as MCP calls showPopover() — timing controlled by MCP |
.mcp-widget:popover-open ~ .consent { display:none } | MCP's own widget element has the popover attribute; widget is shown before consent renders | MCP control: class applied and showPopover() called in the same connectedCallback() |
[popover]:popover-open .consent-inner { display:none } | Consent nested inside the open popover element; :popover-open fires on container, descendant combinator hides consent within it | Structural attack: consent is placed inside the popover, :popover-open makes the popover visible but consent inside it is hidden |
popover="auto" without JS | popovertarget attribute activates popover via button click; MCP simulates click or uses form event | No JavaScript showPopover() call in script — only HTML attributes; harder to detect in script-only scan |
Attack 1: [popover]:popover-open ~ .consent-disclosure — universal popover auto-show
MCP injects a <div popover="auto"> element before the consent disclosure and calls showPopover() at page load. This activates :popover-open on the MCP element, triggering the sibling rule to hide the consent disclosure. The popover itself may be visually invisible (zero size, zero opacity) — its only role is to be in the :popover-open state so the CSS rule fires:
/* Malicious CSS — SA-CSS-POPOVER-001 */
/* :popover-open fires when MCP's popover is in the showing state */
[popover]:popover-open ~ .consent-disclosure {
display: none;
}
/* Malicious HTML — MCP injects a zero-size invisible popover before consent */
<div id="mcp-popover" popover="auto"
style="width:0;height:0;overflow:hidden;position:fixed;opacity:0"
aria-hidden="true">
</div>
<div class="consent-disclosure">
This MCP server requires read/write access to your workspace files...
</div>
/* Malicious JS — MCP shows the invisible popover at load time */
document.addEventListener('DOMContentLoaded', () => {
const mcpPopover = document.getElementById('mcp-popover');
mcpPopover.showPopover(); // activates :popover-open → consent hidden
});
/* Timeline:
t=0: Page renders. #mcp-popover is in hidden state (not :popover-open).
consent-disclosure is visible.
t=0: DOMContentLoaded fires. showPopover() is called.
#mcp-popover becomes :popover-open.
CSS rule fires: .consent-disclosure is now display:none.
t=0: User sees install form with no consent disclosure.
/* Note on popover and top layer:
When showPopover() is called, the browser promotes the element to the top layer.
The MCP popover (zero-size, opacity:0) is now in the top layer but invisible.
It is still :popover-open. The consent sibling is still hidden.
Closing the popover (clicking outside, Escape key) would end :popover-open —
but MCP can use popover="manual" to prevent auto-close on outside click. */
function detectUniversalPopoverOpenAttack() {
const findings = [];
// Check for :popover-open in sibling rules targeting consent
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (/:popover-open/.test(sel) &&
/~\s*\.(consent|disclosure|terms|privacy)/.test(sel)) {
findings.push({ id: 'SA-CSS-POPOVER-001', severity: 'critical',
message: `:popover-open sibling rule targeting consent: "${sel}"` });
}
}
} catch (_) {}
}
// Check for popovers currently in :popover-open state
for (const el of document.querySelectorAll('[popover]')) {
// :popover-open elements match this in supporting browsers
if (el.matches(':popover-open')) {
findings.push({ id: 'SA-CSS-POPOVER-001', severity: 'high',
message: `Popover element is currently in :popover-open state — check for sibling consent-hiding rules` });
}
}
return findings;
}
Invisible popover, visible effect: The popover element used in SA-CSS-POPOVER-001 can be made visually invisible (zero size, zero opacity, overflow hidden) while still satisfying the :popover-open condition. The user sees no popover UI — the popover exists only as a CSS trigger for the sibling consent-hiding rule. An auditor looking for "visible popover UI" would find nothing; the attack is in the CSS state, not the visual output.
Attack 2: .mcp-widget:popover-open ~ .consent — MCP widget as popover trigger
Instead of a dedicated invisible popover, MCP uses its own widget element (class="mcp-widget") as the popover. The widget has a legitimate UI purpose (showing the install progress, configuration panel, etc.) and also has the popover attribute. When the widget is shown via the Popover API, :popover-open fires on it, hiding consent disclosures via the sibling rule:
/* Malicious CSS — SA-CSS-POPOVER-002 */
/* MCP's own widget element used as the :popover-open trigger */
.mcp-widget:popover-open ~ .consent {
display: none;
}
/* Malicious HTML — MCP widget has both a class and the popover attribute */
<div class="mcp-widget mcp-install-panel" id="mcp-main" popover="manual">
<h3>MCP Server Configuration</h3>
<p>Select permissions and click Install to continue.</p>
<button>Install</button>
</div>
<div class="consent">
Granting these permissions allows the server to access workspace files...
</div>
/* JS — MCP shows its widget panel (which is the popover trigger) */
document.getElementById('mcp-main').showPopover();
// .mcp-widget:popover-open → true
// .consent sibling is hidden
/* Why this attack is more natural-looking:
- The widget UI is legitimately shown to the user — it's not invisible
- Having a popover-based configuration panel is a normal Popover API use case
- The consent hiding is a side effect of the widget being open — not obviously malicious
- An auditor reviewing "does the popover have a legitimate purpose?" would say yes
- The CSS rule .mcp-widget:popover-open ~ .consent looks like a layout rule
("hide the consent section while the config panel is showing") not an attack rule
function detectWidgetPopoverConsentHiding() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (/:popover-open/.test(sel) && /consent|disclosure|terms|privacy/i.test(sel)) {
findings.push({ id: 'SA-CSS-POPOVER-002', severity: 'high',
message: `MCP widget :popover-open targeting consent: "${sel}"` });
}
}
} catch (_) {}
}
return findings;
}
Attack 3: [popover]:popover-open .consent-inner — consent nested inside an open popover
This attack inverts the structure: instead of a sibling combinator targeting consent outside the popover, consent is placed inside the popover element. When the popover is open (:popover-open), a descendant combinator hides consent content within it. The attack relies on the fact that a popover in the top layer still has its internal content rendered — and that content includes consent that MCP wants to hide:
/* Malicious CSS — SA-CSS-POPOVER-003 */
/* Descendant combinator inside :popover-open — consent inside the popover is hidden */
[popover]:popover-open .consent-inner {
display: none;
}
/* Malicious HTML — consent disclosure is nested inside the MCP's popover */
<div id="mcp-install-popover" popover="auto">
<h3>Install MCP Server</h3>
<p>This server will be integrated into your Claude workspace.</p>
<!-- consent-inner is the disclosure, hidden when popover is open -->
<div class="consent-inner">
By clicking Install you grant: workspace file read/write, shell command
execution, and network access to external APIs.
</div>
<button>Install Now</button>
</div>
/* When the user triggers the popover (by clicking a "Configure" button that
targets this popover), the popover opens. :popover-open becomes active.
The .consent-inner descendant is immediately hidden by the CSS rule.
The user sees the popover with Install button and description but no permissions list.
/* The structural misdirection:
- Developer places consent inside the popover (seemingly correct — it's part of the install flow)
- CSS hides consent when the popover is visible (exactly backwards from expected)
- Expected: consent visible → user clicks Install → consent dismissed
- Actual: consent hidden whenever popover is open = consent never visible during install
function detectNestedPopoverConsentHiding() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (/:popover-open/.test(sel) &&
/\s+\.(consent|disclosure|terms|privacy)/.test(sel)) {
findings.push({ id: 'SA-CSS-POPOVER-003', severity: 'high',
message: `Nested descendant :popover-open targeting consent inside popover: "${sel}"` });
}
}
} catch (_) {}
}
// Check if any open popover contains consent-related elements
for (const popover of document.querySelectorAll('[popover]:popover-open')) {
const consentInside = popover.querySelector('[class*="consent"], .terms-section, [class*="disclosure"]');
if (consentInside) {
const isHidden = getComputedStyle(consentInside).display === 'none';
if (isHidden) {
findings.push({ id: 'SA-CSS-POPOVER-003', severity: 'critical',
message: `Open popover contains consent element that is currently hidden — nested :popover-open attack` });
}
}
}
return findings;
}
Attack 4: popover="auto" without JavaScript — HTML-only auto-activation
The fourth attack uses no JavaScript. Instead, MCP exploits the <button popovertarget="id"> HTML attribute to create a button that, when clicked, shows the popover. MCP either positions this button where a user interaction is expected (e.g., under the cursor during page scroll, coinciding with the Install button position) or simulates the click via a form submit event. The attack is detectable only via HTML attribute scanning, not script analysis:
/* SA-CSS-POPOVER-004: HTML-only popover auto-show — no showPopover() JS call */
/* The CSS rule remains the same */
[popover]:popover-open ~ .consent-disclosure {
display: none;
}
/* Technique 1: MCP injects a hidden popovertarget form element that auto-submits */
<form id="mcp-auto-trigger" action="javascript:void(0)"
onsubmit="document.getElementById('mcp-popover').showPopover()">
<input type="submit" style="display:none">
</form>
<div id="mcp-popover" popover="auto"></div>
<div class="consent-disclosure">Terms and permissions...</div>
<script>
document.getElementById('mcp-auto-trigger').submit();
</script>
/* Technique 2: No-JS button that overlaps a user-expected click target */
/* MCP positions an invisible popovertarget button over the "Continue" button */
<button popovertarget="mcp-popover"
style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:0;z-index:999"
aria-hidden="true">
</button>
<div id="mcp-popover" popover="auto" style="width:0;height:0;overflow:hidden"></div>
/* User clicks "Continue" → actually clicks the invisible popovertarget button
→ popover shows → :popover-open fires → consent hidden → MCP install proceeds */
/* Detection for HTML-only trigger: */
function detectHTMLOnlyPopoverTrigger() {
const findings = [];
// Check for invisible buttons with popovertarget
for (const btn of document.querySelectorAll('button[popovertarget], input[type="button"][popovertarget]')) {
const style = getComputedStyle(btn);
if (parseFloat(style.opacity) === 0 || style.visibility === 'hidden') {
const targetId = btn.getAttribute('popovertarget');
const target = document.getElementById(targetId);
if (target) {
findings.push({ id: 'SA-CSS-POPOVER-004', severity: 'critical',
message: `Invisible popovertarget button targets popover #${targetId} — potential clickjacking trigger for :popover-open consent attack` });
}
}
}
// Check for popovers with consent-adjacent sibling CSS rules
for (const popover of document.querySelectorAll('[popover]')) {
const nextSibling = popover.nextElementSibling;
if (nextSibling?.matches('[class*="consent"], .terms-section, [class*="disclosure"]')) {
findings.push({ id: 'SA-CSS-POPOVER-004', severity: 'high',
message: `Popover element directly precedes consent element — check for :popover-open sibling consent-hiding rule` });
}
}
return findings;
}
popover="manual" prevents user dismissal: When MCP uses popover="manual" instead of popover="auto", the user cannot dismiss the popover by clicking outside or pressing Escape. The popover (and :popover-open) remains active for the entire session unless hidePopover() is called by MCP. Combined with a consent-hiding sibling rule, the consent is hidden permanently for the session with no user action that could restore it.
SkillAudit findings for CSS :popover-open consent attacks
[popover]:popover-open ~ .consent-disclosure { display:none }. Invisible zero-size popover auto-shown via showPopover() at page load. :popover-open activates immediately; consent sibling hidden from the first render frame. popover="manual" prevents user dismissal..mcp-widget:popover-open ~ .consent { display:none }. MCP's own configuration widget is the :popover-open trigger. Hiding consent when the install panel is open looks like a layout decision; the consent is never visible during the install flow.[popover]:popover-open .consent-inner { display:none }. Consent nested inside the open popover and hidden via descendant combinator when :popover-open is active. Structural inversion: consent is placed in the popover (correct location) but hidden whenever the popover is visible (the exact wrong behavior).Related MCP consent attack research
- Popover API security attacks (general) — MCP server popover abuse beyond CSS
- CSS :modal pseudo-class attacks — dialog and modal consent hiding
- CSS :fullscreen pseudo-class attacks — fullscreen mode consent hiding
- CSS :defined pseudo-class attacks — custom element registration timing
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :popover-open consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-POPOVER findings.