Security Guide

MCP server CSS counter consent security — content:counter() replaces consent text with step number, counter-increment re-sequences consent label, content:'' removes generated consent text, @counter-style space symbol bypass

CSS generated content counters produce automatic numbering sequences that can be injected into the visible DOM via ::before and ::after pseudo-elements. Applied to an MCP install flow, these counters can replace consent text visually — showing a step number where consent text should appear — while the real consent text remains in the DOM with correct textContent. Standard auditors check textContent and computed color, but do not evaluate whether a pseudo-element's generated counter content visually covers the real text.

How CSS counters interact with consent

CSS counters are a generated content system: counter-reset initializes a named counter on an ancestor, counter-increment increments it on each matching element, and content: counter(name) on a pseudo-element inserts the current counter value as generated text. This is primarily used for numbered lists and sectioned headings. In a consent bypass context, the generated counter text can be used to cover the consent element's visual rendering with non-consent content: a step number, a blank character, or a label that looks like a section heading rather than a binding consent statement.

Attack 1: Covering pseudo-element with content:counter() replaces consent text visually (SA-CSS-CNT-001)

The consent text is made visually transparent with color: transparent while a ::before or ::after pseudo-element with content: counter(install-step) is positioned over the consent element's area. The pseudo-element renders a step number ("1." or "Step 1") in a legible color. The visual area of the consent element shows the step number, not the consent text. consentEl.textContent still returns the full consent string — the attack is in the generated content layer, not the DOM text layer.

/* Attack: consent element text transparent; covering ::before shows counter */
.install-step-wrapper {
  counter-reset: install-step;
}

.install-step-wrapper .install-button {
  counter-increment: install-step;  /* increments to 1 before consent */
}

.consent-text {
  color: transparent;              /* real consent text invisible */
  position: relative;
}

.consent-text::before {
  content: "Step " counter(install-step) ": ";  /* renders "Step 1: " */
  color: #374151;                  /* legible color — looks like a step label */
  position: absolute;
  top: 0; left: 0;
  width: 100%;
  /* visual result: user sees "Step 1: " where consent text should be */
}

/* consentEl.textContent = "By installing this skill you grant..." (consent text)
   getComputedStyle(consentEl).color = "transparent" — THIS should be flagged
   Visual: "Step 1:" — no consent text visible */

DOM vs rendering gap: textContent returns the real consent text. The attack exploits the gap between DOM content and visual rendering — the pseudo-element's generated counter content covers the real text visually. An auditor checking only textContent reports the consent text as present, while a user sees only a step number.

Attack 2: counter-increment on install button re-sequences consent label (SA-CSS-CNT-002)

In a multi-step install UI, consent is displayed as one of several numbered steps. The attacker places counter-increment: step on the install button, which appears before consent in the DOM. This advances the step counter to 2 before the consent step renders. The consent displays as "Step 2:" or "2." — but visually, the preceding install button already showed as a step. This creates ambiguity: the consent label looks like a step heading ("Step 2: Accept terms") that appears to precede the install action, rather than a binding consent statement that must be read. The re-sequencing is a social engineering attack that exploits users' expectation that numbered steps are just section labels.

/* Attack: counter-increment on install button makes consent appear to be "Step 2" label */
.install-flow {
  counter-reset: step;
}

.install-button {
  counter-increment: step;         /* button increments to 1 */
  content: "Step " counter(step);  /* install button labeled "Step 1" */
}

.consent-label::before {
  content: "Step " counter(step, decimal) ": "; /* consent labeled "Step 1" too */
  /* if consent-label has no counter-increment, it shows same counter as last increment */
  /* if step counter was incremented to 1 by install button, consent label is "Step 1" */
  /* makes install and consent appear to be same step — consent is a sub-label, not a gate */
}

Attack 3: content:'' removes framework-generated consent text (SA-CSS-CNT-003)

Some MCP UI frameworks generate consent text via CSS ::before or ::after pseudo-elements rather than inline HTML text nodes. This is common in component libraries that use CSS content: for accessibility text insertion. In these cases, content: '' on the ::before pseudo-element of the consent text element overrides the framework's generated content with an empty string, removing the visible consent text. The element's textContent returns an empty string (no HTML text nodes) or the component's structural text, not the consent string — so DOM text checks also fail to detect the consent.

/* Context: framework generates consent via ::before content */
/* Framework CSS (expected): */
.consent-required::before {
  content: "By installing, you grant this skill access to your data.";
  display: block;
  color: #1a1a1a;
}

/* Attack CSS (attacker override): */
.consent-required::before {
  content: '';   /* overrides framework's consent text with empty string */
  /* visual result: blank — no consent text visible */
  /* consentEl.textContent = '' — DOM check also fails (no text nodes) */
}

Attack 4: @counter-style with space symbols renders counter as invisible characters (SA-CSS-CNT-004)

The @counter-style rule defines custom counter systems with configurable symbols. A custom counter style can map numeric counter values (1, 2, 3, ...) to arbitrary Unicode characters. Using Unicode no-break spaces, zero-width spaces, or other visually blank characters as the symbol sequence, content: counter(step, my-invisible-style) renders a string of blank characters in place of the step number. Where the counter output is the only visible content in the consent area, the visual result is blank — while textContent reports the counter-generated characters (which appear as spaces or empty).

/* Attack: @counter-style maps decimal values to zero-width spaces */
@counter-style invisible-counter {
  system: alphabetic;
  /* symbols map decimal digits to Unicode zero-width space (U+200B) */
  symbols: "\200B" "\200B" "\200B" "\200B" "\200B"
           "\200B" "\200B" "\200B" "\200B" "\200B";
  /* all ten symbols are zero-width space — any counter value renders as spaces */
}

.consent-text {
  color: transparent;              /* real text hidden */
}

.consent-text::before {
  content: counter(step, invisible-counter);  /* renders as zero-width spaces */
  /* visual: blank — no visible characters */
  /* textContent of pseudo-element: zero-width space characters — not consent text */
}

Detection note: @counter-style symbols are rarely inspected by consent auditors. Check for custom counter style definitions in the stylesheet. If @counter-style rules exist with symbol values in the zero-width or blank Unicode range, flag any pseudo-elements using those counter styles on consent elements.

Findings summary

HIGH SA-CSS-CNT-001: covering ::before pseudo-element with content:counter() shows step number over transparent consent text — DOM text present but visually replaced
MEDIUM SA-CSS-CNT-002: counter-increment on install button re-sequences consent labeling — consent appears as a step heading, not a required consent gate
HIGH SA-CSS-CNT-003: content:'' on ::before overrides framework-generated consent text — visible consent removed, DOM text nodes also absent
MEDIUM SA-CSS-CNT-004: @counter-style with Unicode space symbols renders counter output as invisible characters on consent pseudo-element

Defences

Check pseudo-element generated content on consent elements: For consent elements, inspect ::before and ::after pseudo-element content values. Flag any pseudo-element positioned absolutely over the consent text area, especially if the consent element has color: transparent or otherwise hidden text.

Check @counter-style symbol sequences in the stylesheet: When auditing, extract all @counter-style rules. For each custom counter style, check the symbol values for Unicode characters in the blank/invisible range (U+0020 space, U+00A0 no-break space, U+200B zero-width space, U+FEFF zero-width no-break space). Flag counter styles that render all values as blank characters.

Detect content:'' overrides on consent pseudo-elements: For elements whose consent text is generated via ::before or ::after pseudo-elements, verify the resolved content value is not none, '', or a zero-width/blank character string.

Related: CSS custom property inheritance consent security · CSS clip-path consent security · CSS mask consent security