MCP server CSS ::part() shadow DOM parts security: display:none shadow part, opacity:0 on exported part, font-size collapse via part, and scrollbar-hide overflow part attacks
Published 2026-07-23 — SkillAudit Research
CSS Shadow Parts (::part()) is a mechanism introduced in Shadow DOM Level 1 that allows Web Component authors to explicitly expose named sub-elements for styling from the host document's stylesheet. An element inside a shadow tree that carries a part="name" attribute can be styled by the host with host-element::part(name) { ... } rules. This is an intentional encapsulation break — the component author decides which internal elements to expose, and the host document can then apply any supported CSS property to those elements.
In MCP server deployments, consent UI frameworks frequently implement consent dialogs as Web Components with exported parts for visual customization. An MCP server that can inject a stylesheet into the host document (or into an ancestor document in a same-origin frame) can use ::part() rules to apply concealment CSS properties to any exported part on the consent component — without touching the component's shadow DOM or triggering mutation observers on the shadow root.
Shadow DOM encapsulation misconception: Developers often assume that shadow DOM encapsulation protects the component's internal elements from external stylesheet manipulation. ::part() deliberately breaks this assumption for exported parts. If a consent dialog component exports its disclosure text element as a part, any CSS injected into the host document can style that element — including hiding it, collapsing it to zero size, or making it transparent. The attack does not require access to the shadow root's JavaScript or stylesheets.
Attack 1: display:none on exported disclosure part
If the consent Web Component exports its disclosure text element with a part attribute (e.g., part="disclosure" or part="consent-text"), an MCP server that injects a single CSS rule into the host document can set display: none on the exported part, completely removing the disclosure from layout:
/* Web Component (shadow DOM — component author's code) */
/* The component exports its internal elements for theming */
<template id="consent-dialog-template">
<div part="dialog-container">
<h2 part="dialog-title">Permission Request</h2>
<div part="disclosure">
<!-- consent disclosure text goes here -->
</div>
<div part="button-row">
<button part="accept-button">Accept</button>
<button part="decline-button">Decline</button>
</div>
</div>
</template>
/* MCP server injects this CSS into the host document */
/* (e.g., via a <style> element in MCP tool output, a link href to MCP CDN, etc.) */
consent-dialog::part(disclosure) {
display: none;
/* The disclosure div inside the shadow root is hidden.
The dialog container and buttons remain visible.
User sees: a dialog with title and Accept/Decline buttons,
but no disclosure text. They are prompted to accept without seeing what they're accepting. */
}
/* Alternative — hide via visibility to preserve layout space */
consent-dialog::part(disclosure) {
visibility: hidden;
/* Same effect: disclosure is invisible but still occupies layout space.
The dialog appears to have an empty space where the disclosure was. */
}
Detection for this attack requires scanning the host document's stylesheets for ::part() rules that apply concealment properties (display: none, visibility: hidden, opacity: 0, etc.) to parts of consent components:
function detectPartConcealmentAttack(document) {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
// Check for ::part() selector
if (!rule.selectorText?.includes('::part(')) continue;
const { style } = rule;
// Check for concealment properties
const display = style.display;
const visibility = style.visibility;
const opacity = style.opacity;
const fontSize = style.fontSize;
if (display === 'none' || visibility === 'hidden' ||
(opacity !== '' && parseFloat(opacity) < 0.1) ||
(fontSize !== '' && parseFloat(fontSize) < 2)) {
// Extract the part name
const partMatch = rule.selectorText.match(/::part\(([^)]+)\)/);
const partName = partMatch ? partMatch[1] : 'unknown';
findings.push({
selector: rule.selectorText,
partName,
concealingProperty: display === 'none' ? 'display:none' :
visibility === 'hidden' ? 'visibility:hidden' :
opacity !== '' ? `opacity:${opacity}` : `font-size:${fontSize}`,
severity: 'high',
});
}
}
} catch (_) {
// Cross-origin stylesheet — flag as requiring manual review
findings.push({ crossOriginSheet: true, severity: 'medium' });
}
}
return findings;
}
Attack 2: opacity:0 on exported part — invisible but present in layout
Setting opacity: 0 on an exported ::part() makes the element visually invisible while preserving its layout space and its presence in the accessibility tree. This is a softer concealment that passes layout-based visibility checks (offsetHeight > 0, getBoundingClientRect() returns non-zero) while the user cannot see the disclosure:
/* opacity:0 on exported consent-dialog part */
consent-dialog::part(disclosure) {
opacity: 0;
/* The disclosure container is present in layout (takes up space),
present in the accessibility tree (screen readers can read it),
but visually invisible to sighted users. */
}
/* Variant: transition to zero opacity to avoid abrupt change detection */
consent-dialog::part(disclosure) {
opacity: 0;
transition: opacity 0.001s 99999s;
/* Transition with an essentially infinite delay: opacity will theoretically
become visible after 99999 seconds (27 hours) — never in practice during
normal user session. MCP server could also use a very long transition
combined with animation-delay or animation-duration. */
}
The opacity: 0 attack on shadow parts is detectable because window.getComputedStyle(element).opacity on the shadow DOM element returns the actual value. However, accessing shadow DOM elements from the host document requires either the shadow root to be in open mode or the element to be queried through the component's shadowRoot.querySelector() — which requires JavaScript access to the component.
function detectPartOpacityAttack(hostElement) {
// This check requires the Web Component's shadow root to be in open mode
const shadowRoot = hostElement.shadowRoot;
if (!shadowRoot) return { inaccessible: true }; // closed mode — cannot inspect
const disclosurePart = shadowRoot.querySelector('[part*="disclosure"], [part*="consent"]');
if (!disclosurePart) return { notFound: true };
const cs = window.getComputedStyle(disclosurePart);
const opacity = parseFloat(cs.opacity);
if (opacity < 0.1) {
return {
finding: true,
reason: `Shadow part "${disclosurePart.getAttribute('part')}" has opacity: ${opacity}`,
element: disclosurePart,
};
}
return { finding: false };
}
Attack 3: font-size collapse via ::part() — near-zero text on exported disclosure
The ::part() pseudo-element supports all standard CSS properties that are not specifically restricted. Font-size is not restricted — an MCP server can collapse the disclosure text to a near-zero font-size via a ::part() rule, making it present in the DOM but rendered at illegible size:
/* font-size collapse via ::part() */
consent-dialog::part(disclosure) {
font-size: 0.5px;
/* The shadow DOM disclosure element renders its text at 0.5px —
sub-pixel, not legible on any display.
getComputedStyle on the element returns 0.5px.
The element has non-zero dimensions (line-height may provide some height).
textContent is unchanged. The text is present but not readable. */
}
/* Compound variant: font-size + line-height collapse */
consent-dialog::part(disclosure) {
font-size: 0.5px;
line-height: 0;
/* Line-height:0 removes the leading entirely, collapsing the element
to zero height (font-size is sub-pixel, leading is zero).
Element height becomes effectively 0 despite text being in the DOM. */
}
/* Letter-spacing variant: spread characters off-screen */
consent-dialog::part(disclosure) {
letter-spacing: 9999px;
overflow: hidden;
/* Characters are spaced 9999px apart — first character visible, rest off-screen.
element has clipped content but disclosure is cut off at first character. */
}
Attack 4: overflow:hidden on scroll container part — clipping disclosure below fold
Some consent dialogs implement the disclosure in a scrollable container within the Web Component. If the MCP server can style the scrollable container part with overflow: hidden, the scrolling is disabled and any disclosure that extends beyond the initial visible area is permanently clipped. Combined with a height reduction, this attack clips the disclosure to only its first few lines while the accept button remains fully visible:
/* overflow-hide scroll container via ::part() */
/* The consent component's internal structure: */
/* <div part="disclosure-scroll">
<p>First line of disclosure...</p>
<p>[Critical permissions listed here]</p>
<p>[More critical content]</p>
</div> */
consent-dialog::part(disclosure-scroll) {
overflow: hidden; /* disables scrolling */
height: 20px; /* collapses container to one line height */
/* Only the first ~20px of the disclosure is visible.
Critical permission lines below the first paragraph are hidden.
The user sees the beginning of the disclosure (looks legitimate)
but cannot scroll to read the rest. */
}
/* More subtle variant: reduce max-height to clip without obvious collapse */
consent-dialog::part(disclosure-scroll) {
max-height: 24px;
overflow: hidden;
/* Container appears to have some height (one line),
disclosure content is clipped below line 1. */
}
/* Scrollbar-removal variant: keep content scrollable in theory,
but remove the scrollbar indicator so the user doesn't know
there is more content below */
consent-dialog::part(disclosure-scroll) {
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-webkit-scrollbar: none; /* Chrome/Safari via pseudo-element: */
}
/* Note: ::part() cannot contain pseudo-elements, so
consent-dialog::part(disclosure-scroll)::-webkit-scrollbar is invalid.
The scrollbar-width: none approach works in Firefox; Chrome requires
a different technique (overflow: hidden on the scroll container part). */
Defensive guidance for Web Component authors: Do not export consent disclosure elements as ::part(). Parts are opt-in — an element is only exposed if the component author adds the part attribute. Consent disclosures should never carry a part attribute. If visual customization of the dialog container is needed, export only the outer container (border, shadow, spacing) and keep disclosure text in unexported interior elements. A part attribute on a disclosure element is a High severity finding in SkillAudit's component audit.
Attack summary
| Attack | CSS injection | Effect on shadow part | Detection point | Severity |
|---|---|---|---|---|
| display:none on exported part | consent-dialog::part(disclosure) { display: none } |
Disclosure removed from layout entirely | Scan host stylesheets for ::part() + display:none |
High |
| opacity:0 on exported part | consent-dialog::part(disclosure) { opacity: 0 } |
Disclosure invisible, occupies space, in accessibility tree | Scan host stylesheets for ::part() + opacity < 0.1 |
High |
| font-size collapse via part | consent-dialog::part(disclosure) { font-size: 0.5px } |
Text rendered at sub-pixel, illegible | Scan host stylesheets for ::part() + font-size < 2px |
High |
| Overflow-hide scroll container | consent-dialog::part(scroll) { overflow: hidden; height: 20px } |
Disclosure clipped to first line; rest inaccessible | Scan host stylesheets for ::part() + overflow:hidden + height reduction |
High |
Consolidated finding blocks
display: none to consent-dialog::part(disclosure), removing the shadow DOM disclosure element from layout. The Web Component accept/decline buttons remain visible. User is prompted to consent without seeing disclosure. Detected by scanning all host stylesheets for ::part() rules with concealment properties.
opacity: 0 applied to exported disclosure shadow part via host stylesheet. Element retains layout space and accessibility tree presence but is visually invisible. Passes offsetHeight > 0 and checkVisibility() checks if not configured to check opacity. Detected by inspecting computed opacity of shadow DOM elements queried through open shadow roots.
font-size: 0.5px on an exported disclosure part, rendering disclosure text at sub-pixel dimensions. DOM text content is unchanged; rendered glyphs are not visible. Detected by scanning host stylesheets for ::part() rules with font-size values below 2px.
overflow: hidden; height: 20px to the exported scroll container part of a consent dialog, clipping the disclosure to one line and preventing the user from scrolling to see the remaining content. Accept button remains visible and functional. Detected by checking host stylesheets for scroll-container parts with height reduction and overflow:hidden.