Security reference · CSS injection · Selector attacks · Consent hiding

MCP server CSS :not() pseudo-class security

CSS :not() is a negation pseudo-class that matches every element that does not satisfy its argument selector. The attack pattern is the inverse of standard consent-hiding: instead of selecting the consent element directly and applying a destructive style, an MCP server selects everything except the install form and applies visibility: hidden, color: transparent, or height: 0. If the consent element lacks the class or attribute that the :not() argument requires for exemption, consent is caught by the destructive rule while the install form is protected. The install form appears normal; the consent disclosure is invisible.

:not() attack surface

Attack patternSelectorDestructive propertyHow consent is caught
Classless element targeting:not([class])visibility: hiddenConsent lacks a class attribute; install form elements have classes and are exempted
Container exclusion:not(.mcp-install) *display: noneAll elements not inside the install container are hidden; consent is outside the container
Focus-state inversion:not(:focus-visible) scoped to consentopacity: 0Consent is invisible unless actively focused by keyboard; users never focus it during install
Cascade overrideHigh-specificity :not() compoundAny hiding propertydiv:not([data-visible="true"]) overrides consent's own .consent { visibility: visible } rule due to higher specificity

Negation selector detection requires positive selector logic: An audit that scans for visibility: hidden on the consent element's own rules may miss a rule that targets consent via negation. The rule :not([class]) { visibility: hidden } does not appear in any selector that names the consent element — but its computed effect on the consent element is visibility: hidden. Detection must check the computed style of the consent element, not just scan selectors that explicitly target it.

Attack 1: :not([class]) { visibility: hidden } — classless element targeting

The simplest :not() attack targets elements without a class attribute. Install UI components (the input, button, and container) always have classes for styling. Consent disclosures, especially when injected by MCP JavaScript, are often plain <p> or <span> elements without an explicit class. The rule :not([class]) { visibility: hidden } is destructive to those elements while leaving the styled install UI intact:

/* Malicious CSS — SA-CSS-NOT-001 */
/* This rule appears to be a "reset" for unstyled elements */
:not([class]) {
  visibility: hidden;  /* hides all elements without a class attribute */
}

/* Install form elements: have classes, survive the :not([class]) rule */
.mcp-install-input  { visibility: visible; /* explicit rule preserves it */ }
.mcp-install-button { visibility: visible; }

/* Consent disclosure: no class → caught by :not([class]) → hidden */
/* 

By installing this MCP server, you agree to...

*/ /* This p element has no class → visibility:hidden */ /* Detection: scan all consent-matching elements for computed visibility/opacity/display */ function detectClasslessHiding() { const findings = []; const CONSENT = /consent|disclosure|terms|privacy/i; for (const el of document.querySelectorAll('*')) { if (!CONSENT.test(el.textContent || '')) continue; const s = getComputedStyle(el); /* Check computed values — regardless of which selector caused them */ if (s.visibility === 'hidden' || s.display === 'none' || parseFloat(s.opacity) < 0.1) { const rect = el.getBoundingClientRect(); findings.push({ id: 'SA-CSS-NOT-001', severity: 'critical', message: `Consent element has computed visibility:${s.visibility}, display:${s.display}, opacity:${s.opacity}. Element has ${el.className ? `class="${el.className}"` : 'no class'}. Check if :not([class]) or :not(.install-form) rule is responsible.`, element: el.tagName + (el.id ? '#'+el.id : '') + (el.className ? '.'+el.className : '[no class]') }); } } return findings; }

Attack 2: :not(.mcp-install) * — container exclusion

A more surgical variant uses a descendant combinator with :not(): :not(.mcp-install) * selects all elements that are descendants of any element that is not .mcp-install. This means every element whose ancestor chain does not include the install container is hidden. The install UI elements are inside .mcp-install (exempted); the consent element, rendered outside the install container (often appended to the document body or a different parent), is caught:

/* Malicious CSS — SA-CSS-NOT-002 */
/* Appears to scope all content inside the install UI — actually hides everything outside it */
:not(.mcp-install) > * {
  display: none;  /* hide all direct children of non-install elements */
}

/* More precise: */
body > :not(.mcp-install) {
  display: none;  /* hide all direct children of body except .mcp-install */
}

/* Install UI: .mcp-install > [children] → children of .mcp-install are exempt */
/* Consent: appended to body directly → body > p.consent → caught by body > :not(.mcp-install) rule */

/* Real-world pattern: MCP JS appends consent to body: */
/* const p = document.createElement('p'); p.textContent = 'Terms: ...'; document.body.appendChild(p); */
/* → body > p → caught by :not(.mcp-install) rule */

/* Detection: check if consent element's direct parent is the excluded container */
function detectContainerExclusion() {
  const findings = [];
  for (const el of document.querySelectorAll('*')) {
    if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
    const s = getComputedStyle(el);
    if (s.display !== 'none') continue;
    /* Check for :not(.install) parent pattern by inspecting CSSOM rules */
    let foundNegationRule = false;
    for (const sheet of document.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (!rule.selectorText) continue;
          if (/:\s*not\s*\(/.test(rule.selectorText) && rule.style.display === 'none') {
            foundNegationRule = true;
          }
        }
      } catch {}
    }
    findings.push({ id: 'SA-CSS-NOT-002', severity: 'critical',
      message: `Consent element has computed display:none. ${foundNegationRule ? 'Negation :not() rule with display:none found in stylesheet — likely source.' : 'No negation rule found in accessible stylesheets (may be in shadow DOM or injected style).'}` });
  }
  return findings;
}

Attack 3: :not(:focus-visible) opacity collapse — focus-gate hiding

A more subtle attack uses a state-based negation: .mcp-consent-disclosure:not(:focus-visible) { opacity: 0 }. The consent element is invisible (opacity: 0) in all states except when the element itself has :focus-visible. Since users never Tab-focus a consent paragraph during a normal install flow, consent is permanently invisible. The rule looks like an "accessibility enhancement" (visible only when focused), but its practical effect is consent suppression in all realistic user interactions:

/* Malicious CSS — SA-CSS-NOT-003 */
/* Appears to enhance keyboard accessibility — actually hides consent */
.mcp-consent-disclosure:not(:focus-visible) {
  opacity: 0;
  /* consent is invisible unless the consent element itself receives :focus-visible */
  /* users click the install button, not the consent element → consent never focused */
}

/* More aggressive: make it non-focusable so it can never satisfy :focus-visible */
.mcp-consent-disclosure {
  pointer-events: none;  /* cannot be clicked */
  user-select: none;     /* cannot be selected */
  /* Without tabindex or interactive content, it cannot receive focus at all */
  /* :focus-visible is never satisfied → opacity:0 always applies */
}

/* Detection: check opacity + whether element is focusable */
function detectFocusGateOpacity() {
  const findings = [];
  for (const el of document.querySelectorAll('*')) {
    if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
    const s = getComputedStyle(el);
    if (parseFloat(s.opacity) > 0.1) continue;
    /* Is the element practically focusable? */
    const tabindex = el.getAttribute('tabindex');
    const isFocusable = tabindex !== null && parseInt(tabindex) >= 0;
    const rect = el.getBoundingClientRect();
    findings.push({ id: 'SA-CSS-NOT-003', severity: 'critical',
      message: `Consent element opacity:${s.opacity} (effectively invisible). Focusable: ${isFocusable} (tabindex=${tabindex}). If hidden via :not(:focus-visible), element must be focusable for consent to ever appear. pointer-events:${s.pointerEvents}. Rect: w=${Math.round(rect.width)},h=${Math.round(rect.height)}.` });
  }
  return findings;
}

Attack 4: High-specificity :not() compound selector cascade override

In CSS Selectors Level 4, :not() accepts complex selectors (selector lists), and its specificity is determined by the most specific argument. div:not([data-visible="true"]) has specificity [0,1,1] — higher than a simple class selector [0,1,0]. If the consent element is a <div> without data-visible="true", a rule targeting it with a high-specificity :not() overrides the consent element's own positive style rule:

/* Malicious CSS — SA-CSS-NOT-004 */
/* High-specificity :not() overrides .mcp-consent { visibility: visible } */
div:not([data-visible="true"]) {
  visibility: hidden;  /* specificity [0,1,1] — higher than .class [0,1,0] */
}

/* Consent stylesheet rule: */
.mcp-consent-disclosure {
  visibility: visible;  /* specificity [0,1,0] — loses to div:not([data-visible]) */
}

/* Consent element: 
   → is a div AND does not have data-visible="true"
   → caught by div:not([data-visible="true"]) → visibility:hidden
   → its own .mcp-consent-disclosure { visibility:visible } rule LOSES the cascade battle */

/* Level 4 :not() with selector list — even higher specificity: */
div:not(.install-form, .install-button, .install-input) {
  color: transparent;  /* specificity = max of [.install-form] = [0,1,0] on the :not() argument */
                       /* total: div [0,0,1] + :not(.install-form) [0,1,0] = [0,1,1] */
}

/* Detection: check if consent element's computed visibility contradicts its own stylesheet rules */
function detectCascadeOverride() {
  const findings = [];
  for (const el of document.querySelectorAll('[class*="consent"],[class*="disclosure"],[class*="terms"]')) {
    const s = getComputedStyle(el);
    if (s.visibility === 'hidden' || s.display === 'none' || parseFloat(s.opacity) < 0.1
        || s.color === 'transparent' || s.color === 'rgba(0, 0, 0, 0)') {
      /* Element appears to have explicit consent class — check if computed style contradicts it */
      findings.push({ id: 'SA-CSS-NOT-004', severity: 'critical',
        message: `Consent-classed element computed: visibility=${s.visibility}, display=${s.display}, opacity=${s.opacity}, color=${s.color}. Despite having consent class, element is hidden — possible :not() high-specificity cascade override. Check for :not() rules in MCP stylesheet targeting element type without data-visible or explicit allowlist attributes.` });
    }
  }
  return findings;
}

Detection must use computed style, not selector scanning: A CSS rule containing :not() does not mention the consent element by name. Scanning the stylesheet text for "consent" or "disclosure" will not find a :not([class]) { visibility: hidden } rule. SkillAudit checks the computed style of every element matching consent patterns — if the computed value is destructive, it flags the finding regardless of which rule caused it, then traces back to the responsible CSSOM rule for the report.

SkillAudit findings for CSS :not() consent attacks

CriticalSA-CSS-NOT-001 — Consent element computed visibility: hidden, display: none, or opacity < 0.1; element lacks a class attribute. A :not([class]) destructive rule in the MCP stylesheet is the likely source — install form elements with classes are exempt while classless consent disclosures are caught.
CriticalSA-CSS-NOT-002 — Consent element computed display: none; a :not(.install-container) descendant rule found in accessible stylesheets. Consent is rendered outside the install container and is caught by the container-exclusion negation rule.
CriticalSA-CSS-NOT-003 — Consent element computed opacity: 0 and element is not focusable (tabindex missing or negative, no interactive content). Focus-gate pattern: :not(:focus-visible) hides consent permanently because the element cannot receive focus during a normal install interaction.
CriticalSA-CSS-NOT-004 — Consent-classed element has a destructive computed style despite the presence of an explicit consent class with a positive visibility rule. High-specificity :not() compound selector in MCP stylesheet overrides the consent element's own positive styles via cascade specificity.

Related MCP consent attack research

Audit your MCP server for :not() negation selector consent hiding: paste your GitHub URL at skillaudit.dev for a free report including SA-CSS-NOT findings. SkillAudit checks computed style — not just selector text — to catch negation-based indirect hiding.