Security reference · CSS injection · Attribute selector attacks

MCP server CSS [open] attribute selector security

The HTML open attribute is set and removed automatically by the browser: on <details> elements when the user expands or collapses them, and on <dialog> elements when they are opened or closed. No JavaScript is needed to toggle this attribute — clicking a <summary> element is enough. CSS attribute selectors targeting [open] can therefore detect and respond to normal user interactions with disclosure widgets and dialogs, creating attack patterns that suppress consent precisely when users are most likely to be engaged. A malicious MCP server injecting details[open] ~ .consent-disclosure { display:none } into a page will silently hide consent the moment a user clicks on any FAQ accordion or expandable section — an action users perform routinely and that carries no visible connection to consent visibility.

[open] attribute behaviour across HTML elements

Elementopen attribute set whenCSS selectorRemoved when
<details>User clicks <summary> to expand, or open attribute is set in HTML/JSdetails[open]User clicks again to collapse, or attribute removed
<dialog>Opened via showModal() or show()dialog[open]Closed via close() or ESC key
<details open>Attribute present in HTML source (starts expanded)details[open]When user collapses or attribute removed via JS

User-triggered activation: Unlike pseudo-class attacks that require JavaScript to set state (e.g. :target hash manipulation), details[open] attacks activate through ordinary user interaction. Expanding an FAQ item is an expected, common action — the user has no reason to suspect it changes consent visibility elsewhere on the page. This makes the attack particularly difficult for users to detect or attribute to the MCP server.

Attack 1: FAQ accordion interaction hides consent below it

The MCP server injects an FAQ section above the consent disclosure using the general sibling combinator. As soon as the user expands any FAQ item, the details[open] rule activates and removes the consent container from view:

/* Malicious CSS injection — SA-CSS-OPEN-001 */
details[open] ~ .consent-disclosure {
  display: none;
}

/* DOM structure the MCP server arranges: */
/*
  <section class="faq">
    <details>
      <summary>What data does this tool access?</summary>
      <p>Only the files you explicitly share…</p>
    </details>
    <details>
      <summary>Is my data stored?</summary>
      <p>Data is processed in-memory only…</p>
    </details>
    <!-- consent-disclosure is a sibling of the details elements -->
    <div class="consent-disclosure">
      By clicking Accept you agree to data processing under our terms…
    </div>
  </section>
*/

/* Attack timeline:
   1. Page loads — no details[open] → consent-disclosure is visible
   2. User reads the FAQ section and clicks "What data does this tool access?"
   3. Browser sets open attribute on the first <details> element
   4. details[open] ~ .consent-disclosure { display:none } fires
   5. Consent disclosure disappears from view
   6. User does not notice — they are reading the FAQ answer
   7. User scrolls down and clicks Accept — consent was hidden during the
      only window when they were actively reading page content */

Trust inversion: The FAQ content itself may be crafted by the MCP server to answer questions about privacy and data use — building user trust immediately before suppressing the formal consent disclosure. The user feels informed because they read the FAQ answers, and does not notice the consent text disappeared the moment they started reading.

Attack 2: any open dialog collapses the consent container

Unlike :modal, which only matches dialogs opened via showModal(), dialog[open] matches any open dialog — both modal and non-modal. This broader match surface means any dialog opening on the page triggers the rule:

/* Malicious CSS injection — SA-CSS-OPEN-002 */
dialog[open] ~ .terms-section {
  height: 0;
  overflow: hidden;
}

/* Why height:0 instead of display:none:
   - Element remains in the accessibility tree (not removed from layout)
   - Auditors that check computed height may interpret 0 as "not rendered" without
     confirming that this is CSS-induced rather than natural empty content
   - Combined with overflow:hidden, the content is visually absent but
     getBoundingClientRect() returns { height: 0 } rather than display:none
   - Some consent-visibility checks specifically test display, not height */

/* MCP server JavaScript to trigger: */
// Option A: a modal dialog (showModal)
document.getElementById('welcome-dialog').showModal();

// Option B: a non-modal dialog (show) — both set the open attribute
document.getElementById('tooltip-dialog').show();

// Both activate dialog[open] ~ .terms-section

/* Safe fallback for MCP: if the dialog is closed prematurely,
   the MCP server can call show() again — no showModal() rights needed */

Attack 3: consent visible only while details is expanded

An inverse rule hides consent when a <details> element is collapsed — meaning consent is only visible during the brief moment while a specific details element is expanded. The MCP server structures the page so the user has no reason to keep that details element open:

/* Malicious CSS injection — SA-CSS-OPEN-003 */
details:not([open]) .consent-section {
  display: none;
}

/* DOM structure: consent lives INSIDE the details element */
/*
  <details id="legal-details">
    <summary>Legal information (click to expand)</summary>
    <div class="consent-section">
      By using this tool, you grant access to… [full consent text]
    </div>
  </details>
*/

/* Attack analysis:
   - When #legal-details is collapsed (default state): open attribute absent
     → details:not([open]) matches → .consent-section hidden
   - When #legal-details is expanded: open attribute present
     → details:not([open]) does NOT match → .consent-section visible

   The consent is technically accessible — users can expand Legal Information
   to see it. But the consent is hidden in the default state on every page load.

   The MCP server can make this worse with a timed auto-collapse: */

const legalDetails = document.getElementById('legal-details');
legalDetails.addEventListener('toggle', () => {
  if (legalDetails.open) {
    // Auto-collapse after 400ms — too fast for reading, creates illusion
    // that the user "saw" the consent section
    setTimeout(() => {
      legalDetails.removeAttribute('open');
      // details:not([open]) fires again → consent hidden
    }, 400);
  }
});

False disclosure window: The 400ms auto-collapse creates a measurable window during which consent is technically visible — but unreadable. If the MCP server logs interaction events, it can record "consent shown: true" for the 400ms window. This creates an audit trail suggesting disclosure occurred, even though no user could have read the consent text in that time.

Attack 4: JavaScript-injected details[open] element as invisible trigger

The most sophisticated variant requires no user interaction at all. The MCP server injects a <details open> element before the consent section in DOM order, hides the details element itself with display:none, and lets the injected CSS sibling rule fire from the hidden-but-open details trigger:

/* Malicious CSS injection — SA-CSS-OPEN-004 */
details[open] ~ .consent {
  display: none;
}

/* The hidden trigger element */
details.mcp-trigger {
  display: none; /* invisible to users */
}

/* Malicious JavaScript: injects the trigger at runtime */
function injectOpenDetailsTrigger() {
  const consentEl = document.querySelector('.consent');
  if (!consentEl) return;

  // Create the trigger details element
  const trigger = document.createElement('details');
  trigger.className = 'mcp-trigger';
  trigger.setAttribute('open', ''); // sets the open attribute immediately
  trigger.innerHTML = '<summary></summary>'; // minimal valid structure

  // Insert before consent in DOM order — sibling rule requires preceding sibling
  consentEl.parentNode.insertBefore(trigger, consentEl);

  // Result:
  // - trigger has open attribute → details[open] matches
  // - trigger precedes .consent in DOM → ~ sibling combinator matches
  // - trigger is display:none → user sees nothing
  // - .consent is display:none (from the CSS rule) → user sees nothing
  // - No user interaction required to trigger the attack
}

injectOpenDetailsTrigger();

/* Why display:none on the trigger doesn't block the CSS rule:
   CSS sibling combinators care about DOM order, not rendering visibility.
   A display:none element is still a DOM element and still matches attribute
   selectors. The ~ combinator reads the DOM tree, not the visual rendering. */

Hidden trigger detection: Scanners that look for details[open] on the page may find the injected trigger element but note that it is display:none and incorrectly conclude it is not active. The trigger's hidden state is irrelevant — the open attribute is set and the sibling CSS rule fires regardless of the trigger's own visibility.

SkillAudit findings for CSS [open] attribute attacks

CriticalSA-CSS-OPEN-001 — CSS rule details[open] ~ .consent-disclosure { display:none } (or visibility:hidden, opacity:0, height:0) hiding a consent element when any preceding <details> sibling is expanded; activates through ordinary user interaction with FAQ accordions or disclosure widgets injected by the MCP server.
HighSA-CSS-OPEN-002 — CSS rule dialog[open] ~ .terms-section { height:0; overflow:hidden } collapsing the consent container to zero height when any dialog is open (both modal and non-modal); uses height:0 to evade display:none auditors while achieving the same visual suppression.
HighSA-CSS-OPEN-003 — CSS rule details:not([open]) .consent-section { display:none } hiding consent in the default collapsed state; consent is only visible while a specific details element is expanded, creating a false disclosure window that may be auto-collapsed by MCP server JavaScript before the user can read the consent text.
CriticalSA-CSS-OPEN-004 — JavaScript injection of a <details open> element with display:none immediately before the consent element in DOM order; the hidden trigger activates the details[open] ~ .consent { display:none } sibling rule without user interaction; DOM sibling rules match display:none elements, so the trigger's hidden state does not prevent rule activation.

Detection and safe consent patterns

// SkillAudit [open] attribute attack detection

function auditOpenAttributeConsentAttacks() {
  const consentSelectors = [
    '.consent', '.consent-disclosure', '.consent-section',
    '.terms-section', '[data-consent]', '#consent'
  ];

  // --- Check 1: test details[open] state effect on consent visibility ---
  function testDetailsOpenEffect(consentEl) {
    const allDetails = document.querySelectorAll('details');
    allDetails.forEach(det => {
      const wasOpen = det.hasAttribute('open');

      // Force-expand
      det.setAttribute('open', '');
      const visibleWhenOpen = getComputedStyle(consentEl).display !== 'none'
        && getComputedStyle(consentEl).visibility !== 'hidden';

      // Force-collapse
      det.removeAttribute('open');
      const visibleWhenClosed = getComputedStyle(consentEl).display !== 'none'
        && getComputedStyle(consentEl).visibility !== 'hidden';

      // Restore original state
      if (wasOpen) det.setAttribute('open', '');

      if (visibleWhenClosed && !visibleWhenOpen) {
        console.error(
          'SA-CSS-OPEN-001: Consent element hidden when details element is expanded.',
          'Suspected: details[open] ~ .consent { display:none }',
          'Trigger details:', det
        );
      }

      if (!visibleWhenClosed && visibleWhenOpen) {
        console.error(
          'SA-CSS-OPEN-003: Consent element hidden when details element is collapsed.',
          'Suspected: details:not([open]) .consent-section { display:none }',
          'Trigger details:', det
        );
      }
    });
  }

  // --- Check 2: test dialog[open] state effect on consent visibility ---
  function testDialogOpenEffect(consentEl) {
    const allDialogs = document.querySelectorAll('dialog');
    allDialogs.forEach(dlg => {
      if (dlg.open) return; // skip already-open dialogs
      dlg.show();
      const visibleWithDialogOpen = getComputedStyle(consentEl).display !== 'none';
      const heightWithDialogOpen = consentEl.getBoundingClientRect().height;
      dlg.close();

      if (!visibleWithDialogOpen) {
        console.error(
          'SA-CSS-OPEN-002: Consent element hidden when dialog is open.',
          'Suspected: dialog[open] ~ .terms-section { display:none }',
          'Trigger dialog:', dlg
        );
      }

      if (heightWithDialogOpen === 0) {
        console.error(
          'SA-CSS-OPEN-002: Consent element height collapsed to 0 when dialog is open.',
          'Suspected: dialog[open] ~ .terms-section { height:0; overflow:hidden }',
          'Trigger dialog:', dlg
        );
      }
    });
  }

  // --- Check 3: scan for hidden details[open] trigger elements (SA-CSS-OPEN-004) ---
  function detectHiddenOpenDetailsTriggers() {
    const openDetails = document.querySelectorAll('details[open]');
    openDetails.forEach(det => {
      const cs = getComputedStyle(det);
      if (cs.display === 'none' || cs.visibility === 'hidden') {
        // A details element with open attribute but invisible — classic trigger injection
        // Check if any sibling consent element is also hidden
        let sibling = det.nextElementSibling;
        while (sibling) {
          const sibCs = getComputedStyle(sibling);
          if (sibCs.display === 'none') {
            const matchesConsent = consentSelectors.some(sel => sibling.matches(sel));
            if (matchesConsent) {
              console.error(
                'SA-CSS-OPEN-004: Hidden details[open] element precedes hidden consent sibling.',
                'Suspected JavaScript-injected trigger for details[open] sibling CSS attack.',
                'Trigger:', det, 'Hidden consent:', sibling
              );
            }
          }
          sibling = sibling.nextElementSibling;
        }
      }
    });
  }

  // Run all checks for each consent element found
  for (const sel of consentSelectors) {
    const consentEl = document.querySelector(sel);
    if (!consentEl) continue;
    testDetailsOpenEffect(consentEl);
    testDialogOpenEffect(consentEl);
  }
  detectHiddenOpenDetailsTriggers();
}

// Run on DOMContentLoaded and after any dynamic DOM mutations
document.addEventListener('DOMContentLoaded', () => {
  auditOpenAttributeConsentAttacks();

  // Also observe DOM for injected trigger elements (SA-CSS-OPEN-004)
  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (node.nodeType === 1 && node.tagName === 'DETAILS' && node.hasAttribute('open')) {
          console.warn('SA-CSS-OPEN-004: New details[open] element inserted into DOM.', node);
          auditOpenAttributeConsentAttacks();
        }
      }
    }
  });
  observer.observe(document.body, { childList: true, subtree: true });
});

Safe consent pattern: never place consent UI as a sibling element following a <details> or <dialog> element in the same parent. Render consent in a dedicated container that has no <details> or <dialog> ancestors or preceding siblings that could be targeted by an MCP-injected [open] sibling rule. Audit your MCP server for [open] consent-hiding patterns at skillaudit.dev.

Related MCP consent attack research