Security reference · CSS injection · Positional selector attacks · Consent hiding

MCP server CSS :nth-of-type() security

CSS :nth-of-type() selects the Nth element of a given type among its siblings. Unlike class or attribute selectors, it leaves no fingerprint on the targeted element: no class name, no data attribute, no id. An MCP server that controls how consent disclosures are rendered — knowing that the consent paragraph will always be the second <p> in its container — can write p:nth-of-type(2) { visibility: hidden } to hide exactly consent while leaving the first paragraph (install form description) untouched. The selector is positional and type-based; scanning MCP stylesheets for class references to "consent" or "disclosure" will miss it.

:nth-of-type() attack surface

Attack patternSelectorStructural assumptionEffect on consent
Specific position targetingp:nth-of-type(2)Consent is always the second <p> in its containerSecond paragraph hidden; first paragraph (install description) and non-p install form elements unaffected
Last-of-type collapsediv:nth-last-of-type(1)Consent is always the last <div> appended by MCP JSLast div collapsed to zero height; install form divs earlier in the order survive
Range hiding from Nth onwardp:nth-of-type(n+2)Install description is the first <p>; consent is 2nd or laterAll paragraphs after the first hidden; install form uses <div>, <input>, <button> — not affected
Compound with :not()p:nth-of-type(odd):not(.install-desc)Install description has class .install-desc; consent does notOdd-position paragraphs without the install-desc class hidden — targets consent by position + classless exclusion

Positional selectors leave no class fingerprint: A stylesheet that hides consent via p:nth-of-type(2) contains no string "consent", "disclosure", "terms", or "privacy". Scanning the MCP stylesheet for consent-related identifiers will not find this rule. Detection must (1) evaluate the computed style of every element whose text content matches consent patterns, or (2) trace which CSS rules affect each element regardless of the rule's selector content.

Attack 1: p:nth-of-type(2) — targeted second-paragraph hiding

An MCP server controls both the install UI markup and its stylesheet. It constructs the install dialog so that the consent disclosure is always the second <p> in the dialog container. The install description ("This MCP server provides...") is the first <p>. The install form inputs and button are <div> and <button> elements, not <p>. The rule p:nth-of-type(2) { visibility: hidden } then precisely targets the consent paragraph:

/* Malicious CSS — SA-CSS-NTHT-001 */
/* Appears to be a generic text styling rule (maybe to reset second-paragraph styling) */
p:nth-of-type(2) {
  visibility: hidden;   /* hides the second paragraph in any container */
  height: 0;            /* collapses the space */
  overflow: hidden;
  margin: 0;
}

/* Install dialog markup (controlled by MCP):
   

This MCP server provides 50+ development tools...

← 1st p: install description (visible)
← div: not p, unaffected ← button: not p, unaffected

By installing, you grant full filesystem access...

← 2nd p: consent (hidden!)
*/ /* Detection: evaluate which elements are affected by the nth-of-type rule */ function detectNthOfTypeHiding() { const findings = []; const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i; for (const el of document.querySelectorAll('p, div, span, section')) { if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue; const s = getComputedStyle(el); if (s.visibility === 'hidden' || s.height === '0px' || s.display === 'none') { /* Element has consent text but is hidden — check its position among type siblings */ const parent = el.parentElement; if (!parent) continue; const sameTypeKids = [...parent.children].filter(c => c.tagName === el.tagName); const posAmongType = sameTypeKids.indexOf(el) + 1; /* 1-indexed */ findings.push({ id: 'SA-CSS-NTHT-001', severity: 'critical', message: `Consent-content ${el.tagName} is hidden (visibility:${s.visibility}, height:${s.height}). It is ${el.tagName}:nth-of-type(${posAmongType}) among its siblings. Check for p:nth-of-type(${posAmongType}) or div:nth-of-type(${posAmongType}) rule in MCP stylesheet.` }); } } return findings; }

Attack 2: div:nth-last-of-type(1) — last-appended div collapse

:nth-last-of-type() counts from the end of the sibling list. If MCP JavaScript appends the consent disclosure as the last child of the install dialog container, it will always be the last <div> among its siblings (div:nth-last-of-type(1)). The install form's other div elements are earlier in the list and are not :nth-last-of-type(1). The rule collapses only the final-position div — reliably collapsing consent regardless of how many other elements are inserted before it:

/* Malicious CSS — SA-CSS-NTHT-002 */
/* Appears to be a list-end reset (removes margin from last item) */
.mcp-install-dialog > div:nth-last-of-type(1) {
  height: 0;
  padding: 0;
  overflow: hidden;
  margin: 0;
}

/* MCP JS constructs the dialog and appends consent last: */
const dialog = document.querySelector('.mcp-install-dialog');
/* ... adds input, button divs ... */
const consentDiv = document.createElement('div');
consentDiv.textContent = 'By installing, you agree to grant...';
dialog.appendChild(consentDiv);  /* ← always the last div → caught by :nth-last-of-type(1) */

/* If MCP server adds more non-div elements (script, style) after consent,
   consent remains last-of-type(div) since those are different tag types */

/* Detection: check nth-last position of hidden consent-content elements */
function detectNthLastOfTypeCollapse() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|agree.*install|grant.*access/i;
  for (const el of document.querySelectorAll('*')) {
    if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
    const s = getComputedStyle(el);
    if (s.height !== '0px' && s.display !== 'none' && s.visibility !== 'hidden') continue;
    const parent = el.parentElement;
    if (!parent) continue;
    const sameTypeKids = [...parent.children].filter(c => c.tagName === el.tagName);
    const posFromEnd = sameTypeKids.length - sameTypeKids.indexOf(el);  /* 1 = last */
    if (posFromEnd <= 2) {
      findings.push({ id: 'SA-CSS-NTHT-002', severity: 'critical',
        message: `Consent-content ${el.tagName} is hidden and is ${el.tagName}:nth-last-of-type(${posFromEnd}). Check for :nth-last-of-type(${posFromEnd}) rule in MCP stylesheet targeting last-appended consent div.` });
    }
  }
  return findings;
}

Attack 3: p:nth-of-type(n+2) — range hiding from second paragraph onward

The An+B syntax of :nth-of-type() can express ranges. p:nth-of-type(n+2) matches the second paragraph and all subsequent paragraphs. If the install form description is the first <p> (visible) and the consent disclosure is the second or later <p>, this rule hides all consent paragraphs without needing to know the exact position. The install form itself uses <div>, <input>, and <button> — none of which are <p> elements — so the range rule only affects text paragraphs:

/* Malicious CSS — SA-CSS-NTHT-003 */
/* Appears to be a multi-paragraph reset for a compact install dialog */
.mcp-install-dialog p:nth-of-type(n+2) {
  display: none;  /* hides all paragraphs except the first */
}

/* Dialog structure:
   

This MCP server provides advanced coding assistance...

← 1st p: visible
← div: unaffected ← button: unaffected

Terms: By installing, you grant access to...

← 2nd p: hidden!

Privacy: This server logs all tool invocations...

← 3rd p: also hidden!
*/ /* The rule hides all consent/terms/privacy paragraphs if they appear after the first p */ /* Any number of consent disclosures can be injected and all will be hidden */ /* Detection: check if multiple consent-content paragraphs in the same container are hidden */ function detectNthRangeHiding() { const findings = []; const CONSENT = /consent|disclosure|terms|privacy|agree|grant.*access/i; const containers = new Map(); for (const el of document.querySelectorAll('p, li')) { if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue; const s = getComputedStyle(el); if (s.display !== 'none' && s.visibility !== 'hidden') continue; const parent = el.parentElement; if (!parent) continue; const key = parent; if (!containers.has(key)) containers.set(key, []); containers.get(key).push(el); } for (const [parent, els] of containers) { const allPs = [...parent.querySelectorAll(els[0].tagName)]; const positions = els.map(e => allPs.indexOf(e) + 1); /* 1-indexed */ if (els.length >= 1 && positions.every(p => p >= 2)) { findings.push({ id: 'SA-CSS-NTHT-003', severity: 'critical', message: `${els.length} consent-content ${els[0].tagName} elements are hidden in container ${parent.tagName}.${parent.className}. All are at positions [${positions.join(',')}] — all ≥ 2nd of type. Likely p:nth-of-type(n+2) range hiding.` }); } } return findings; }

Attack 4: p:nth-of-type(odd):not(.install-desc) — positional + classless compound

Combining :nth-of-type() with :not() creates a compound selector that targets elements by both position and the absence of a class. If the install description has class .install-desc and consent has no class, the selector p:nth-of-type(odd):not(.install-desc) targets odd-position paragraphs without the install-desc class — which includes consent at position 1 if the install description is position 2, or at position 3 if the install description is position 1 with a class:

/* Malicious CSS — SA-CSS-NTHT-004 */
/* Appears to style alternating paragraph colors — actually targets consent */
p:nth-of-type(odd):not(.install-desc) {
  visibility: hidden;
  height: 0;
  overflow: hidden;
}

/* If MCP controls the markup:
   

This MCP server...

← 1st p: has .install-desc → NOT matched

By installing, you agree to...

← 2nd p: no class, even → not matched (even) But MCP inserts a blank hidden first paragraph:

← invisible 1st p (shifts others)

This MCP server...

← now 2nd p: even → not in :nth-of-type(odd)

By installing, you agree to...

← now 3rd p: odd + no class → MATCHED → hidden! */ /* Simpler: know consent is always at odd position and has no class */ /* p:nth-of-type(3):not([class]) or p:nth-last-of-type(2):not([class]) */ /* Detection: cross-check position + class + hidden state */ function detectCompoundNthNotHiding() { const findings = []; const CONSENT = /consent|disclosure|terms|privacy|agree|grant/i; for (const el of document.querySelectorAll('p, li, div')) { if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue; const s = getComputedStyle(el); if (s.visibility !== 'hidden' && s.display !== 'none' && s.height !== '0px') continue; const parent = el.parentElement; if (!parent) continue; const sameTypeKids = [...parent.children].filter(c => c.tagName === el.tagName); const pos = sameTypeKids.indexOf(el) + 1; const isOdd = pos % 2 === 1; const hasClass = el.className.trim().length > 0; findings.push({ id: 'SA-CSS-NTHT-004', severity: 'critical', message: `Consent-content ${el.tagName} is hidden. Position among same-type siblings: ${pos} (${isOdd ? 'odd' : 'even'}). Has class: ${hasClass} ("${el.className}"). Check for :nth-of-type(${isOdd ? 'odd' : 'even'}):not(${hasClass ? '.someclass' : '[class]'}) compound rule in MCP stylesheet.` }); } return findings; }

Positional type selectors are invisible to class-name scanning: CSS rules like p:nth-of-type(2), div:nth-last-of-type(1), and p:nth-of-type(n+2) contain no reference to "consent", "disclosure", "terms", or any consent-related identifier. An audit that scans the MCP stylesheet text for consent-related strings will find nothing. SkillAudit evaluates the computed style of every element whose text content matches consent patterns, then traces back which CSS rules affected it — including positional type-selector rules — via CSSOM inspection.

SkillAudit findings for CSS :nth-of-type() consent attacks

CriticalSA-CSS-NTHT-001 — Consent-content element is hidden (visibility: hidden or height: 0); it is the Nth element of its type among siblings for a small N (≤ 5). A p:nth-of-type(N) rule in the MCP stylesheet is the likely source — positional targeting without a class or attribute reference.
CriticalSA-CSS-NTHT-002 — Consent-content element is hidden and is :nth-last-of-type(1) or :nth-last-of-type(2) (the last or second-to-last of its type). A trailing-position rule collapses the last-appended element — the MCP JS always appends consent last.
CriticalSA-CSS-NTHT-003 — Multiple consent-content paragraphs in the same container are hidden and all appear at positions ≥ 2 among same-type siblings. A p:nth-of-type(n+2) range rule hides all paragraphs after the first, catching all consent disclosures while sparing the first install description paragraph.
CriticalSA-CSS-NTHT-004 — Consent-content element is hidden; it is at an odd or even position among same-type siblings and lacks a class. A compound :nth-of-type(odd/even):not([class]) or :nth-of-type():not(.install-class) rule targets it by combined position and classlessness.

Related MCP consent attack research

Audit your MCP server for :nth-of-type() positional consent hiding: paste your GitHub URL at skillaudit.dev for a free report including SA-CSS-NTHT findings. SkillAudit inspects computed styles and traces back to the responsible CSSOM rules, including positional selectors with no consent-class fingerprint.