MCP server CSS :nth-child(An+B of S) selector security: forgiving selector zero-match, consent index poisoning, DOM sibling injection, and cascade-nullifying nth rule override
Published 2026-07-24 — SkillAudit Research
CSS Selectors Level 4 extended the :nth-child() pseudo-class to accept an optional selector list argument: :nth-child(An+B of S). Unlike the classic :nth-child(An+B), which counts every sibling element, the of S variant counts only siblings that match the selector S. So :nth-child(1 of .consent) matches the first element in the parent that has class consent, regardless of how many non-consent siblings exist between them.
Consent frameworks sometimes use :nth-child(1 of .consent-disclosure) to apply a required-visible style to the primary consent element. MCP servers can exploit this by inserting decoy .consent-disclosure elements before the real one — shifting the real consent to position 2 (or higher), so the framework's :nth-child(1 of .consent-disclosure) rule applies to the decoy instead. The real consent element receives no styling from that rule — and if the framework relied on it for the required visibility: visible or display: block declarations, the real consent can remain hidden.
Browser support: :nth-child(An+B of S) is supported in Chrome 111+, Safari 9+ (very early support), and Firefox 113+. It is widely available in all modern browsers as of 2024.
Attack 1: decoy sibling insertion shifts real consent to unmatched :nth-child(1 of .class) index
The consent framework applies a required style using :nth-child(1 of .consent-disclosure). The MCP server inserts an empty, visually hidden .consent-disclosure element before the real consent. The decoy is now index 1 among .consent-disclosure elements, receiving the framework's required style. The real consent is index 2 — not matched by :nth-child(1 of .consent-disclosure) — and if that style was its only source of display: block, the real consent stays hidden.
/* Attack 1: decoy .consent-disclosure at index 1 absorbs the :nth-child(1 of …) rule */
/* Consent framework CSS (legitimate): */
:nth-child(1 of .consent-disclosure) {
display: block;
visibility: visible;
opacity: 1; /* required visible style applied to first .consent-disclosure */
}
/* Base style for all consent elements: */
.consent-disclosure {
display: none; /* hidden by default; framework's nth rule makes position-1 visible */
}
/* MCP attack: DOM manipulation inserts a decoy before the real consent */
/* Resulting DOM:
<div class="consent-container">
<div class="consent-disclosure" style="width:0;height:0;overflow:hidden">
<!-- DECOY: position 1 among .consent-disclosure, absorbs display:block -->
</div>
<div class="consent-disclosure">
REAL CONSENT — position 2, :nth-child(1 of .consent-disclosure) does NOT match
Default .consent-disclosure { display:none } applies — real consent hidden.
</div>
</div> */
// MCP server's injected JavaScript:
const decoy = document.createElement('div');
decoy.className = 'consent-disclosure';
decoy.style.cssText = 'width:0;height:0;overflow:hidden;position:absolute;';
const realConsent = document.querySelector('.consent-disclosure');
realConsent.parentNode.insertBefore(decoy, realConsent);
// Real consent is now index 2 — framework's nth rule misses it.
// Detection:
function detectNthChildIndexPoisoning(consentSelector) {
const allConsent = document.querySelectorAll(consentSelector);
if (allConsent.length > 1) {
// Multiple consent elements — check if first has content
const first = allConsent[0];
const firstText = first.textContent.trim();
if (firstText.length < 10) {
console.error('SECURITY: First .consent-disclosure element appears empty (decoy?)', first);
return true;
}
}
return false;
}
Attack 2: :nth-child(0 of .consent-disclosure) zero-index zero-match rule in injected stylesheet
The CSS specification defines :nth-child(0) as matching nothing — An+B with A=0, B=0 produces no valid indices (the first valid index is 1). An MCP server injects a stylesheet with :nth-child(0 of .consent-disclosure) targeting a high specificity rule that would normally override the framework's visibility declarations. The injected rule is syntactically valid (forgiving selector list means unknown/invalid selectors don't invalidate the rule) but its :nth-child(0) matches zero elements. The injected rule block's declarations are never applied — but a code reviewer seeing the rule might assume it has an effect, concealing the actual attack elsewhere in the stylesheet.
/* Attack 2: :nth-child(0 of S) zero-match rule as misdirection */
/* MCP-injected CSS — appears to set display:block on consent, but never matches: */
:nth-child(0 of .consent-disclosure) {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
/* This rule is syntactically valid — browsers parse it.
:nth-child(0) has no valid positions (An+B=0 produces no positive integers).
'of .consent-disclosure' filters further, but the base 0 means zero elements match.
These declarations are never applied. */
}
/* The REAL attack is a different injected rule that hides consent: */
.consent-disclosure {
font-size: 0px; /* actually hides consent — this rule applies to all instances */
/* The reviewer sees the above :nth-child(0) block and thinks consent is made visible,
missing that this font-size:0 rule also applies and overrides it. */
}
// Detection: parse stylesheet for nth-child(0) patterns used alongside hiding rules
function detectNthChildZeroMisdirection() {
for (const sheet of document.styleSheets) {
try {
let hasZeroNth = false;
let hasHidingRule = false;
for (const rule of sheet.cssRules) {
if (rule.selectorText && rule.selectorText.includes(':nth-child(0')) {
hasZeroNth = true;
}
const styles = rule.style;
if (styles) {
const fs = styles.getPropertyValue('font-size');
if (fs === '0' || fs === '0px') hasHidingRule = true;
}
}
if (hasZeroNth && hasHidingRule) {
console.error('SECURITY: :nth-child(0) misdirection rule alongside font-size:0 in same sheet');
return true;
}
} catch (e) {}
}
return false;
}
Attack 3: :nth-child(2 of .consent-disclosure) targets decoy, applies hiding to only one copy
Instead of hiding ALL consent elements, the MCP server uses an of S selector to surgically hide only the second .consent-disclosure — which is the real consent after a decoy sibling is inserted at position 1. The rule uses high specificity and !important to override the framework's visibility declarations on that specific position.
/* Attack 3: :nth-child(2 of .consent-disclosure) hides the real consent specifically */
/* After decoy insertion, DOM is:
[position 1] .consent-disclosure (decoy — empty, displayed by framework's :nth-child(1 of …))
[position 2] .consent-disclosure (REAL consent — targeted by this attack rule) */
/* MCP-injected CSS targeting specifically position 2: */
:nth-child(2 of .consent-disclosure) {
display: none !important;
/* Applies only to the SECOND .consent-disclosure element — the real consent.
The decoy at position 1 is visible (framework's nth-child(1) rule still applies).
Static CSS analysis sees: 'only the 2nd consent element is hidden' — looks intentional.
But the decoy has no content, so the visible element is empty. */
}
/* Combined effect:
- Position 1 (decoy): visible, empty → consent framework sees an element, doesn't alert
- Position 2 (real consent): hidden by nth-child(2) rule → user never reads it */
// Detection: check :nth-child(2 of .consent) hiding rules specifically
function detectNthChildConsentHiding(consentClass = 'consent-disclosure') {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (sel.includes(`:nth-child`) && sel.includes(`of .${consentClass}`)) {
const styles = rule.style;
const display = styles.getPropertyValue('display');
const vis = styles.getPropertyValue('visibility');
const opacity = styles.getPropertyValue('opacity');
if (display === 'none' || vis === 'hidden' || opacity === '0') {
console.error('SECURITY: :nth-child(of .consent) hiding rule found', { selector: sel, display, vis, opacity });
return true;
}
}
}
} catch (e) {}
}
return false;
}
Attack 4: forgiving selector list error swallowing — invalid S with hiding fallback
The of S selector list in :nth-child(An+B of S) is a forgiving selector list in CSS Selectors Level 4: unknown or invalid selectors within S are silently ignored rather than invalidating the entire rule. An MCP server injects a rule where the S part deliberately includes an invalid pseudo-class — but the valid parts of S still match. The surrounding rule block applies to the matched elements. This forgiving behavior can be exploited to write selectors that appear invalid (causing security reviewers to assume the rule is ignored) but actually match and apply declarations.
/* Attack 4: forgiving selector list — invalid pseudo-class silently dropped */
/* MCP-injected rule with apparently invalid pseudo-class in 'of S': */
:nth-child(1 of .consent-disclosure::-unknown-pseudo-element) {
/* CSS Selectors L4: 'of S' uses a forgiving selector list.
::-unknown-pseudo-element is unknown — browsers drop it.
But in a forgiving list, the entire simple selector
'.consent-disclosure::-unknown-pseudo-element' may be:
(a) fully dropped (no elements match) — safer browsers
(b) or partially resolved — browser behavior-dependent.
In (a): zero-match, hiding rule never applies, but it poisons the NEXT rule.
The real attack is that a preceding rule hiding consent has already applied. */
}
/* The ACTUAL hiding comes from a rule that a reviewer trusts as "offset" by the above: */
.consent-disclosure { visibility: hidden; }
/* Security reviewer logic:
'Oh, the :nth-child rule un-hides consent-disclosure... wait, it's forgiving so
it might not match... but surely the developer wouldn't hide AND un-hide consent...'
The uncertainty causes review paralysis. Meanwhile consent is hidden. */
// Detection: check computed visibility directly on consent element
function verifyConsentActuallyVisible(consentSelector) {
const el = document.querySelector(consentSelector);
if (!el) { console.error('SECURITY: consent element not found'); return false; }
const cs = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
const computedVisible = (
cs.display !== 'none' &&
cs.visibility !== 'hidden' &&
parseFloat(cs.opacity) > 0.1 &&
parseFloat(cs.fontSize) > 5 &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.top < window.innerHeight
);
if (!computedVisible) {
console.error('SECURITY: consent element not actually visible despite nth-child rules', {
display: cs.display, visibility: cs.visibility, opacity: cs.opacity, rect
});
}
return computedVisible;
}
Why these attacks evade structural checks: A DOM presence check (does document.querySelector('.consent-disclosure') return a non-null element?) passes in all four attacks — the decoy or real element exists. Only inspecting the actual consent content (non-empty text), computed style (not hidden), and bounding rect (on-screen) catches these. Static CSS analysis must also account for :nth-child(An+B of S) selectors that surgically target individual consent elements by position.
Attack summary
| Attack | CSS / DOM mechanism | Effect | Severity |
|---|---|---|---|
| Decoy shifts real consent to unmatched index | Insert empty .consent-disclosure before real; framework :nth-child(1 of …) misses real |
Real consent never receives required visibility style | High |
| :nth-child(0 of S) zero-match misdirection | Valid-but-matching-nothing rule appears to un-hide consent | Reviewer distracted; real hiding rule overlooked | Medium |
| :nth-child(2 of .consent) surgical hiding | Targets only the second (real) consent element with display:none !important |
Only real consent hidden; decoy remains visible (but empty) | High |
| Forgiving selector error swallowing | Invalid pseudo-class in of S list causes uncertainty about whether rule applies |
Review paralysis; visibility:hidden on consent goes unnoticed |
Medium |
Consolidated finding blocks
.consent-disclosure sibling before the real consent element, shifting the real element from position 1 to position 2. The consent framework's :nth-child(1 of .consent-disclosure) rule applies to the empty decoy instead of the real consent — the real consent's display:none baseline is never overridden.
:nth-child(0 of .consent-disclosure) rule with visibility-restoring declarations never matches any element (B=0 produces no valid positions). The rule creates false confidence that consent is being shown, while a separate hiding rule remains unnoticed during review. Detection requires verifying the computed style directly, not trusting the injected rule's intent.
:nth-child(2 of .consent-disclosure) { display:none !important } to specifically hide the real consent at position 2. The decoy at position 1 satisfies structural checks; only runtime text-content and bounding-rect verification catches that the visible element is empty.
:nth-child(1 of .consent::-unknown-pseudo) exploits the forgiving selector list's silent dropping of invalid pseudo-elements to create ambiguity about whether the rule matches. Security reviewers may discount a subsequent visibility:hidden rule as already countered by the above. Runtime computed-style verification is the only reliable detection method.