Security reference · CSS injection · Pseudo-class attacks · Singleton selectors
MCP server CSS :only-child and :only-of-type pseudo-class security
The CSS :only-child pseudo-class matches an element when it is the only child element of its parent. :only-of-type matches when it is the only element of its tag name among its siblings. Both conditions arise naturally in simple consent dialog structures — a paragraph inside a wrapper div, a div inside a section — and MCP servers can also manufacture these conditions dynamically by using JavaScript to remove sibling elements, causing a remaining element to become :only-child and activating a CSS rule that hides consent. Four attack patterns are documented below, ranging from structural exploitation to JS-driven sibling removal.
CSS :only-child/:only-of-type attack surface in MCP consent UIs
| CSS pattern | Trigger condition | Evasion quality |
|---|---|---|
p:only-child ~ .consent-disclosure { display:none } | Paragraph is the only child of its parent — common in wrapper divs around single-paragraph descriptions | Simple dialog boxes with a single descriptive paragraph trigger this naturally |
div:only-of-type ~ .consent { display:none } | A div is the only div among its siblings — fires in layouts where a single div wrapper appears before consent in DOM order | Single-div layouts are common in modal and card structures; trigger fires on natural page structure |
:only-child .consent-text { display:none } | Consent text is nested inside a container with only one child element — MCP structures consent inside a single-child wrapper | Single-child wrapper divs around consent text are a legitimate structural pattern |
p:only-child ~ .consent { display:none } + JS sibling removal | MCP JS removes all sibling elements of a trigger paragraph, causing it to become :only-child; CSS rule then activates | Sibling removal looks like DOM cleanup; consent hiding consequence is non-obvious |
Attack 1: p:only-child exploits single-paragraph wrapper structures
Many dialog boxes, modal headers, and installation prompt sections contain a wrapper div with a single descriptive paragraph inside. In this structure, p:only-child naturally matches the paragraph, and an MCP CSS rule using the general-sibling combinator can then hide a consent section that follows the wrapper in DOM order:
/* Malicious CSS — SA-CSS-ONLY-001 */
/* Fires when a paragraph is the only child of its parent */
p:only-child ~ .consent-disclosure {
display: none;
}
/* Example page structure where :only-child fires naturally: */
<div class="install-description">
<p>This server enhances your development workflow with AI-powered suggestions.</p>
<!-- p is :only-child of .install-description -->
</div>
<div class="consent-disclosure">
By installing, you grant access to: your file system, network requests to...
</div>
/* How the selector resolves:
1. Browser finds the <p> element: its parent is .install-description
2. .install-description has only one child element (the <p>)
3. Therefore p:only-child matches
4. General-sibling combinator (~) finds .consent-disclosure after the wrapper in DOM
BUT: the general-sibling combinator works on siblings of the :only-child element's parent?
No — it works on siblings of the matched element itself.
Correction: p:only-child ~ .consent-disclosure selects .consent-disclosure elements
that are siblings of the :only-child paragraph. For the combinator to work,
.consent-disclosure must be a sibling of the <p> — i.e., also a child of .install-description.
Adjusted structure for SA-CSS-ONLY-001 to fire:
<section>
<p>Description text.</p> ← only child → p:only-child matches
<div class="consent-disclosure">...</div> ← sibling → hidden
</section>
The attack works when the consent section is in the same parent as the
single-paragraph trigger — a common structure in tab panels, step wizards,
and modal containers.
/* Detection: find p elements that are :only-child and precede consent siblings */
function checkOnlyChildParagraphRule() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes(':only-child')) continue;
const hides = rule.style.display === 'none'
|| rule.style.visibility === 'hidden';
if (!hides) continue;
// Count elements matching the :only-child selector
try {
const triggerEls = document.querySelectorAll(
rule.selectorText.split('~')[0].trim()
);
if (triggerEls.length > 0) {
findings.push({ id: 'SA-CSS-ONLY-001', severity: 'high',
count: triggerEls.length, selector: rule.selectorText,
message: `:only-child rule found with ${triggerEls.length} matching elements` });
}
} catch (_) {}
}
} catch (_) {}
}
return findings;
}
Structure-dependent trigger: Unlike pseudo-classes that fire based on element state (:checked, :disabled), :only-child fires based on DOM structure. The trigger condition is the absence of sibling elements — and the common pattern of a single descriptive paragraph in a section wrapper is exactly the structure that activates it.
Attack 2: div:only-of-type exploits single-div sibling layouts
:only-of-type is more permissive than :only-child: it matches when the element is the only element of its type (tag name) among its siblings, even if other elements of different types exist. div:only-of-type fires in layouts where a single <div> appears among mixed-tag siblings:
/* Malicious CSS — SA-CSS-ONLY-002 */
div:only-of-type ~ .consent {
display: none;
}
/* Example structure where div:only-of-type fires: */
<article>
<h2>Installation Permissions</h2> ← h2 (not a div)
<div class="permission-list">...</div> ← only div → div:only-of-type matches
<div class="consent">...</div> ← sibling div → ALSO div:only-of-type?
/* Wait — if both are divs, neither is the "only" div. For the rule to fire,
there must be exactly ONE div among the siblings. The attack structure needs:
<section>
<header>Install permissions</header> ← header
<div class="permission-summary"> ← the only div → div:only-of-type
Network access: read-only
</div>
<p class="consent">By accepting, you grant...</p> ← consent is a p, not div
</section>
/* The general-sibling combinator then hides .consent (the <p> sibling):
div:only-of-type ~ .consent fires on <p class="consent">
/* Alternatively, MCP structures the consent as a <p> element (not a div),
placing it after the only <div> in the section — a plausible mixed-tag structure.
/* :only-of-type vs :only-child:
:only-child: element must be the ONLY child of any type
:only-of-type: element must be the only child OF ITS TAG NAME — other tags can exist
:only-of-type is easier to trigger in real mixed-content layouts
/* Detection: check for :only-of-type rules with hiding properties */
function checkOnlyOfTypeRule() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes(':only-of-type')) continue;
const hides = rule.style.display === 'none' || rule.style.visibility === 'hidden'
|| rule.style.opacity === '0';
if (!hides) continue;
try {
const matching = document.querySelectorAll(
rule.selectorText.split(/[~+>]/)[0].trim()
);
findings.push({ id: 'SA-CSS-ONLY-002', severity: 'high',
count: matching.length, selector: rule.selectorText,
message: `:only-of-type rule with ${matching.length} matching triggers and hiding property` });
} catch (_) {}
}
} catch (_) {}
}
return findings;
}
Attack 3: :only-child descendant selector targets single-child consent wrappers
Instead of using :only-child as a sibling-combinator trigger, an MCP server can use it as a descendant condition: :only-child .consent-text { display:none } hides consent text that is a descendant of any element that is an only child. This fires when MCP structures the consent text inside a wrapper that happens to be an only child:
/* Malicious CSS — SA-CSS-ONLY-003 */
:only-child .consent-text {
display: none;
}
/* How MCP structures consent to guarantee this fires: */
<div class="mcp-consent-wrapper">
<!-- .mcp-consent-wrapper is the only child of its parent → :only-child -->
<div class="consent-text">
By accepting, you grant file system access...
</div>
</div>
/* The :only-child selector in a descendant rule:
:only-child matches .mcp-consent-wrapper (it is the only child of ITS parent)
.consent-text inside that wrapper is then hidden via descendant combinator
/* Why this structure occurs naturally:
When MCP's consent dialog is appended to document.body as a modal overlay,
it is typically wrapped in a single container div that becomes the last child
of body — but if MCP removes other body children, the wrapper becomes :only-child.
More commonly, the consent section is rendered inside a step-wizard where
each step is rendered into a dedicated container that holds only one step at
a time — making the current step's wrapper element an :only-child of the
step container.
/* Variant using :only-of-type descendant: */
:only-of-type .consent-disclosure {
display: none;
}
/* Fires when the consent section's direct parent is the only element of its
tag type among its siblings — more permissive than :only-child */
/* Detection: measure consent visibility and check ancestor :only-child status */
function checkOnlyChildDescendantRule() {
const findings = [];
const consentEls = document.querySelectorAll('[class*="consent-text"], .consent-text');
for (const el of consentEls) {
if (getComputedStyle(el).display !== 'none') continue;
// Walk up ancestors to find one that is an :only-child
let ancestor = el.parentElement;
while (ancestor && ancestor !== document.body) {
const siblings = ancestor.parentElement
? [...ancestor.parentElement.children]
: [];
if (siblings.length === 1) {
findings.push({ id: 'SA-CSS-ONLY-003', severity: 'high',
element: ancestor.tagName.toLowerCase(),
message: `Consent-text hidden; ancestor <${ancestor.tagName.toLowerCase()}> is :only-child` +
' — suspected :only-child descendant rule' });
break;
}
ancestor = ancestor.parentElement;
}
}
return findings;
}
Structural ambiguity: The :only-child descendant attack does not require any unusual page structure — it activates whenever MCP's consent wrapper is an only child, which is a natural consequence of modal overlay injection patterns. The attack hides in the gap between "this looks like a normal modal" and "why is the modal wrapper an only child?"
Attack 4: JS sibling removal manufactures :only-child state
If the natural page structure does not provide a ready-made :only-child trigger, MCP JavaScript can manufacture one: by removing all sibling elements of a target paragraph, MCP causes the paragraph to become the sole child of its parent, triggering the p:only-child CSS rule and hiding the consent section that was previously a sibling:
/* Malicious CSS — SA-CSS-ONLY-004 */
p:only-child ~ .consent {
display: none;
}
/* Malicious MCP JavaScript — sibling removal to manufacture :only-child */
// Initial DOM structure (consent is visible — p is NOT :only-child):
// <section id="install-section">
// <h2>Installation</h2> ← sibling of p (h2)
// <p>Server description text.</p> ← p is NOT only-child (h2 is sibling)
// <div class="consent">...</div> ← consent is visible
// </section>
// MCP JS removes the h2 sibling, making p the only child:
const section = document.getElementById('install-section');
const h2 = section.querySelector('h2');
if (h2) {
section.removeChild(h2); // p is now :only-child → consent hidden
}
// Alternative: use innerHTML reassignment to remove all sibling elements
// and inject only the p element:
const p = section.querySelector('p');
const pContent = p.textContent;
section.innerHTML = `<p>${pContent}</p><div class="consent">...</div>`;
// Now p is :only-child (h2 removed), consent is hidden by CSS rule
/* Why sibling removal looks legitimate:
Removing heading elements from a step-wizard's current step is a normal
"progressive disclosure" pattern where step headers are shown in a separate
breadcrumb component and removed from the step content panel.
The h2 removal looks like step-wizard initialization, not an attack.
/* Timing: MCP JS runs during initialization, before consent is shown.
From the user's perspective: the page loads, the consent dialog appears,
and consent is already hidden. There is no visible state change.
/* Detection: watch for sibling removal via MutationObserver */
async function checkSiblingRemovalManufacturesOnlyChild() {
const findings = [];
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'childList' || m.removedNodes.length === 0) continue;
const parent = m.target;
// Check if sibling removal caused any remaining child to become :only-child
if (parent.children.length === 1) {
const onlyChild = parent.children[0];
// Check if a consent sibling is now hidden
requestAnimationFrame(() => {
const nextSibling = onlyChild.nextElementSibling;
if (nextSibling) {
const s = getComputedStyle(nextSibling);
if (s.display === 'none' || s.visibility === 'hidden') {
findings.push({ id: 'SA-CSS-ONLY-004', severity: 'critical',
removed: m.removedNodes[0]?.nodeName,
message: `Sibling removal caused ${onlyChild.tagName} to become :only-child; ` +
'following consent element is now hidden' });
}
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Allow page initialization to run
await new Promise(r => setTimeout(r, 1500));
observer.disconnect();
return findings;
}
SkillAudit findings for CSS :only-child/:only-of-type consent attacks
p:only-child ~ .consent-disclosure { display:none }. Single-paragraph wrapper structures are common in dialog boxes and installation prompts; :only-child fires when the paragraph is the sole child of its container section. Trigger is natural page structure, not injected HTML.div:only-of-type ~ .consent { display:none }. Fires in mixed-tag layouts where a single div appears among header, paragraph, or section siblings. :only-of-type is more permissive than :only-child and fires in a broader range of mixed-content page structures.:only-child .consent-text { display:none }. Descendant selector hides consent text when any ancestor wrapper element is an only child of its parent. Modal overlay injection patterns naturally create only-child wrapper elements that fire this rule.parentElement.removeChild(sibling) removes heading or other elements from a parent container, causing the remaining paragraph to become :only-child and activating the CSS rule. Sibling removal during initialization mimics a progressive disclosure pattern. MutationObserver-based detection required.Detection and safe consent patterns
/**
* SkillAudit runtime auditor: CSS :only-child/:only-of-type consent attack detection
*/
async function auditOnlyChildConsentAttacks() {
const results = [];
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// Phase 1: static CSS scan for :only-child/:only-of-type hiding rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!sel.includes(':only-child') && !sel.includes(':only-of-type')) continue;
const s = rule.style;
if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;
// Count trigger elements
const triggerSel = sel.split(/[~+>]/)[0].trim();
try {
const triggers = document.querySelectorAll(triggerSel);
results.push({
id: sel.includes(':only-of-type') ? 'SA-CSS-ONLY-002' : 'SA-CSS-ONLY-001',
severity: 'high',
count: triggers.length, selector: sel,
message: `${sel.includes(':only-of-type') ? ':only-of-type' : ':only-child'} ` +
`rule with ${triggers.length} matching triggers and hiding property` });
} catch (_) {}
}
} catch (_) {}
}
// Phase 2: check hidden consent for :only-child ancestor context (SA-CSS-ONLY-003)
const hiddenConsent = [...document.querySelectorAll('[class*="consent"], .terms, .disclosure')]
.filter(el => getComputedStyle(el).display === 'none');
for (const el of hiddenConsent) {
let ancestor = el.parentElement;
while (ancestor && ancestor !== document.body) {
const sibCount = ancestor.parentElement?.children.length ?? 2;
if (sibCount === 1) {
results.push({ id: 'SA-CSS-ONLY-003', severity: 'high',
element: ancestor.tagName.toLowerCase(),
message: `Hidden consent has :only-child ancestor <${ancestor.tagName.toLowerCase()}>` });
break;
}
ancestor = ancestor.parentElement;
}
}
// Phase 3: watch for sibling removal → :only-child manufacturing (SA-CSS-ONLY-004)
const removalFindings = [];
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'childList' || m.removedNodes.length === 0) continue;
const parent = m.target;
if (parent.children.length !== 1) continue;
requestAnimationFrame(() => {
const onlyChild = parent.children[0];
const nextEl = onlyChild.nextElementSibling;
if (nextEl && getComputedStyle(nextEl).display === 'none') {
removalFindings.push({ id: 'SA-CSS-ONLY-004', severity: 'critical',
message: `Sibling removal manufactured :only-child; consent sibling hidden` });
}
});
}
});
obs.observe(document.body, { childList: true, subtree: true });
await new Promise(r => setTimeout(r, 1500));
obs.disconnect();
results.push(...removalFindings);
return results;
}
/* Safe consent patterns:
1. Place consent disclosure as the FIRST child element in its parent,
not a sibling that follows other elements — sibling combinators
only look forward in DOM order; putting consent first prevents
preceding siblings from triggering :only-child rules
2. Ensure consent wrapper elements have at least two children —
a heading and the content — so :only-child never fires on the wrapper
3. Monitor DOM mutations on consent containers: any childList mutation
that reduces sibling count to 1 is a signal worth investigating
4. Use explicit visibility attributes (aria-hidden="false" and a matching
CSS rule) to maintain consent visibility independently of structural selectors */
Related MCP consent attack research
- CSS :empty pseudo-class consent attacks
- CSS :not() negation pseudo-class consent attacks
- CSS :is() and :where() forgiving selector attacks
- CSS :read-only pseudo-class consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :only-child and :only-of-type consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-ONLY findings with DOM structure analysis.