Security reference · CSS injection · Pseudo-class attacks · Custom element registration

MCP server CSS :defined pseudo-class security

The CSS :defined pseudo-class (CSS Selectors 4) matches any element that has been defined — either a standard built-in HTML element (which is defined by the browser from page load) or a custom element that has been registered via customElements.define(). For MCP server consent attacks, :defined creates two distinct attack windows: a breadth attack using standard HTML elements (all of which are :defined immediately, making the rule fire from page load with zero injected HTML) and a timing attack using MCP's own custom elements (which become :defined at the precise moment MCP's registration script runs, letting MCP control when consent hides).

:defined scope — what is and isn't :defined

Element type:defined at page load?When does :defined change?
All standard HTML elements (div, p, section, input, etc.)Yes — defined by the browser spec from initial renderNever changes; always :defined
Registered custom elements (my-widget after customElements.define('my-widget', ...))Only if registration runs before renderBecomes :defined when customElements.define() is called
Unknown/unregistered custom elements (unknown-tag with no registration)No — browser treats as HTMLUnknownElement, never :definedRemains :not(:defined) until registered; if never registered, never :defined
Autonomous custom elements (valid name, not yet registered)No — valid custom element names that are not yet registered are not :definedBecomes :defined when registration runs

Key insight: Because all standard HTML elements are :defined from page load, a rule like div:defined ~ .consent { display:none } fires immediately at render with zero injected HTML. The attacker needs only to ensure a div exists before the consent disclosure in the DOM — which is almost universally true on any real page.

CSS :defined attack surface in MCP consent UIs

CSS patternWhy it firesEvasion quality
div:defined ~ .consent-disclosure { display:none }Every div on the page is :defined from load; fires immediately at renderZero injected HTML required; fires as soon as a div precedes consent in the DOM
mcp-widget:defined ~ .consent { display:none }Fires when MCP registers its custom element via customElements.define()Timing-controlled by MCP; consent hides at the moment MCP's script registers its element
:defined.mcp-component ~ .consent-section { display:none }Compound: element must be both :defined AND have the .mcp-component classTwo conditions both under MCP control; class applied at same moment as element registration
*:not(:defined) ~ .consent { display:none }:not(:defined) fires on unregistered custom element tags; MCP injects <unknown-mcp-el> before consentAbsence-condition inversion; the injected element is :not(:defined) by design — zero registration needed

Attack 1: div:defined — standard element breadth attack at page load

Because all standard HTML elements are :defined from the moment the browser renders them, a rule targeting div:defined fires immediately at page load on any page that contains a div before the consent disclosure — which is effectively all pages. No injected HTML is required. The MCP server need only include this CSS rule in its injected stylesheet:

/* Malicious CSS — SA-CSS-DEF-001 */
/* div:defined fires from page load — every div is always :defined */
div:defined ~ .consent-disclosure {
  display: none;
}

/* Why this works without injected HTML:
   - The general sibling combinator (~) matches ANY div that precedes .consent-disclosure
   - Any real install page has multiple divs before the consent section
   - The browser renders divs as :defined immediately (no customElements.define() needed)
   - Static auditor checks visibility at load → consent is ALREADY hidden → SA-CSS-DEF-001 fires

/* Other standard HTML variants that are equally effective: */
p:defined ~ .consent-disclosure { display:none }     /* p elements are all :defined from load */
section:defined ~ .consent-disclosure { display:none }  /* section elements are all :defined */
header:defined ~ .consent-disclosure { display:none }   /* header elements are all :defined */

/* Detection: :defined on standard elements should never be used in sibling selectors
   targeting consent-related class names. Standard elements being :defined is a
   browser invariant — the meaningful check is the SIBLING SELECTOR targeting consent. */
function detectDefinedBreadthAttack() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        // Standard tag + :defined targeting consent via sibling combinator
        if (/^(div|p|span|section|article|header|main|footer):defined\s*~/.test(sel) &&
            /consent|disclosure|terms|privacy/i.test(sel)) {
          findings.push({ id: 'SA-CSS-DEF-001', severity: 'high',
            message: `Standard element :defined sibling rule hiding consent: "${sel}"` });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Zero HTML required: SA-CSS-DEF-001 is one of the simplest CSS consent-hiding attacks — the only injected artifact is a CSS rule. No HTML injection, no JavaScript, no custom element registration. The attack relies entirely on a browser invariant (standard elements are always :defined) that auditors rarely check for in selector patterns.

Attack 2: mcp-widget:defined — custom element registration timing attack

When MCP injects a custom element (<mcp-widget></mcp-widget>) and then registers it via customElements.define('mcp-widget', MCP_Widget), the element transitions from :not(:defined) to :defined at the exact moment the define() call executes. MCP can time this registration to occur after any initial consent visibility checks — for example, in a setTimeout or after a user interaction event — making the consent hiding happen asynchronously:

/* Malicious CSS — SA-CSS-DEF-002 */
/* mcp-widget:defined fires when MCP calls customElements.define('mcp-widget', ...) */
mcp-widget:defined ~ .consent {
  display: none;
}

/* Injected HTML — MCP places this before the consent disclosure */
<mcp-widget id="mcp-core"></mcp-widget>
<div class="consent">By installing, you grant access to your workspace...</div>

/* Malicious JS — timed registration hides consent after auditor has already checked */
// At page load: mcp-widget is :not(:defined) → consent is visible → auditor passes
// After 500ms: customElements.define() runs → mcp-widget becomes :defined → consent hides
setTimeout(() => {
  customElements.define('mcp-widget', class extends HTMLElement {
    connectedCallback() { /* MCP functionality */ }
  });
}, 500);

/* Timeline:
   t=0ms:   Page renders. mcp-widget is :not(:defined). Consent visible.
   t=0ms:   Auditor checks consent visibility → PASSES (consent visible)
   t=500ms: customElements.define() runs. mcp-widget becomes :defined.
   t=500ms: CSS rule fires. .consent hidden. User now sees the install form.
   t=500ms: User fills form without seeing consent — MCP timing controlled. */

/* Detection: scan for custom element name :defined rules in stylesheets */
async function detectCustomElementDefinedAttack() {
  const findings = [];
  const customElementPattern = /[a-z]+-[a-z][a-z0-9-]*:defined/i;
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        if (customElementPattern.test(sel) &&
            /consent|disclosure|terms|privacy/i.test(sel)) {
          findings.push({ id: 'SA-CSS-DEF-002', severity: 'critical',
            message: `Custom element :defined timing attack detected in selector: "${sel}"` });
        }
      }
    } catch (_) {}
  }
  // Also check for deferred customElements.define() calls
  const scripts = [...document.querySelectorAll('script')].map(s => s.textContent);
  const hasTimedRegistration = scripts.some(s =>
    /setTimeout|requestAnimationFrame|addEventListener/.test(s) &&
    /customElements\.define/.test(s)
  );
  if (hasTimedRegistration) {
    findings.push({ id: 'SA-CSS-DEF-002', severity: 'high',
      message: 'Deferred customElements.define() call detected — potential :defined timing attack; recheck consent visibility after all registrations complete' });
  }
  return findings;
}

Attack 3: :defined.mcp-component — compound selector with class control

The compound selector :defined.mcp-component requires two conditions to both be true: the element must be :defined and it must have the class .mcp-component applied. Since MCP controls both conditions (it registers the custom element and applies the class), both conditions become true simultaneously. The compound selector adds specificity (0-1-1 vs 0-0-1 for plain :defined) and is harder for automated selectors to recognize as malicious:

/* Malicious CSS — SA-CSS-DEF-003 */
/* Compound :defined + class — both conditions controlled by MCP */
:defined.mcp-component ~ .consent-section {
  display: none;
}

/* Injected HTML — MCP assigns .mcp-component class to its custom element */
<mcp-core class="mcp-component"></mcp-core>

/* JS — registers element AND applies class simultaneously */
customElements.define('mcp-core', class extends HTMLElement {
  connectedCallback() {
    this.classList.add('mcp-component');
    // Both :defined (element now registered) and .mcp-component (class just added)
    // The compound selector :defined.mcp-component is now true on this element
    // All .consent-section siblings are now hidden
  }
});

/* Why the compound selector is harder to detect:
   - ":defined.mcp-component" doesn't look like a consent-hiding attack
   - The class name ".mcp-component" is a legitimate-looking component class
   - The compound selector has specificity 0-1-1, overriding most consent-show rules
   - A rule scanner checking for ":defined" alone may miss compound forms */

/* Detection: check for any :defined compound selector targeting consent siblings */
function detectCompoundDefinedAttack() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        if (/:defined[.\[#]/.test(sel) || /[.\[#[a-z]]:defined/.test(sel)) {
          if (/consent|disclosure|terms|privacy/i.test(sel) ||
              /~\s*(\.consent|\.terms|\.disclosure)/.test(sel)) {
            findings.push({ id: 'SA-CSS-DEF-003', severity: 'high',
              message: `Compound :defined selector targeting consent: "${sel}"` });
          }
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 4: *:not(:defined) — absence-condition inversion with unknown elements

The :not(:defined) inversion selects elements that are not defined — specifically, unknown custom element tags that have not been registered. MCP injects an unknown tag (<unknown-mcp-el></unknown-mcp-el>) into the page before the consent disclosure. Because no customElements.define() ever registers unknown-mcp-el, it remains permanently :not(:defined). The CSS rule *:not(:defined) ~ .consent { display:none } fires from page load and never resolves:

/* Malicious CSS — SA-CSS-DEF-004 */
/* :not(:defined) fires on any unregistered custom element tag */
*:not(:defined) ~ .consent {
  display: none;
}

/* Injected HTML — MCP places an unknown custom tag before consent */
<unknown-mcp-el></unknown-mcp-el>
<div class="consent">Terms and permissions disclosure...</div>

/* Why this is an absence-condition attack:
   - "unknown-mcp-el" is never registered via customElements.define()
   - Any unregistered element with a hyphenated name is permanently :not(:defined)
   - No JavaScript needed — the CSS rule fires from page load based on element presence
   - The element name "unknown-mcp-el" looks like a web component; it blends in
   - This is the inverse of SA-CSS-DEF-002: instead of waiting for :defined to become true,
     the attack relies on :not(:defined) being permanently true for unregistered elements

/* Detection: check for :not(:defined) in sibling selectors targeting consent */
function detectNotDefinedAbsenceAttack() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        if (/:not\(:defined\)/.test(sel) &&
            /consent|disclosure|terms|privacy/i.test(sel)) {
          findings.push({ id: 'SA-CSS-DEF-004', severity: 'critical',
            message: `Absence-condition :not(:defined) attack targeting consent: "${sel}"` });
        }
      }
    } catch (_) {}
  }
  // Also check for unknown custom elements in DOM that have no registration
  for (const el of document.querySelectorAll('*')) {
    const tag = el.tagName.toLowerCase();
    if (tag.includes('-') && !customElements.get(tag)) {
      const nextSibling = el.nextElementSibling;
      if (nextSibling?.matches('[class*="consent"], .terms-section, [class*="disclosure"]')) {
        findings.push({ id: 'SA-CSS-DEF-004', severity: 'high',
          message: `Unregistered custom element <${tag}> directly precedes consent element — :not(:defined) attack candidate` });
      }
    }
  }
  return findings;
}

:defined vs. :not(:defined) — opposite conditions, same attack goal: SA-CSS-DEF-002 waits for registration to fire (:defined becomes true when customElements.define() runs). SA-CSS-DEF-004 relies on permanent non-registration (:not(:defined) remains true because no customElements.define() ever runs). SkillAudit's detection must cover both directions: checking for :defined timing attacks and for :not(:defined) absence-condition attacks.

SkillAudit findings for CSS :defined consent attacks

HighSA-CSS-DEF-001 — div:defined ~ .consent-disclosure { display:none }. Standard HTML elements are :defined from page load — fires immediately with zero injected HTML. The only injected artifact is a CSS rule. All real install pages have divs before consent disclosures.
CriticalSA-CSS-DEF-002 — mcp-widget:defined ~ .consent { display:none }. Custom element :defined timing attack — MCP controls when customElements.define() runs. A deferred registration (setTimeout/rAF) hides consent after page-load audits have already passed. Requires post-registration consent recheck.
HighSA-CSS-DEF-003 — :defined.mcp-component ~ .consent-section { display:none }. Compound :defined + class selector with 0-1-1 specificity. Both conditions controlled by MCP — the class is applied in the same connectedCallback() that fires when the element is registered. Harder for automated scanners to identify as malicious.
CriticalSA-CSS-DEF-004 — *:not(:defined) ~ .consent { display:none }. Absence-condition inversion — an unregistered custom element tag is injected before consent. It is permanently :not(:defined), so the rule fires from page load without ever requiring a customElements.define() call. No JavaScript needed.

Related MCP consent attack research

Audit your MCP server for :defined consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-DEF findings.