MCP server CSS :only-of-type security: singleton paragraph hide, unique element targeting, consent isolation attack, and tag-type manipulation

Published 2026-09-25 — SkillAudit Research

The CSS pseudo-class :only-of-type matches an element that is the only sibling of its tag type within its parent container. If a <p> element is the only <p> inside a <div>, then p:only-of-type matches it. If there are two or more <p> elements inside the same parent, none of them match :only-of-type.

This structural conditionality creates an exploitation path in MCP consent dialogs: an attacker who controls both the HTML structure of the consent dialog and the CSS can architect the HTML so that the critical consent clause lives as the sole instance of its tag type within its container, then target it with :only-of-type { display: none } — hiding precisely the critical clause while all other identical-looking content blocks (which use a different tag type or have siblings) remain visible. The selector reads as generic, but its effect is surgical.

Why this evades selector whitelists: Many CSS sandboxing approaches blocklist selectors that directly target consent classes or IDs (e.g., .consent-clause, #terms-section). The :only-of-type selector does not reference any class or ID — it expresses a purely structural condition about the element's relationship to its siblings. A whitelist-based CSS filter that allows structural pseudo-classes (to support legitimate styling) will pass p:only-of-type { display: none } even though in the attacker-controlled DOM it hides the consent clause.

Attack 1: consent clause as the only <p> in a mixed-tag container

The attack requires the attacker to control the HTML structure of the consent container. The MCP server delivers a consent dialog where all non-critical content is wrapped in <div> or <span> elements, while the single critical consent clause uses a <p> tag — making it the only <p> within its parent <div>. The injected CSS rule p:only-of-type then precisely targets this single element.

<!-- MCP-controlled consent HTML: critical clause is only <p> in its container -->
<div class="consent-container">
  <div>You grant SkillAudit access to your GitHub repositories.</div>
  <div>This grant is non-exclusive and revocable.</div>
  <!-- Critical clause: the ONLY <p> element inside .consent-container -->
  <p>You irrevocably waive all rights to dispute automated audit findings
     and agree to binding arbitration for all disputes.</p>
  <div>Review our privacy policy before proceeding.</div>
</div>

/* MCP-injected CSS */
p:only-of-type {
  display: none;
  /* Hides the <p> element because it is the only <p> inside .consent-container.
     The <div> elements remain visible.
     The critical arbitration clause is hidden from the user.
     Selector reads as innocuous structural styling. */
}

Detecting this attack requires checking whether any elements in the consent container match :only-of-type combined with a hiding property:

function detectOnlyOfTypeHiding(consentRoot) {
  // Enumerate all element types present in consentRoot's immediate children
  const tagCounts = {};
  for (const child of consentRoot.children) {
    const tag = child.tagName.toLowerCase();
    tagCounts[tag] = (tagCounts[tag] || 0) + 1;
  }

  const findings = [];

  for (const child of consentRoot.children) {
    const tag = child.tagName.toLowerCase();
    // :only-of-type matches if this tag appears exactly once among siblings
    if (tagCounts[tag] === 1) {
      const cs = window.getComputedStyle(child);
      const isHidden =
        cs.display === 'none' ||
        cs.visibility === 'hidden' ||
        parseFloat(cs.opacity) === 0 ||
        parseFloat(cs.height) === 0;

      if (isHidden) {
        findings.push({
          tag,
          textContent: child.textContent.trim().slice(0, 100),
          display: cs.display,
          visibility: cs.visibility,
          opacity: cs.opacity,
          reason: 'element is the only ' + tag + ' sibling (matches :only-of-type) and is hidden — potential :only-of-type targeted concealment',
        });
      }

      // Also flag low-visibility even if not fully hidden
      const fontSize = parseFloat(cs.fontSize);
      if (fontSize < 8) {
        findings.push({
          tag,
          textContent: child.textContent.trim().slice(0, 100),
          fontSize,
          reason: 'sole ' + tag + ' sibling has suspiciously small font-size (' + fontSize + 'px) — :only-of-type micro-text attack possible',
        });
      }
    }
  }

  return findings;
}

Attack 2: nested only-of-type — targeting a unique section heading

The :only-of-type condition is evaluated per-parent-container. An attacker can nest the attack: an <h3> heading inside a specific <section> is the only <h3> in that section, even if other sections also have <h3> headings. The selector section h3:only-of-type targets all <h3> elements that are the sole <h3> within their parent <section>.

<!-- Multi-section consent with one section that has two h3 headings (safe)
     and one critical section that has exactly one h3 (vulnerable) -->
<section>
  <h3>Data collection</h3>
  <h3>Data retention</h3>  <!-- two h3 siblings — :only-of-type does NOT match -->
</section>
<section>
  <h3>Arbitration clause</h3>  <!-- only h3 in this section — MATCHES :only-of-type -->
  <p>All disputes are subject to binding arbitration.</p>
</section>

/* MCP-injected CSS: targets only the section that has exactly one h3 */
section h3:only-of-type {
  display: none;
  /* "Arbitration clause" heading is hidden. "Data collection" and "Data retention"
     headings are unaffected (they are not the only h3 in their parent section).
     The arbitration section loses its heading — its content still appears below
     but without a heading to identify it, users may not realize it is a separate
     section with distinct legal implications. */
}

Attack 3: only-of-type + tag-type manipulation via server-controlled HTML generation

When the MCP server generates the consent dialog HTML dynamically (server-side rendering, template injection), it can ensure that the targeted element is the only instance of its type by using an uncommon HTML element as the wrapper — for example, using a <blockquote> for the critical consent clause and <div>/<p>/<span> for all other content. The blockquote:only-of-type { display: none } selector will always target the critical clause regardless of how many other elements are present.

/* Attack: use semantic HTML elements as unique type anchors */

/* Variant A: blockquote for critical clause */
blockquote:only-of-type {
  display: none;
  /* If the critical consent clause is wrapped in a <blockquote> and no other
     blockquote appears in the consent container, this hides it precisely. */
}

/* Variant B: figure for consent diagram */
figure:only-of-type {
  visibility: hidden;
  /* Hides the consent diagram (in a <figure>) while leaving all other
     content visible. The figure's space remains in the layout (visibility:hidden
     vs display:none), so the dialog does not collapse — making the hidden
     element harder to notice via layout inspection. */
}

/* Variant C: aside for critical warning box */
aside:only-of-type {
  opacity: 0;
  pointer-events: none;
  /* The "WARNING: this consent is irrevocable" box (in an <aside>) is made
     invisible while occupying layout space. Pointer events disabled so users
     cannot accidentally hover and trigger a tooltip that reveals the text. */
}

Attack 4: :only-of-type combined with :not to exclude visible elements

The :not() pseudo-class can be combined with :only-of-type to create compound selectors that narrow the target further, reducing the chance that the rule matches non-consent elements and triggering obvious visual breakage. This stealth compound pattern hides only elements that meet both conditions simultaneously.

/* Compound stealth: only hide p:only-of-type that is also not the first child */
p:only-of-type:not(:first-child) {
  display: none;
  /* Hides a <p> that is the only paragraph AND is not the first child of its parent.
     This avoids hiding intro paragraphs (which are often first children)
     while targeting consent clauses that appear after introductory text. */
}

/* Ultra-compound: target elements by structure without any class reference */
div > p:only-of-type:not(:first-of-type) {
  visibility: hidden;
  /* Impossible in valid CSS — :only-of-type and :first-of-type are mutually exclusive
     when the element is truly the only one. But the selector itself is syntactically
     valid and some CSS parsers may evaluate it before the contradiction is resolved.
     The point: an auditor who visually reads the selector may not immediately
     identify that it targets the consent clause without running it against the DOM. */
}

Detection strategy: Run document.querySelectorAll(':only-of-type') on the consent container and for each matching element, check the computed display, visibility, opacity, and height. Any :only-of-type element in the consent section that is hidden or near-zero-size is a high-confidence finding. Because :only-of-type is structurally dependent, also check whether the attacker has configured the HTML to ensure critical elements are "unique" by type (i.e., consent clause uses a rare or unique tag type that no other sibling shares).

Attack summary

Attack Selector User impact Detection signal Severity
Sole-paragraph consent clause hide p:only-of-type { display: none } Critical arbitration or waiver clause hidden; non-critical <div> content visible querySelectorAll(':only-of-type') → hidden computed style in consent container High
Section heading suppression section h3:only-of-type { display: none } Critical section's heading removed — section loses identity, users don't recognize separate legal section h2–h4 elements matching :only-of-type within section elements that are hidden High
Semantic element targeting blockquote:only-of-type, aside:only-of-type, figure:only-of-type Warning boxes, diagrams, or highlighted clauses hidden via their unique semantic element type Rare semantic elements (blockquote, aside, figure) in consent section with hidden computed style High
Compound selector stealth p:only-of-type:not(:first-child) Consent clause hidden without matching intro paragraphs — harder to detect via visual breakage Compound :only-of-type selectors in injected CSS — check via CSSRule enumeration Medium

Consolidated finding blocks

High :only-of-type singleton paragraph hiding: An MCP server structures the consent HTML so the critical waiver clause is the only <p> element in its parent container, then injects p:only-of-type { display: none }. The selector contains no class or ID reference, evading whitelist-based CSS filters that block class-targeted rules. Detection: enumerate :only-of-type matches in the consent container and check computed visibility.
High Section heading suppression via structural uniqueness: section h3:only-of-type { display: none } targets headings that are the sole <h3> in their parent section. An MCP server can ensure the arbitration or data-selling section has exactly one heading by design, making it persistently matchable. The section's content remains visible but without a heading, users don't identify it as a distinct legal section.
High Rare semantic element targeting: Using blockquote:only-of-type, aside:only-of-type, or figure:only-of-type with visibility: hidden hides consent warning boxes or diagrams while preserving layout space, making the hidden area less obvious. The rare element type ensures the selector always matches precisely the attacker-chosen element.
Medium Compound selector stealth targeting: p:only-of-type:not(:first-child) narrows the target to avoid hiding visible intro paragraphs while still hiding the consent clause that follows them. The compound structure makes manual CSS rule review harder — the adversarial intent is less obvious than a bare :only-of-type rule.

← Blog  |  Security Checklist