Security reference · CSS injection · Shadow DOM · ::part() attacks

MCP server CSS ::part() pseudo-element security

The CSS ::part() pseudo-element (CSS Shadow Parts spec) is the intentional shadow DOM style escape hatch. Elements inside a shadow root can expose themselves to host-page CSS by adding a part="" attribute. Once exposed, host-level CSS can target them using custom-element::part(name). MCP server attacks invert the purpose of this mechanism: MCP's shadow component intentionally exposes consent disclosures with part="consent" and then hides them using its own injected host-page ::part(consent) { display:none } rule — weaponizing the consent exposure against the user.

::part() mechanics — how shadow part exposure works

MechanismHow it worksAttack relevance
part="name" attributeShadow root element declares itself as a named part; accessible to host-page CSS via ::part(name)MCP exposes consent with part="consent", then immediately hides it from host CSS
exportparts="name" attributeRe-exports a nested shadow root's parts through an outer custom element; allows targeting across two shadow boundariesMCP chains nested components; outer element's exportparts surfaces inner consent parts for host hiding
::part() specificity(0,1,1) — same as a class + type selector; can override shadow-internal styles with lower specificityShadow internal .consent { display:block } at (0,1,0) is overridden by host-level mcp-widget::part(consent) { display:none } at (0,1,1)
::part() scopeOnly crosses one shadow boundary by default; exported parts (via exportparts) extend to nested shadowsMulti-level nesting with exportparts chains defeat per-level shadow encapsulation

The part exposure paradox: An element with part="consent" on a live MCP server looks compliant — the consent element is exposed and "accessible." What auditors miss is that the same MCP stylesheet that exposes the part immediately hides it via ::part(consent) { display:none }. The part attribute is evidence of deliberate exposure, not evidence of consent being shown.

CSS ::part() attack surface in MCP consent UIs

CSS pattern (host page)Shadow HTMLAttack quality
mcp-widget::part(consent) { display:none }<div part="consent">By installing...</div>Direct part name attack — MCP exposes and immediately hides its own consent disclosure via ::part()
mcp-widget::part(terms disclosure) { visibility:hidden }<p part="terms disclosure">...</p>Multi-name part — element has both "terms" and "disclosure" parts; ::part() with space-separated names hides either match
mcp-panel::part(consent-inner) { display:none }Nested shadow: outer has exportparts="consent:consent-inner"Transitive exportparts chain — consent part from inner shadow root re-exported through outer element; host CSS targets inner element
mcp-widget::part(consent) { display:none !important }Shadow internal: .consent-part { display:block }Specificity bypass — shadow internal rule at (0,1,0) overridden by ::part() at (0,1,1); !important ensures host wins

Attack 1: mcp-widget::part(consent) — intentional expose-then-hide

MCP's shadow component marks its consent disclosure with part="consent". This exposes the element to host-page CSS. MCP's own injected host-page stylesheet then targets it with mcp-widget::part(consent) { display:none }. The part attribute makes the exposure detectable — but the detection conclusion ("consent is exposed, therefore visible") is wrong. The critical check is whether any ::part(consent) rule exists in the host-page stylesheets:

/* Malicious host-page CSS — SA-CSS-PART-001 */
/* MCP's injected stylesheet hides the consent part it just exposed */
mcp-widget::part(consent) {
  display: none;
}

/* Equivalent hiding methods via ::part(): */
mcp-widget::part(consent) { visibility: hidden }
mcp-widget::part(consent) { opacity: 0; pointer-events: none }
mcp-widget::part(consent) { position: absolute; clip: rect(0,0,0,0) }

/* MCP's shadow component HTML (inside shadow root): */
/* <div part="consent" class="consent-section">                        */
/*   <p>By installing this MCP server, you grant access to:</p>       */
/*   <ul><li>Your workspace files</li><li>Tool execution rights</li></ul> */
/* </div>                                                               */
/* <button class="install-btn">Install MCP Server</button>             */

/* Why part="consent" is evidence of the attack, not evidence of compliance:
   - The part attribute was added by MCP to expose the consent element
   - The ::part() rule was added by MCP to then immediately hide it
   - Both the exposure and the hiding are under MCP's control
   - An auditor reading the shadow HTML sees part="consent" and concludes
     the consent is accessible — without checking host CSS for ::part() hiding

/* Detection: find all ::part() rules targeting consent-named parts */
function detectPartHideAttack() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        /* ::part() rules that target consent-related part names */
        const partMatch = sel.match(/::part\(([^)]+)\)/);
        if (partMatch) {
          const partNames = partMatch[1].split(/\s+/);
          const isConsentPart = partNames.some(p =>
            /consent|disclosure|terms|privacy|legal/i.test(p));
          if (isConsentPart && /display|visibility|opacity|clip|position/i.test(rule.cssText)) {
            findings.push({ id: 'SA-CSS-PART-001', severity: 'critical',
              message: `Host-page ::part() rule hiding consent part "${partMatch[1]}": ${rule.cssText}` });
          }
        }
      }
    } catch (_) {}
  }
  /* Also check shadow DOM elements for part="consent" */
  for (const el of document.querySelectorAll('*')) {
    const shadow = el.shadowRoot;
    if (!shadow) continue;
    for (const partEl of shadow.querySelectorAll('[part]')) {
      const parts = partEl.getAttribute('part').split(/\s+/);
      if (parts.some(p => /consent|terms|disclosure/i.test(p))) {
        findings.push({ id: 'SA-CSS-PART-001', severity: 'high',
          message: `Shadow element has part="${partEl.getAttribute('part')}" — check host-page CSS for ::part() hiding rules` });
      }
    }
  }
  return findings;
}

Standard shadow parts scan misses this: Auditors that check element.shadowRoot.querySelectorAll('[part]') and report "consent part is exposed" produce a false-negative. The exposure is the first half of the attack; the host-page ::part() hiding is the second half. Both must be checked together.

Attack 2: ::part(terms disclosure) — multi-name part targeting

CSS ::part() with multiple space-separated names matches shadow elements whose part attribute contains ALL of those names. Conversely, in the CSS ::part(terms disclosure) syntax, the space-separated list means the rule matches any element whose part attribute includes "terms" OR "disclosure" as separate values. This creates a broader attack surface: a single rule can hide elements with any one of several part names, while the shadow HTML uses multi-value part attributes to apply multiple semantic labels:

/* Malicious host-page CSS — SA-CSS-PART-002 */
/* ::part() with space-separated names: note — in CSS ::part(terms disclosure)
   actually means the element's part attribute list must contain BOTH "terms" AND "disclosure" */
mcp-widget::part(terms) { display: none }
mcp-widget::part(disclosure) { display: none }
/* Or target both simultaneously using two rules */
mcp-widget::part(terms),
mcp-widget::part(disclosure) { visibility: hidden }

/* Shadow HTML uses multi-value part attribute */
/* <section part="terms disclosure legal-notice"> */
/*   Terms, conditions and consent text here...   */
/* </section>                                     */

/* Why multi-name parts increase hiding coverage:
   - Each name in the part attribute is independently targetable
   - Even if an auditor discovers one ::part(terms) rule and removes it,
     the backup ::part(disclosure) rule remains
   - MCP can rotate part names without changing the shadow HTML structure
   - The multi-label part attribute looks like legitimate semantic markup

/* Advanced: attribute-selector inside :host + ::part() combination */
/* This requires the shadow root to expose parts AND the host CSS to target them */
mcp-widget:not([data-consent-verified])::part(terms) {
  display: none;
}
/* Combined selector: hides consent part UNLESS the host element has */
/* data-consent-verified attribute — MCP never sets this attribute */

/* Detection: find shadow elements with multi-name part attributes */
function detectMultiPartAttack() {
  const findings = [];
  for (const el of document.querySelectorAll('*')) {
    const shadow = el.shadowRoot;
    if (!shadow) continue;
    for (const partEl of shadow.querySelectorAll('[part]')) {
      const parts = partEl.getAttribute('part').split(/\s+/);
      /* Multi-name part with any consent-related name */
      const consentParts = parts.filter(p => /consent|terms|disclosure|privacy|legal/i.test(p));
      if (consentParts.length > 0) {
        /* Check host-page CSS for matching ::part() hide rules */
        const elementTag = el.tagName.toLowerCase();
        for (const sheet of document.styleSheets) {
          try {
            for (const rule of sheet.cssRules) {
              const sel = rule.selectorText || '';
              if (sel.startsWith(elementTag + '::part(')) {
                const rulePartName = sel.match(/::part\(([^)]+)\)/)?.[1];
                if (rulePartName && consentParts.some(p => p === rulePartName.trim())) {
                  findings.push({ id: 'SA-CSS-PART-002', severity: 'critical',
                    message: `Multi-name part attack: shadow ${el.tagName.toLowerCase()} exposes part="${partEl.getAttribute('part')}", host CSS hides part "${rulePartName}"` });
                }
              }
            }
          } catch (_) {}
        }
      }
    }
  }
  return findings;
}

Attack 3: exportparts transitive chain — hiding across nested shadow roots

The exportparts attribute forwards a shadow root's internal parts through a parent custom element, making them addressable by the host-page's ::part() rules. MCP uses nested custom elements — an outer <mcp-panel> wrapping an inner <mcp-consent> — with exportparts chains that surface the innermost consent element's part to the host page:

/* MCP's shadow component nesting — SA-CSS-PART-003 */

/* Outer element: mcp-panel (shadow root) */
/* Inner shadow HTML: */
/*   <mcp-consent exportparts="consent:inner-consent"></mcp-consent> */
/* The exportparts attribute re-exports mcp-consent's "consent" part   */
/* as "inner-consent" at the mcp-panel level                          */

/* Inner element: mcp-consent (shadow root) */
/* Shadow HTML: */
/*   <div part="consent">Consent disclosure text...</div>             */

/* Host page HTML: */
/* <mcp-panel exportparts="inner-consent:consent-disclosure"></mcp-panel> */
/* This re-exports "inner-consent" (already a re-export of "consent")  */
/* as "consent-disclosure" at the host page level                      */

/* Result: host-page CSS can target the innermost consent div:         */
mcp-panel::part(consent-disclosure) {
  display: none; /* SA-CSS-PART-003 */
}

/* The exportparts chain:
   Inner shadow div  part="consent"
         ↓  re-exported via mcp-consent's exportparts as:
   Outer shadow      inner-consent
         ↓  re-exported via mcp-panel's exportparts attribute as:
   Host page         consent-disclosure

   Each re-export step maintains the part's CSS targetability
   while adding a level of indirection that makes manual tracing harder.

/* Why exportparts chains defeat per-level audits:
   - Auditing mcp-panel's shadow root finds no consent element there
   - Auditing mcp-consent's shadow root finds the consent element
   - But the ::part() rule is on the HOST page, not in any shadow root
   - Tracing the exportparts chain requires reading three files:
     mcp-panel shadow template, mcp-consent shadow template, host CSS

/* Detection: trace exportparts chains */
function detectExportpartsChainAttack() {
  const findings = [];
  function walkExportparts(el, prefix = '') {
    const exportparts = el.getAttribute('exportparts');
    if (!exportparts) return;
    const mappings = exportparts.split(',').map(s => s.trim());
    for (const mapping of mappings) {
      const [inner, outer] = mapping.split(':').map(s => s.trim());
      const effectiveName = prefix ? `${prefix}-${outer || inner}` : (outer || inner);
      if (/consent|terms|disclosure|privacy/i.test(inner) ||
          /consent|terms|disclosure|privacy/i.test(effectiveName)) {
        findings.push({ id: 'SA-CSS-PART-003', severity: 'high',
          message: `exportparts chain exposes consent-related part "${inner}" as "${effectiveName}" on ${el.tagName.toLowerCase()}` });
      }
      /* Recurse into shadow DOM to find nested exportparts */
      const shadow = el.shadowRoot;
      if (shadow) {
        for (const child of shadow.querySelectorAll('[exportparts]')) {
          walkExportparts(child, effectiveName);
        }
      }
    }
  }
  for (const el of document.querySelectorAll('[exportparts]')) {
    walkExportparts(el);
  }
  return findings;
}

Attack 4: ::part() specificity bypass — overriding shadow-internal display:block

Shadow root CSS that sets .consent-part { display: block } has specificity (0,1,0). A host-page rule mcp-widget::part(consent) { display: none } has specificity (0,1,1) — one higher. Because ::part() specificity includes the custom element name as a type selector, the host-page rule silently wins the cascade against the shadow's own visibility rule. This makes the shadow component appear to protect its consent (there IS a display:block rule in the shadow), while the host-page rule overrides it:

/* Shadow root internal CSS — appears to protect consent */
/* Specificity: (0,1,0) — class selector */
.consent-part {
  display: block !important; /* Shadow author intends this to be final */
  visibility: visible !important;
}

/* Host-page malicious CSS — SA-CSS-PART-004 */
/* Specificity: (0,1,1) = custom-element-type(0,0,1) + ::part()(0,1,0) */
/* Overrides (0,1,0) shadow-internal class rule when !important not used */
mcp-widget::part(consent) {
  display: none; /* Wins over .consent-part { display:block } at (0,1,0) */
}

/* Specificity comparison: */
/* Shadow  .consent-part              → (0,1,0) */
/* Host    mcp-widget::part(consent)  → (0,1,1) — host wins */

/* To prevent override, the shadow internal rule needs !important: */
/* Shadow  .consent-part { display:block !important } → wins regardless of specificity */

/* MCP exploits this by intentionally NOT using !important in shadow styles.
   The shadow has a display:block rule — it looks protective.
   The host-page has a display:none ::part() rule — it wins silently.
   A code reviewer checking only the shadow CSS concludes consent is protected. */

/* Detection: check if ::part() rules targeting consent override shadow-internal rules */
async function detectPartSpecificityBypass() {
  const findings = [];
  for (const el of document.querySelectorAll('*')) {
    const shadow = el.shadowRoot;
    if (!shadow) continue;
    const elementTag = el.tagName.toLowerCase();
    /* Find shadow-internal consent display rules */
    const shadowConsentRules = [];
    for (const sheet of shadow.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (/consent|terms|disclosure/i.test(rule.selectorText || '') &&
              /display|visibility/i.test(rule.cssText)) {
            shadowConsentRules.push(rule);
          }
        }
      } catch (_) {}
    }
    if (shadowConsentRules.length === 0) continue;
    /* Check host-page for matching ::part() override */
    for (const sheet of document.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          const sel = rule.selectorText || '';
          if (sel.startsWith(`${elementTag}::part(`) &&
              /display|visibility|opacity/i.test(rule.cssText)) {
            findings.push({ id: 'SA-CSS-PART-004', severity: 'high',
              message: `::part() specificity bypass: ${elementTag} shadow has consent display rule, host-page ::part() overrides it — check cascade winner` });
          }
        }
      } catch (_) {}
    }
  }
  return findings;
}

::part() only crosses one shadow boundary by default: Without exportparts, ::part() can only target elements in the immediate shadow root of the targeted custom element. Nested shadow roots require explicit exportparts chains at each boundary (SA-CSS-PART-003). This design means deeply nested shadow components are safe from ::part() attacks unless their authors explicitly re-export parts up the chain.

SkillAudit findings for CSS ::part() consent attacks

CriticalSA-CSS-PART-001 — mcp-widget::part(consent) { display:none }. Intentional expose-then-hide attack. MCP exposes consent with part="consent" and hides it with host-page ::part(). The part attribute is evidence of deliberate exposure for the purpose of hiding. Auditors must check host-page stylesheets for ::part() rules alongside shadow DOM part attribute scans.
CriticalSA-CSS-PART-002 — mcp-widget::part(disclosure) { visibility:hidden }. Multi-name part targeting. Shadow element has multiple part names (e.g., part="terms disclosure legal-notice"); host-page CSS targets any one of them to hide the element. Each part name is an independent attack vector; removing one host-page rule leaves others effective.
HighSA-CSS-PART-003 — transitive exportparts chain hides deeply nested consent. Outer custom element re-exports inner shadow's consent part, allowing host-page ::part() to target two shadow boundaries deep. Requires tracing exportparts attribute chains across multiple component files — not caught by single-component audits.
HighSA-CSS-PART-004 — ::part() specificity (0,1,1) overrides shadow-internal display:block at (0,1,0). The shadow component appears to protect consent with an explicit display:block rule, but the host-page ::part() rule wins the cascade. The shadow rule creates a false sense of security; the host rule silently takes effect unless the shadow uses !important.

Related MCP consent attack research

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