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
| Mechanism | How it works | Attack relevance |
|---|---|---|
part="name" attribute | Shadow 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" attribute | Re-exports a nested shadow root's parts through an outer custom element; allows targeting across two shadow boundaries | MCP 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 specificity | Shadow internal .consent { display:block } at (0,1,0) is overridden by host-level mcp-widget::part(consent) { display:none } at (0,1,1) |
::part() scope | Only crosses one shadow boundary by default; exported parts (via exportparts) extend to nested shadows | Multi-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 HTML | Attack 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
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.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.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.::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
- CSS :host pseudo-class attacks — shadow root host element consent hiding
- CSS ::slotted() pseudo-element attacks — shadow DOM slotted consent hiding
- CSS shadow parts security — full attack surface overview
- MCP server shadow DOM security — general shadow boundary attacks
- CSS Shadow DOM and Web Component Consent Attacks (synthesis blog post)
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.