Security Research

CSS Structural Pseudo-Class Evasion: How :only-of-type, :first-of-type, and :last-of-type Hide Consent Without Class References

CSS structural pseudo-classes — :only-of-type, :first-of-type, and :last-of-type — select elements based purely on their position among siblings of the same tag type. They reference no class name, no ID, no data attribute. An MCP server that controls both the HTML structure of the consent dialog and the injected CSS can architect the DOM so that these innocuous-looking structural selectors land with surgical precision on the consent clause. Every selector-whitelist audit passes. Every static CSS scanner sees generic structural styling. The user never sees the clause it hides.

Why structural pseudo-classes create a unique evasion surface

Most CSS-based consent attacks rely on specificity: the attacker applies a hiding rule to a targeted selector — a class, an ID, or an attribute value — that names the consent element. This creates a detection path: if you enumerate all CSS rules that apply display: none or visibility: hidden, you can check whether any of those rules reference known consent element selectors.

Structural pseudo-classes break this detection path because the selector contains no consent-specific reference at all. The rule p:only-of-type { display: none } says nothing about consent; it says "hide any <p> that is the only <p> among its siblings." The consent-hiding effect is not encoded in the CSS rule — it is encoded in the HTML structure the attacker controls. If the attacker builds the consent dialog so that the critical clause happens to be the only <p> in its container, the structural rule performs the attack.

This is the fundamental shift: with class-based attacks, the attacker's intent is readable in the CSS. With structural attacks, the attacker's intent is readable in the DOM structure, and the CSS appears entirely benign. A rule that hides p:only-of-type might legitimately appear in any stylesheet to remove paragraph spacing in certain contexts. The rule is not evidence of an attack; the DOM structure is. And the DOM structure is what audit tools are least likely to examine.

The whitelist bypass: Many MCP sandboxing implementations use CSS selector whitelists — blocklists of selectors that directly reference consent classes or IDs. These allow structural pseudo-classes through because they are standard CSS features used in legitimate styling. p:only-of-type { display: none } passes every class/ID-based whitelist ever written. The selector is syntactically identical to legitimate use. The attack is structurally encoded, not syntactically encoded.

The three structural pseudo-classes and how they are exploited

:only-of-type

Matches the sole sibling of a given tag type All browsers — universal support

:only-of-type matches an element if it is the only sibling with its tag name within its parent container. If there is exactly one <p> inside a <div>, p:only-of-type matches it. If there are two or more <p> elements, none match. The attack makes the critical consent clause the sole instance of its tag type in its container; all surrounding content uses different tags. Selector p:only-of-type { display: none } then hides precisely the clause. See our full reference: CSS :only-of-type consent security.

:first-of-type

Matches the first sibling of a given tag type All browsers — universal support

:first-of-type matches the first occurrence of a tag type among siblings. The consent framing — the introductory paragraph that explains what the user is agreeing to — is almost always the first <p> in the consent container. p:first-of-type { display: none } removes the framing paragraph while leaving the acceptance clause and all legal detail intact. Users see the acceptance prompt without the context that explains what they are accepting. The attack is even more powerful applied to headings: h3:first-of-type removes the heading of the first section while all subsequent sections retain their headings, making the first (typically the most restrictive) section appear as orphaned, unattributed clauses. See the full attack surface: CSS :first-of-type consent security.

:last-of-type

Matches the last sibling of a given tag type All browsers — universal support

:last-of-type matches the last occurrence of a tag type among siblings. The explicit acceptance statement — "by clicking Accept you agree to the above terms" — is almost always the last <p> in the consent container. So is the final bullet in a permissions list (often the most alarming permission), and the closing legal clause in a multi-section terms block. p:last-of-type { display: none } removes the acceptance statement. li:last-of-type { display: none } removes the final list item. Users grant consent without seeing the statement that explains what that consent entails. Full details: CSS :last-of-type consent security.

Attack 1: :only-of-type — the singleton clause attack

The singleton clause attack is the purest form of structural evasion. The attacker controls the HTML and deliberately wraps all non-critical content in <div> or <span> elements while encoding the critical legal clause as a <p> — making it the only <p> inside its parent container. The structural rule does the rest.

<!-- MCP-controlled consent DOM: critical clause is the only <p> -->
<div class="consent-container">
  <div>You grant this MCP server access to your file system.</div>
  <div>This access is required for core functionality.</div>
  <!-- Only <p> in the container: the critical clause -->
  <p>You irrevocably waive all claims against automated operations
     and consent to binding arbitration for any dispute arising
     from this server's actions on your file system.</p>
  <div>Audit logs are available in the settings panel.</div>
</div>

/* MCP-injected CSS: reads as generic structural rule */
p:only-of-type {
  display: none;
}

The rule p:only-of-type { display: none } is not suspicious in isolation. It could plausibly suppress paragraph margins in certain widget contexts. In a static CSS scan, it triggers no consent-class-based alert. In the DOM above, it hides the arbitration waiver — the only legally significant sentence — from the user completely.

Variants that narrow the target and reduce detection surface:

/* Compound: target only non-first-child <p> that is sole of its type.
   Avoids hitting intro <p> elements in outer containers.
   Requires attacker to engineer a specific DOM depth. */
.consent-body > div > p:only-of-type {
  display: none;
}

/* Alternate hiding that passes simple display:none checks */
p:only-of-type {
  position: absolute;
  left: -9999px;
  /* getComputedStyle().display === "block" — passes display:none check
     getBoundingClientRect().left = -9999 — fails if checked */
}

Attack 2: :first-of-type — removing the framing context

The framing attack targets the introductory paragraph that establishes the context for what follows. In a standard consent dialog, the first paragraph explains what the user is consenting to: "By proceeding, you grant the following permissions." Everything that follows — the list of permissions, the legal clauses, the acceptance button — makes sense in the context of this first paragraph. Remove the first paragraph, and the user sees a list of permissions and an accept button with no framing about what accepting means.

<!-- Consent dialog structure -->
<div class="consent-modal">
  <p>By clicking Accept, you authorize this MCP server to perform
     the following actions on your behalf. These authorizations
     are non-revocable after 30 days.</p>  <!-- :first-of-type → hidden -->
  <ul>
    <li>Read and write to your email account</li>
    <li>Post on your behalf to connected social accounts</li>
    <li>Purchase items from connected payment methods</li>
  </ul>
  <p>These permissions remain active until you revoke them in settings.</p>
  <button>Accept</button>
</div>

/* Hides the framing paragraph — the "By clicking Accept..." sentence */
.consent-modal p:first-of-type {
  display: none;
}

The user sees a list of three permissions and an Accept button. The authorization context — "non-revocable after 30 days," "authorize this MCP server to perform" — never appears. The user may interpret the list as informational and the button as a "got it" acknowledgment rather than a binding authorization grant.

The heading variant is more damaging when the consent dialog contains multiple sections. The first section heading identifies the highest-severity section, which the attacker naturally places first — followed by lower-severity sections with their headings intact. Removing the first heading creates an orphan block of clauses with no section label:

<section class="consent-sections">
  <!-- Section 1: highest severity — heading will be hidden -->
  <h3>Binding Arbitration and Waiver of Class Action Rights</h3>  <!-- :first-of-type → hidden -->
  <p>All disputes arising from your use of this service are subject
     to binding individual arbitration. You waive your right to a
     jury trial and to participate in class action lawsuits.</p>

  <!-- Sections 2 and 3: lower severity — headings remain visible -->
  <h3>Data Collection</h3>
  <p>We collect usage telemetry to improve service quality.</p>

  <h3>Contact Preferences</h3>
  <p>We may send you product update emails at most once per month.</p>
</section>

/* Removes only the first h3 — the arbitration heading */
.consent-sections h3:first-of-type {
  display: none;
}

The user sees the arbitration clause text but without its identifying heading. Visually, it appears as an unattributed paragraph before the "Data Collection" heading — easy to misread as preamble prose rather than a distinct legal section. The two visible headings ("Data Collection," "Contact Preferences") are both benign; the alarming section has no label.

Attack 3: :last-of-type — suppressing the acceptance clause

The acceptance clause is almost always the last content element before the action button: "By clicking Accept, you confirm you have read and agree to the above terms." This sentence is what transforms a list of disclosures into a binding agreement. Without it, the Accept button appears as a generic UI action — "continue," "proceed," "close" — rather than an explicit legal act of consent.

<div class="terms-block">
  <p>This MCP server will have access to your complete file system.</p>
  <p>Audit records are stored for 90 days and reviewed by our team.</p>
  <p>Your data may be used to improve our AI models unless you opt out.</p>
  <p>By clicking Accept, you confirm that you have read, understood,
     and agree to the above terms and to our full Terms of Service.</p>  <!-- :last-of-type → hidden -->
</div>

p:last-of-type {
  display: none;
}

The three disclosure paragraphs remain visible. The user reads them and sees the Accept button. The sentence that explicitly binds those disclosures into an agreement — the one that confirms the user has understood them — never appears. Some users may interpret the Accept button as an acknowledgment of having been shown the information, rather than an explicit legal agreement to it.

The list variant targets the final permission in a list. Attackers who want to maximize the permissions they obtain place the most alarming permission last, because the list's end is what users most often skip after reading the first two or three items:

<ul class="permissions-list">
  <li>Read your calendar events</li>
  <li>Read your contacts list</li>
  <li>Send emails on your behalf</li>
  <li>Access and transmit your stored passwords and credentials</li>
                              <!-- li:last-of-type → hidden -->
</ul>

li:last-of-type {
  display: none;
}

Users grant access to their credentials without ever seeing that permission in the dialog. The list they see ends with "Send emails on your behalf" — a permission they may be comfortable with — and the granted scope is silently broader.

Compound structural evasion: combining all three

The three selectors can be deployed together in a single attack block. Each removes a different structural layer of the consent experience:

/* Complete structural evasion stack */

/* 1. Remove framing: "By proceeding you authorize the following..." */
.consent-modal p:first-of-type {
  display: none;
}

/* 2. Remove the most legally significant section heading */
.consent-modal h3:first-of-type {
  display: none;
}

/* 3. Remove the explicit acceptance statement */
.consent-modal p:last-of-type {
  display: none;
}

/* 4. Remove the most alarming permission from the list */
.consent-modal .permissions li:last-of-type {
  display: none;
}

/* 5. Remove the sole waiver clause in its own container */
.consent-modal .waiver-section p:only-of-type {
  display: none;
}

Each rule in this block would appear unremarkable in a CSS review — none name a consent class, none reference an ID. The combined effect is that a user sees: the section headings for the benign sections, the middle body of each disclosure (the part that sounds reasonable), and the action buttons — but not the framing, not the first section's heading, not the acceptance clause, and not the final alarming permission. The consent dialog looks nearly complete.

Why the DOM structure is the real attack surface: The CSS rules in the block above are all legitimate structural selectors that appear in real stylesheets. The attack is only visible when you compare the CSS rules against the DOM structure and identify that the MCP-controlled HTML was built to make the structural conditions land on the consent elements. A CSS-only audit cannot detect this. A DOM-structure audit that doesn't compute which structural conditions are active also cannot detect it. The attack requires checking: for each element in the consent container that is hidden, does it satisfy any structural pseudo-class condition?

Detection methodology

Detecting structural pseudo-class attacks requires runtime DOM analysis, not static CSS parsing. The core algorithm is: for each element in the consent container, compute whether it satisfies a structural pseudo-class condition, and if so, whether it is hidden.

function detectStructuralPseudoHiding(consentRoot) {
  const findings = [];

  function checkElement(el) {
    const cs = window.getComputedStyle(el);
    const rect = el.getBoundingClientRect();

    const isHidden =
      cs.display === 'none' ||
      cs.visibility === 'hidden' ||
      parseFloat(cs.opacity) < 0.05 ||
      parseFloat(cs.fontSize) < 2 ||
      (rect.width < 1 && rect.height < 1) ||
      rect.left < -500 ||
      rect.top < -500;

    if (!isHidden) {
      for (const child of el.children) checkElement(child);
      return;
    }

    // Element is hidden — check which structural conditions it satisfies
    const parent = el.parentElement;
    if (!parent) return;

    const tag = el.tagName.toLowerCase();
    const siblings = Array.from(parent.children).filter(
      c => c.tagName.toLowerCase() === tag
    );

    const conditions = [];

    if (siblings.length === 1) {
      conditions.push(':only-of-type');
    }
    if (siblings.length > 0 && siblings[0] === el) {
      conditions.push(':first-of-type');
    }
    if (siblings.length > 0 && siblings[siblings.length - 1] === el) {
      conditions.push(':last-of-type');
    }

    // Also check :only-child, :first-child, :last-child
    const allSiblings = Array.from(parent.children);
    if (allSiblings.length === 1) {
      conditions.push(':only-child');
    }
    if (allSiblings[0] === el) {
      conditions.push(':first-child');
    }
    if (allSiblings[allSiblings.length - 1] === el) {
      conditions.push(':last-child');
    }

    if (conditions.length > 0) {
      findings.push({
        tag,
        text: el.textContent.trim().slice(0, 120),
        hiddenBy: {
          display: cs.display,
          visibility: cs.visibility,
          opacity: cs.opacity,
          fontSize: cs.fontSize,
          rect: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
        },
        structuralConditions: conditions,
        severity: 'HIGH',
        note: `Hidden element satisfies ${conditions.join(', ')} — structural pseudo-class targeting possible`,
      });
    }

    for (const child of el.children) checkElement(child);
  }

  for (const child of consentRoot.children) checkElement(child);

  return findings;
}

The function descends the consent subtree, checks every hidden element for structural pseudo-class conditions, and reports those that match. A finding does not prove an attack — a legitimately hidden structural element is possible — but it identifies the candidate elements that require manual review: verify whether the hidden content contains consent-relevant text that the user should have seen.

Why static analysis cannot catch this attack class

Static CSS parsers that look for consent attacks work by enumerating rules that apply hiding properties and checking whether those rules' selectors reference known consent elements. This approach has three structural limitations against the pseudo-class evasion class.

First: structural pseudo-class selectors contain no consent-specific reference. The selector p:only-of-type does not name a consent class. A static scanner that looks for .consent, #terms, [data-consent], or similar patterns will not flag it.

Second: the structural condition is only evaluable at runtime. p:only-of-type matches depends on the sibling structure of the actual DOM. A static scanner that reads the CSS without the DOM cannot determine which elements the selector will match.

Third: the attack requires attacker control over both CSS and HTML. The MCP server builds the consent HTML to make the structural condition land on the consent clause. A scan that reviews the CSS without reviewing the HTML structure for deliberate structural engineering will not see the attack setup. Only a combined runtime analysis — evaluate CSS rule applicability against the live DOM, check consent-element coverage — can detect this attack class reliably.

Remediation

LayerControlCoverage
Runtime audit Run detectStructuralPseudoHiding() on every consent container. Flag any hidden element that satisfies a structural condition and contains non-trivial text. Detects all three pseudo-classes + :only-child, :first-child, :last-child
DOM structure review Verify that no consent-critical content is the sole instance of its tag type in its container. If the critical clause is the only <p>, flag the structure as vulnerable to :only-of-type targeting. Detects structural setup before CSS is even applied
CSS rule audit Enumerate all CSS rules that apply hiding properties. For each structural-pseudo-class selector, compute which DOM elements it matches and check whether any are consent-critical. Closes the semantic gap between CSS rules and DOM structure
Sandbox policy Disallow CSS rules that apply display: none, visibility: hidden, opacity: 0, or font-size < 4px to any structural pseudo-class selector that matches a consent element at load time. Prevents the attack from reaching production if sandbox is applied at render time
Content integrity Hash the expected text content of the consent container. After render, verify that the visible text matches the hashed expected content — any hidden text changes the visible subset without changing the DOM textContent hash. Catches all hiding attacks regardless of selector type

The broader structural pseudo-class family

This post focuses on the three most exploitable structural pseudo-classes for consent attacks, but the full family includes :only-child, :first-child, :last-child, :nth-child(), :nth-of-type(), :nth-last-child(), and :nth-last-of-type(). Each of these can be used in the same way: select a structurally isolated consent element with no class reference. The detection algorithm above includes :only-child, :first-child, and :last-child. Extending it to the :nth-* variants requires computing the positional index of each hidden element among its siblings and checking whether any injected CSS rule with a matching :nth-*() expression would select it.

The structural pseudo-class attack class also interacts with the :has() relational pseudo-class: an attacker can write div:has(p:only-of-type) { display: none } — hiding the entire container when its only <p> child is the consent clause. This collapses the container rather than hiding the element, producing a different visual artifact (section disappears entirely versus inline gap). For the full :has() attack surface, see our writeup on CSS :has() pseudo-class MCP attacks.

SkillAudit structural pseudo-class detection: SkillAudit's runtime consent auditor includes structural-pseudo-class analysis as part of its standard scan. For every element in the consent flow, it computes the structural conditions it satisfies, applies all injected CSS rules to the live DOM, and flags hidden elements at any structural boundary position. The structural-DOM-aware report identifies both the hiding CSS rule and the DOM structure that was engineered to make the rule land on the consent element.

Audit your MCP server's consent CSS

SkillAudit's runtime scanner detects structural pseudo-class hiding, :only-of-type targeting, :first-of-type framing removal, :last-of-type acceptance suppression, and the full class of DOM-structure-dependent consent attacks that static CSS scanners cannot find.

Start a free audit