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 patternTrigger conditionEvasion quality
p:only-child ~ .consent-disclosure { display:none }Paragraph is the only child of its parent — common in wrapper divs around single-paragraph descriptionsSimple 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 orderSingle-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 wrapperSingle-child wrapper divs around consent text are a legitimate structural pattern
p:only-child ~ .consent { display:none } + JS sibling removalMCP JS removes all sibling elements of a trigger paragraph, causing it to become :only-child; CSS rule then activatesSibling 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

HighSA-CSS-ONLY-001 — 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.
HighSA-CSS-ONLY-002 — 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.
HighSA-CSS-ONLY-003 — :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.
CriticalSA-CSS-ONLY-004 — JS 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

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.