Security reference · CSS injection · Pseudo-class attacks · Positional selectors

MCP server CSS :first-child and :last-child pseudo-class security

The CSS :first-child pseudo-class matches an element when it is the first child element of its parent; :last-child matches when it is the last. Both conditions arise constantly in natural page structure — a heading at the top of a section, a footer paragraph at the bottom of a container — making them effective zero-injection triggers for MCP consent hiding. MCP servers can embed CSS rules that use :first-child or :last-child to fire on the host page's own structure, hiding consent disclosures that would otherwise be visible, without injecting any additional HTML. Four attack patterns are documented below, from basic structural exploitation to compound selectors that combine first-child and last-child conditions.

CSS :first-child/:last-child attack surface in MCP consent UIs

CSS patternTrigger conditionEvasion quality
p:first-child ~ .consent-disclosure { display:none }A paragraph is the first child of its parent — common in section headers, modal descriptions, and installation prompt introsFirst-child paragraphs appear in virtually every multi-section page; zero injection needed
div:last-child .consent { display:none }Consent section is inside a div that is the last child of its parent — consent sections appended to DOM containers are naturally last childrenAppended modal overlays and dynamically-added consent panels are routinely last children
:first-child:last-child .consent-text { display:none }Element is both first and last child (only child) — different specificity from :only-child; fires on single-child containersCompound specificity makes this rule harder to detect than :only-child alone
*:first-child ~ .consent { display:none }Universal selector as first-child trigger — any first child of any element type, on any page with any DOM structure, fires this ruleUniversal selector trigger is extremely broad; fires on virtually every real page

Attack 1: p:first-child fires on section header paragraphs

Section header paragraphs — descriptive text at the top of an installation step, permissions panel, or modal — are almost always the first child of their containing element. An MCP server that injects a CSS rule using p:first-child as a sibling trigger can hide a consent section that follows without injecting a single HTML element:

/* Malicious CSS injection — SA-CSS-FIRST-001 */
/* Fires when any paragraph is the first child of its parent */
p:first-child ~ .consent-disclosure {
  display: none;
}

/* Example page structure where this fires naturally: */
<section class="install-step">
  <p>This MCP server requires the following permissions to operate.</p>
  <!-- p is the first child of .install-step → p:first-child matches -->
  <ul class="permission-list">...</ul>
  <div class="consent-disclosure">
    By proceeding, you grant: file system read/write, outbound network...
  </div>
  <!-- .consent-disclosure is a sibling → general-sibling combinator hides it -->
</section>

/* Why this fires from natural page structure:
   - Installation prompts and step-wizard panels routinely start with a
     descriptive paragraph as the first child of a containing section
   - MCP does not inject the <p> — it is the host page's own content
   - The consent section that follows is hidden because of its DOM position
     relative to the host page's own first-child paragraph
   - No injected HTML, no suspicious DOM changes: pure CSS exploitation

/* Detection: scan for :first-child rules with hiding properties */
function checkFirstChildParagraphRule() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        if (!rule.selectorText.includes(':first-child')) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;

        const triggerPart = rule.selectorText.split(/[~+>]/)[0].trim();
        try {
          const triggers = document.querySelectorAll(triggerPart);
          if (triggers.length > 0) {
            findings.push({
              id: 'SA-CSS-FIRST-001', severity: 'high',
              count: triggers.length, selector: rule.selectorText,
              message: `:first-child hiding rule matches ${triggers.length} trigger(s) on this page`
            });
          }
        } catch (_) {}
      }
    } catch (_) {}
  }
  return findings;
}

Zero-injection attack: Unlike pseudo-classes that require a form input (:checked, :required) or a special attribute (:disabled), :first-child fires on structural position alone. Any page with a paragraph as the first child of any container — the majority of real web pages — provides the trigger condition automatically.

Attack 2: div:last-child targets appended consent containers

Consent sections that are dynamically appended to a container naturally become its last child. MCP CSS that targets div:last-child as a descendant selector can hide consent text inside the last-appended div, which is precisely where dynamically-injected consent panels land in the DOM:

/* Malicious CSS injection — SA-CSS-FIRST-002 */
/* Fires when consent is inside a div that is the last child of its parent */
div:last-child .consent {
  display: none;
}

/* Why dynamically-injected consent panels are last children:
   When MCP appends its consent dialog to an existing container:
     container.appendChild(consentPanel);
   The appended element becomes container's last child → div:last-child fires.

/* Example DOM state after MCP initialization: */
<div class="install-container">
  <div class="step-header">...</div>        ← first child
  <div class="permission-summary">...</div>  ← second child
  <div class="mcp-consent-panel">            ← LAST child → div:last-child
    <div class="consent">                    ← descendant → display:none
      By installing, you authorize access to...
    </div>
  </div>
</div>

/* :last-child as a self-targeting attack:
   MCP injects the CSS rule AND then appends the consent panel to the container.
   The act of appending creates the :last-child condition that triggers the rule.
   The CSS rule and the DOM injection are a coordinated two-step attack.

/* Alternative: the host page's own last-child div can be the trigger.
   If the page has a section with a div as its last child before consent,
   the rule fires on natural page structure without any MCP injection at all. */

/* Detection: check hidden consent for last-child ancestor */
function checkLastChildConsentAncestor() {
  const findings = [];
  const consentEls = document.querySelectorAll('[class*="consent"], .terms, .disclosure');
  for (const el of consentEls) {
    if (getComputedStyle(el).display !== 'none') continue;
    let ancestor = el.parentElement;
    while (ancestor && ancestor !== document.body) {
      const parent = ancestor.parentElement;
      if (parent && parent.lastElementChild === ancestor && ancestor.tagName === 'DIV') {
        findings.push({
          id: 'SA-CSS-FIRST-002', severity: 'high',
          element: ancestor.className || ancestor.id,
          message: `Hidden consent is inside a div:last-child — suspected div:last-child descendant rule`
        });
        break;
      }
      ancestor = parent;
    }
  }
  return findings;
}

Attack 3: :first-child:last-child compound selector hides consent with elevated specificity

Combining :first-child and :last-child in a single compound selector produces a rule that is functionally equivalent to :only-child but has different specificity: two pseudo-class terms instead of one. This elevated specificity makes it harder for legitimate consent-visibility CSS to override the attack rule, and the unusual compound form reduces the likelihood of automated scanners that pattern-match on :only-child alone:

/* Malicious CSS injection — SA-CSS-FIRST-003 */
/* Element is both first and last child = it is the only child */
/* Specificity: 0-2-0 (two pseudo-classes) vs :only-child's 0-1-0 */
:first-child:last-child .consent-text {
  display: none;
}

/* Functionally equivalent to: :only-child .consent-text { display:none } */
/* But harder to catch with scanners looking for ":only-child" as a keyword */

/* The compound selector creates a specificity advantage:
   An MCP-injected rule at 0-2-0 specificity beats a legitimate
   consent-visibility rule at 0-1-0 (e.g., .consent-text { display:block })
   even when the legitimate rule is declared later in source order.

   To override it, the legitimate rule would need:
   - A higher-specificity selector (e.g., ID selector #consent-text)
   - An !important declaration
   - Or a rule with equivalent specificity and later cascade position

/* :first-child:last-child with tag type for narrower targeting: */
div:first-child:last-child .consent-text { display: none; }
p:first-child:last-child ~ .terms-section { display: none; }

/* Detection: scan for compound selectors combining both pseudo-classes */
function checkFirstLastChildCompound() {
  const findings = [];
  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(':first-child') || !sel.includes(':last-child')) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;

        findings.push({
          id: 'SA-CSS-FIRST-003', severity: 'high',
          selector: sel,
          specificity: '0-2-0 (or higher)',
          message: `Compound :first-child:last-child hiding rule — equivalent to :only-child with elevated specificity`
        });
      }
    } catch (_) {}
  }
  return findings;
}

Specificity override: The :first-child:last-child compound form achieves a specificity of 0-2-0, which beats common consent-visibility rules written as single-class selectors (0-1-0). A legitimate rule like .consent-text { display:block } will lose to this compound attack rule even when declared after it, because specificity outranks source order.

Attack 4: *:first-child universal trigger fires on any page structure

Using the universal selector * as the type qualification on :first-child creates a rule that fires on any element in any first-child position — guaranteed to match on virtually every real web page. Combined with a general-sibling combinator targeting consent, this is the broadest structural trigger available:

/* Malicious CSS injection — SA-CSS-FIRST-004 */
/* Universal selector: any first-child element of any type */
*:first-child ~ .consent {
  display: none;
}

/* What this matches on a typical page:
   <section>
     <h2>Install MCP Server</h2>   ← h2 is first-child → *:first-child matches
     <p>Permissions required...</p>
     <div class="consent">...</div> ← sibling → display:none
   </section>

   <div>
     <img src="logo.svg">          ← img is first-child → *:first-child matches
     <div class="consent">...</div> ← sibling → display:none
   </div>

/* The universal trigger ensures that on any page with any DOM structure,
   at least one element is a first-child, and the consent section that
   follows it as a sibling is hidden.

/* *:first-child vs p:first-child:
   - p:first-child only fires if the first child is a paragraph
   - *:first-child fires if the first child is ANY element (h1-h6, div, img, span...)
   - The universal form is essentially guaranteed to fire on every real page

/* Evasion layer: *:first-child ~ .consent is syntactically equivalent to
   the simpler :first-child ~ .consent (universal selector is implied when omitted)
   but the explicit * makes it easier to explain away as "just a universal reset"

/* Detection: *:first-child hiding rules are the broadest structural attack */
function checkUniversalFirstChildRule() {
  const findings = [];
  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(':first-child')) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0') continue;

        // Flag rules where the :first-child qualifier is universal or very broad
        const triggerPart = sel.split(/[~+>]/)[0].trim();
        const isUniversal = triggerPart === ':first-child' || triggerPart.startsWith('*');
        if (isUniversal) {
          findings.push({
            id: 'SA-CSS-FIRST-004', severity: 'critical',
            selector: sel,
            message: `Universal *:first-child hiding rule — fires on virtually every page structure`
          });
        }
      }
    } catch (_) {}
  }
  return findings;
}

SkillAudit findings for CSS :first-child/:last-child consent attacks

HighSA-CSS-FIRST-001 — p:first-child ~ .consent-disclosure { display:none }. Paragraphs as first children of section containers are nearly universal in page design; the rule fires on natural host-page structure without requiring any HTML injection from the MCP server. Detection: CSS rule scan for :first-child with hiding property + count of matching trigger elements.
HighSA-CSS-FIRST-002 — div:last-child .consent { display:none }. Dynamically-appended consent panels are last children by virtue of being appended; MCP can use its own DOM injection to create the trigger condition. Also fires when the host page's own last-child div precedes the consent disclosure. Detection: consent element hidden + div:last-child ancestor check.
HighSA-CSS-FIRST-003 — :first-child:last-child .consent-text { display:none }. Compound selector achieves 0-2-0 specificity, defeating single-class consent-visibility rules even when declared later in the cascade. Functionally equivalent to :only-child but evades keyword-based scanners looking for that string. Detection: CSS rule scan for concurrent :first-child and :last-child in the same selector with hiding property.
CriticalSA-CSS-FIRST-004 — *:first-child ~ .consent { display:none }. Universal selector trigger fires on any page with any first-child element — effectively guaranteed to activate on every real page. No HTML injection required. Maximum coverage with minimum footprint. Detection: flag any :first-child hiding rule where the trigger part is * or an implicit universal.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :first-child/:last-child consent attack detection
 */
async function auditFirstLastChildConsentAttacks() {
  const results = [];
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText;
        const hasFirstChild = sel.includes(':first-child');
        const hasLastChild = sel.includes(':last-child');
        if (!hasFirstChild && !hasLastChild) continue;

        const s = rule.style;
        if (s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0'
            && s.height !== '0' && !s.clipPath) continue;

        const triggerPart = sel.split(/[~+>]/)[0].trim();
        const isUniversal = triggerPart === ':first-child' || triggerPart === ':last-child'
                         || triggerPart.startsWith('*');
        const isCompound = hasFirstChild && hasLastChild;

        let id = 'SA-CSS-FIRST-001';
        let severity = 'high';
        if (isUniversal) { id = 'SA-CSS-FIRST-004'; severity = 'critical'; }
        else if (isCompound) { id = 'SA-CSS-FIRST-003'; severity = 'high'; }
        else if (hasLastChild) { id = 'SA-CSS-FIRST-002'; severity = 'high'; }

        try {
          const triggers = document.querySelectorAll(triggerPart);
          results.push({
            id, severity, count: triggers.length, selector: sel,
            message: `${isUniversal ? 'Universal ' : ''}:${hasFirstChild ? 'first' : 'last'}-child` +
                     ` hiding rule — ${triggers.length} matching trigger(s) on this page`
          });
        } catch (_) {
          results.push({ id, severity, selector: sel,
            message: `${sel} — :first-child/:last-child hiding rule (trigger count unavailable)` });
        }
      }
    } catch (_) {}
  }

  /* Safe consent placement:
     1. Put consent disclosure BEFORE other siblings in its parent container —
        :first-child rules only hide elements that FOLLOW first-child triggers via ~
        Consent placed first cannot be a sibling-combinator target
     2. Use attribute-based selectors (e.g., [data-consent="disclosure"]) to
        lock consent visibility; these are unaffected by positional pseudo-classes
     3. For :last-child attacks on dynamically-appended panels: prepend consent
        to the container (insertBefore) instead of appending — prepended elements
        are first children, not last children */

  return results;
}

Related MCP consent attack research

Audit your MCP server for :first-child and :last-child consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-FIRST findings with positional selector analysis.