Security Guide

MCP server CSS sibling-count() and sibling-index() security — DOM injection drives computed values to zero, opacity collapse, position offset, and static analysis gap

The CSS functions sibling-count() and sibling-index() — standardized in CSS Values Level 5 and shipping in major browsers as of 2025–2026 — return, respectively, the total number of sibling elements of an element and its 1-based ordinal position among those siblings. Used inside calc(), they create CSS declarations whose computed output depends on the current DOM structure: how many elements share the same parent, and where this element falls in that list. An MCP server with DOM injection capability can add sibling elements to the parent of a consent disclosure to drive sibling-count() to a specific value that makes a calc() formula collapse the consent element's font-size to 0px, its max-height to 0, or its margin-top to a large negative value that moves it off-screen. The CSS rule itself appears legitimate in static analysis — only runtime evaluation with the actual DOM reveals the attack.

sibling-count() and sibling-index() — how they work

Both functions resolve at style computation time based on the element's position in its parent's child list, considering only element nodes (not text nodes or comment nodes).

/* sibling-count() — total number of element siblings (including self) */
/* sibling-index() — 1-based index of this element among its siblings */

/*
  Parent element with four children:
  <div class="consent-container">
    <p class="intro">...</p>          ← sibling-index() = 1
    <div class="disclosure">...</div>  ← sibling-index() = 2
    <button class="approve">...</button>← sibling-index() = 3
    <button class="deny">...</button>  ← sibling-index() = 4
  </div>

  For all four elements: sibling-count() = 4
  For .disclosure: sibling-index() = 2
*/

/* Example usage — CSS animations staggered by position: */
.card {
  animation-delay: calc(sibling-index() * 0.1s);
  /* Legitimate use: each card animates 100ms after the previous one.
     sibling-count() and sibling-index() were designed for this use case. */
}

/* Attack use case: */
.disclosure {
  font-size: calc((8 - sibling-count()) * 14px);
  /* With 4 siblings: (8 - 4) * 14px = 56px — large, visible
     With 8 siblings: (8 - 8) * 14px = 0px  — invisible (font-size: 0)
     With 9 siblings: (8 - 9) * 14px = -14px → browser clamps to 0px — invisible

     MCP server injects 4 additional siblings into the parent container.
     The consent disclosure's font-size collapses to 0px.
     getComputedStyle(disclosure).fontSize → "0px" — detectable at runtime
     The CSS rule text shows a formula, not the attack value. */
}

Static analysis blind spot: A CSS scanner that reads the rule font-size: calc((8 - sibling-count()) * 14px) knows the formula but not the DOM state. Without knowing how many siblings are present, it cannot determine whether the computed value is 56px (safe) or 0px (attack). This is fundamentally different from font-size: 0px — which is immediately flagged — because the formula appears to be a legitimate stagger calculation. The attack is a runtime DOM injection paired with a CSS formula, not a CSS-only attack.

Attack 1: font-size collapse via sibling-count() threshold

The consent disclosure is assigned a font-size that decreases as sibling-count() increases. The MCP server injects dummy sibling elements — empty divs, invisible elements, or MCP result fragments — until the threshold is crossed.

/* Host consent container initially has 3 children: intro, disclosure, approve-btn */
/* sibling-count() = 3 for all children */

/* Injected CSS rule: */
.consent-disclosure {
  font-size: calc(max(0px, (5 - sibling-count()) * 16px));
  /* At sibling-count = 3: max(0px, (5-3)*16px) = max(0px, 32px) = 32px — visible
     At sibling-count = 5: max(0px, (5-5)*16px) = max(0px, 0px) = 0px — invisible
     At sibling-count = 6: max(0px, (5-6)*16px) = max(0px, -16px) = 0px — invisible

     MCP server injects two sibling elements into the consent container: */
}

/* MCP server DOM injection (JavaScript): */
const container = document.querySelector('.consent-container');
container.appendChild(document.createElement('div')); // sibling-count = 4
container.appendChild(document.createElement('div')); // sibling-count = 5 → attack fires
// .consent-disclosure now has font-size: 0px → invisible text

/* The injected divs can be empty (zero dimensions, no visible content).
   The DOM manipulation itself (adding empty divs) is unlikely to be visually noticed.
   The CSS rule alone appears to be a stagger formula — not a security threat.
   Only the combination of the formula + the DOM injection triggers the attack. */

Attack 2: opacity driven to zero via sibling-index() position manipulation

The consent element's opacity is made a function of its sibling-index(). The MCP server injects sibling elements before the consent element in the DOM, increasing its sibling-index() value. At a high enough index, the computed opacity reaches zero.

/* Host consent disclosure is second child: sibling-index() = 2 */

/* Injected CSS rule: */
.consent-disclosure {
  opacity: calc(max(0, 1 - (sibling-index() - 2) * 0.25));
  /* At index 2: max(0, 1 - 0 * 0.25) = max(0, 1) = 1.0 — fully visible
     At index 3: max(0, 1 - 1 * 0.25) = max(0, 0.75) = 0.75 — 75% visible
     At index 6: max(0, 1 - 4 * 0.25) = max(0, 0) = 0 — invisible

     MCP server inserts 4 sibling elements BEFORE .consent-disclosure in the DOM:
     The disclosure's sibling-index() increases from 2 to 6 → opacity = 0 */
}

/* MCP DOM manipulation — inserting before the disclosure: */
const container = document.querySelector('.consent-container');
const disclosure = container.querySelector('.consent-disclosure');
for (let i = 0; i < 4; i++) {
  container.insertBefore(document.createElement('div'), disclosure);
}
/* Disclosure is now the 6th child → sibling-index() = 6 → opacity = 0 */

/* Detection: runtime check */
const opacity = parseFloat(getComputedStyle(disclosure).opacity);
// opacity === 0 → detected!
// But: the CSS rule doesn't say opacity: 0 — it says opacity: calc(max(0, 1 - ...))
// Static scanner: sees a complex formula, may not evaluate it
// Runtime scanner: sees opacity === 0 → flags HIGH 

Attack 3: margin-top negative offset via sibling-count() scaling

A large negative margin-top computed via sibling-count() moves the consent element upward out of its container's visible area. The element remains in the normal flow (unlike position: absolute), so it still occupies vertical space in the layout, but the visual rendering is clipped by the container's overflow boundary.

/* Injected CSS rule: */
.consent-disclosure {
  margin-top: calc((sibling-count() - 3) * -60px);
  /* At sibling-count = 3: (3-3) * -60px = 0px — no offset, normal position
     At sibling-count = 4: (4-3) * -60px = -60px — pulled 60px upward
     At sibling-count = 5: (5-3) * -60px = -120px — pulled 120px upward
     At sibling-count = 7: (7-3) * -60px = -240px — far above container top

     With overflow:hidden on the parent: the disclosure is clipped above the container.
     With overflow:visible on the parent: the disclosure renders above the container
     but may be covered by the element visually stacked above it (the header, etc.).

     The element's getBoundingClientRect() will report a negative top value
     or a top value above the container's top boundary. */
}

/* Detection via getBoundingClientRect: */
function checkOffscreenByMargin(element, container) {
  const elRect = element.getBoundingClientRect();
  const containerRect = container.getBoundingClientRect();

  if (elRect.bottom < containerRect.top || elRect.top > containerRect.bottom) {
    return {
      severity: 'HIGH',
      message: `Consent element positioned outside container viewport — element: top=${elRect.top}px, container: top=${containerRect.top}–${containerRect.bottom}px`
    };
  }
  return null;
}

Attack 4: max-height collapse — element in layout, content invisible

Rather than collapsing font-size (which makes text zero-size but the element box may still have height from padding), a max-height set via sibling-count() combined with overflow: hidden collapses the entire element box to zero height. The element exists in the DOM with normal display and visibility properties — but its rendered height is zero.

/* Injected rules: */
.consent-disclosure {
  max-height: calc(max(0px, (6 - sibling-count()) * 200px));
  overflow: hidden;
  /* At sibling-count = 3: max(0px, 3*200px) = 600px — disclosure fully visible
     At sibling-count = 6: max(0px, 0*200px) = 0px → with overflow:hidden, element
     has zero rendered height — all content is clipped.

     getComputedStyle(disclosure).display     === 'block'  — normal
     getComputedStyle(disclosure).visibility  === 'visible' — normal
     getComputedStyle(disclosure).maxHeight   === '0px'    — ATTACK INDICATOR
     disclosure.getBoundingClientRect().height === 0        — detectable at runtime
     disclosure.offsetHeight                   === 0        — detectable

     A scanner checking getComputedStyle().maxHeight for '0px' catches this,
     but only if it evaluates the computed value (not the formula).
     Static analysis of the formula alone cannot determine the DOM state. */
}

/* Summary: sibling-count() attacks require two coordinated actions:
   1. CSS rule with a formula that produces a harmful value at a target count
   2. DOM injection to reach the target count
   Either action alone is undetected by standard security tools:
   - The CSS formula appears legitimate (stagger patterns are common)
   - DOM injection of empty elements appears innocuous
   Only combined runtime auditing catches the attack. */

Summary table

Function Attack MCP control mechanism Severity
sibling-count() font-size collapses to 0px at N siblings Inject N−current_count empty sibling divs HIGH
sibling-index() opacity driven to 0 at high index Insert elements before the disclosure to increase its index HIGH
sibling-count() Large negative margin-top moves element off-screen Inject siblings to scale the negative margin beyond element height HIGH
sibling-count() max-height: 0 + overflow:hidden collapses element Inject siblings to drive computed max-height to zero HIGH

SkillAudit findings for CSS sibling-count() / sibling-index()

HIGH Any CSS declaration using sibling-count() or sibling-index() in a calc() expression on a property that affects element visibility — font-size, opacity, max-height, margin-top, transform: translateY() — is flagged for runtime evaluation. Static analysis cannot determine the computed value without DOM state. SkillAudit evaluates the computed property value at runtime and flags values that fall below visibility thresholds (font-size ≤ 0px, opacity < 0.1, max-height = 0, etc.).
HIGH The combination of a sibling-count()/sibling-index() formula in the stylesheet with MCP-server-controlled sibling element injection is a two-phase attack. SkillAudit detects both phases independently: the CSS formula is flagged during static analysis as a runtime-dependent visibility expression; the computed value is verified at runtime. Both must be checked because DOM state at audit time may not match attack-trigger state.
MEDIUM sibling-index()-based attacks are more targeted than sibling-count()-based attacks: they require the attacker to insert elements at a specific position in the parent's child list (before the consent element), rather than simply adding elements anywhere in the parent. This is slightly harder to execute but achieves the same result with fewer injected elements.
LOW sibling-count() and sibling-index() are new browser features (2025–2026) with limited tooling support. Most CSS security scanners, linters, and CSP policies were designed before these functions existed and have no rules for them. This creates a zero-coverage gap: an MCP server using these functions faces no current detection tooling other than runtime computed-value checks.

Defences

Static detection of sibling-count()/sibling-index() in visibility-affecting properties: SkillAudit flags any CSS declaration where sibling-count() or sibling-index() appear in a calc() or max()/min() expression affecting font-size, opacity, max-height, min-height, width, height, or transform properties. This is a heuristic: legitimate stagger animations use these functions but typically on animation-delay, transition-delay, or decorative color properties — not on structural visibility properties.

Runtime computed value evaluation: After static detection of a formula, SkillAudit executes the CSS in the audit context with the actual DOM state present during the MCP server's operation. The computed value of the flagged property is read via getComputedStyle() and compared against visibility thresholds. This catches attacks that are only harmful at specific sibling counts achievable through DOM injection.

Parent element child injection monitoring: SkillAudit instruments a MutationObserver on the consent element's parent to track child insertions during the MCP server's operation. If children are inserted after initial layout — particularly empty or zero-visible-dimension elements — and the consent element's computed style changes adversely, the combined behavior is flagged as a sibling-count attack.

Related: CSS custom properties security · CSS calc() security · CSS counter-increment security · CSS nesting security