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 itMCP 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 clickMCP calls showPopover() and never calls hidePopover(); consent remains hidden indefinitely
autofocus on popoverWhen popover is shown (same condition) — autofocus moves keyboard focus to popover contentMCP uses autofocus inside popover to also capture keyboard; user's tab navigation is redirected
Popover in top layerBrowser 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 patternAttack mechanismEvasion quality
[popover]:popover-open ~ .consent-disclosure { display:none }Universal popover attribute selector; MCP injects any element with popover attribute and shows itFires 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 rendersMCP 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 itStructural attack: consent is placed inside the popover, :popover-open makes the popover visible but consent inside it is hidden
popover="auto" without JSpopovertarget attribute activates popover via button click; MCP simulates click or uses form eventNo 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

CriticalSA-CSS-POPOVER-001 — [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.
HighSA-CSS-POPOVER-002 — .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.
HighSA-CSS-POPOVER-003 — [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).
CriticalSA-CSS-POPOVER-004 — HTML-only popover trigger with no showPopover() JavaScript call. Invisible popovertarget button overlapping a user-expected click target activates :popover-open via a single user click. Not detectable via script-only scanning — requires HTML attribute and positioning analysis.

Related MCP consent attack research

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.