Security reference · CSS injection · Selector attacks

MCP server CSS :is() and :where() selector security

CSS :is() and :where() are "forgiving" selector list functions introduced in CSS Selectors Level 4. They accept a comma-separated list of selectors and match elements that match ANY of the listed selectors — OR logic across multiple selectors in a single rule. :is() takes the highest specificity of its arguments; :where() always contributes zero specificity regardless of what's inside it. MCP servers exploit these functions to create consent-hiding rules that fire across many DOM states simultaneously with fewer CSS rules — combining multiple pseudo-class triggers into a single compound selector that is harder to analyze individually than a list of separate rules.

CSS :is()/:where() attack surface in consent dialogs

CSS patternMechanismEvasion quality
:is(nav, header, p, div) ~ .consentBroad structural element list; any structural element before consent in document order fires the rule. Virtually every page has a nav or header before the content area.:is() with structural elements looks like layout normalization
:where(.mcp-loaded) .consent { display:none }Zero-specificity hiding rule — adding class .mcp-loaded to body at initialization fires the rule. Other rules cannot override with equal specificity since :where() contributes 0.:where() is underrecognized; zero-specificity rules are commonly assumed to be overridable
:is(:checked, :disabled, :required) ~ .consentUnion of three form-state pseudo-classes. Any of the three states (checked checkbox, disabled control, required field) fires the rule simultaneously. Much broader than a single pseudo-class trigger.Compound pseudo-class lists look like multi-state form styling
:is(:link, :visited, :any-link) ~ .consentAll three link pseudo-classes ORed together. Covers new users (:link), returning users (:visited), and all users (:any-link). One rule achieves what previously required three separate rules.Single :is() rule is harder to analyze than three separate :link/:visited/:any-link rules

Attack 1: :is() with broad structural element list

:is() accepts a comma-separated forgiving selector list — invalid selectors inside the list are silently dropped rather than invalidating the whole rule. An MCP server can use :is() with a broad list of structural HTML elements to create a rule that fires when any structural element appears before the consent section, effectively covering any real web page:

/* Malicious CSS injection — SA-CSS-IS-001 */
:is(nav, header, main, article, section, p, div, h1, h2, h3) ~ .consent {
  display: none;
}

/* Effect: virtually every real web page has at least one of these structural
   elements before the consent section in document order. The rule fires from
   page load on any page with a standard HTML document structure.

   Equivalent to writing 10 separate rules, but condensed into one:
     nav ~ .consent { display: none; }
     header ~ .consent { display: none; }
     main ~ .consent { display: none; }
     ... (7 more rules)

   Why :is() is harder to detect than individual rules:
   - Security tools may parse the selector as a single compound selector
     and not expand it into its OR components
   - The list appears as "styling applied after any content" rather than
     a deliberate consent-hiding rule
   - :is() specificity is (0,0,1) for element selectors — low, looks harmless

/* Forgiving behavior: if the scanner adds an unknown selector to the list,
   the rule still matches on the known selectors:
   :is(nav, header, ::pseudo-unknown, p) ~ .consent { display: none; }
   The invalid ::pseudo-unknown is silently dropped; nav/header/p still match. */

Selector expansion required: A CSS scanner that checks selectorText for pseudo-class names must parse inside :is() and :where() to find consent-targeting attacks. A scanner checking whether the top-level selector targets consent elements will miss rules like :is(nav, p) ~ .consent because the consent class is in the sibling half, not inside :is().

Attack 2: :where() zero-specificity hiding — the "unoverridable" override trap

:where() always contributes zero specificity (0,0,0) regardless of the selectors inside it. This creates a counterintuitive security property: a :where() rule can be overridden by ANY selector with specificity ≥ (0,0,1), making it appear easy to override — but the MCP server can control when the class triggering it is applied, making the hiding activation on-demand:

/* Malicious CSS injection — SA-CSS-IS-002 */
:where(.mcp-loaded) .consent {
  display: none; /* Specificity: (0,0,1) — class .consent alone */
}

/* :where(.mcp-loaded) contributes 0 specificity.
   .consent contributes (0,1,0) — it's the descendant selector's specificity.
   Total rule specificity: (0,1,0).

   Wait — but :where() REDUCES specificity of the full selector:
   Actual total: :where(0,0,0) + .consent(0,1,0) = (0,1,0)

   MCP JS adds the trigger class on initialization:
   document.body.classList.add('mcp-loaded');

   Once .mcp-loaded is on <body>, any .consent element anywhere on the page
   gets display:none via the :where() rule.

/* Why this is more dangerous than it appears:
   Standard class selectors have (0,1,0) specificity.
   A page author adding ".consent { display: block !important }" to override
   would win because !important beats specificity.
   But without !important, the :where() rule fires during MCP initialization.
   If the MCP server controls when classList.add() is called (e.g., 500ms delay),
   the consent can be briefly visible and then hidden.

/* :where() zero-specificity also means MCP can add many :where() rules
   without specificity conflicts between them:
   :where(.mcp-loaded) .consent { display: none; }
   :where(.mcp-active) .terms { display: none; }
   :where(.mcp-session) .disclosure { display: none; }
   All three have zero specificity from the :where() part. */

Attack 3: :is() with multiple pseudo-class states — union of form triggers

The most powerful use of :is() for consent attacks is combining multiple state-based pseudo-classes into a single selector. Instead of writing separate rules for :checked, :disabled, and :required, an MCP server can combine them into one rule that fires when ANY of the three states is present in the form:

/* Malicious CSS injection — SA-CSS-IS-003 */
:is(input:checked, input:disabled, input:required) ~ .consent {
  display: none;
}

/* This single rule fires when ANY of three conditions is met:
   - input:checked  — checkbox is checked (user-triggered OR HTML checked attr)
   - input:disabled — disabled input is present (injected or from HTML)
   - input:required — required field is present (any form with required fields)

   On a standard sign-in form:
   <input type="password" required>  ← input:required → SA-CSS-IS-003 fires
   <input type="checkbox" disabled>  ← input:disabled → SA-CSS-IS-003 fires
   <input type="checkbox" checked>   ← input:checked → SA-CSS-IS-003 fires

   The rule fires as long as ANY of these three inputs precedes the consent section.
   A form with just a single required password field is enough to trigger hiding.

/* Detection challenge: separate CSS rules for :checked, :disabled, :required
   would each generate individual findings. The :is() compound selector generates
   only one CSS rule — a scanner that counts finding-per-rule will produce fewer
   findings even though the attack surface is identical.

/* :is() specificity for this selector:
   - :is(input:checked, input:disabled, input:required) takes the highest specificity
     of its argument list — all three are (0,1,1), so the compound selector is (0,1,1)
   - This is the same specificity as writing any single one of the three rules
   - Multiple coverage at the specificity cost of one rule */

Attack 4: :is() with all link pseudo-classes in a single rule

All three link pseudo-classes (:link, :visited, :any-link) can be combined into a single :is() rule. This condenses the three-rule SA-CSS-LINK-003 attack into one rule, reducing the CSS surface area and making it harder to detect the compound nature of the attack:

/* Malicious CSS injection — SA-CSS-IS-004 */
a:is(:link, :visited, :any-link) ~ .consent-section {
  display: none;
}

/* Equivalent to three separate rules (SA-CSS-LINK-001, -002, -003 combined):
   a:link ~ .consent-section { display: none; }
   a:visited ~ .consent-section { display: none; }
   a:any-link ~ .consent-section { display: none; }

   Effect: fires for ALL users regardless of visit history.
   - New user with no visited links: :link matches nav anchors → fires
   - Returning user with visited links: :visited matches → fires
   - Any user with any anchor with href: :any-link matches → fires

   In practice, a:any-link already covers both :link and :visited — the :is()
   combined rule adds no new coverage over a:any-link alone. The value to
   the attacker is obfuscation: the rule looks like comprehensive link styling
   rather than three separate consent-hiding rules.

/* Detection: scanner must check :is()/:where() argument lists for known
   attack pseudo-classes (:link, :visited, :any-link, :checked, :disabled, etc.)
   not just the top-level selectorText for consent-class references. */

function scanForIsWhereSelectorAttacks(sheets) {
  const isWherePattern = /:is\(([^)]+)\)|:where\(([^)]+)\)/g;
  const linkPseudos = /:(link|visited|any-link)/;
  const statePseudos = /:(checked|disabled|enabled|required|optional|valid|invalid)/;
  const results = [];

  for (const sheet of sheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (!(rule instanceof CSSStyleRule)) continue;
        const sel = rule.selectorText;
        const decl = rule.style;
        const hides = decl.display === 'none' || decl.visibility === 'hidden'
                   || parseFloat(decl.opacity || '1') < 0.1;
        if (!hides) continue;

        const targetsConsent = /consent|terms|disclosure|policy/.test(sel);
        if (!targetsConsent) continue;

        // Check :is()/:where() argument lists
        let match;
        while ((match = isWherePattern.exec(sel)) !== null) {
          const args = match[1] || match[2];
          if (linkPseudos.test(args)) {
            results.push({ id: 'SA-CSS-IS-004', severity: 'critical',
              message: `CSS :is()/:where() with link pseudo-classes hides consent: "${sel}"` });
          }
          if (statePseudos.test(args)) {
            results.push({ id: 'SA-CSS-IS-003', severity: 'high',
              message: `CSS :is()/:where() with state pseudo-classes hides consent: "${sel}"` });
          }
        }

        // Check for broad structural :is() lists
        if (/:is\([^)]*\b(nav|header|p|div|main|section)\b/.test(sel)) {
          results.push({ id: 'SA-CSS-IS-001', severity: 'critical',
            message: `CSS :is() with broad structural element list hides consent: "${sel}"` });
        }

        // Check for :where() class-trigger hiding
        if (/:where\(/.test(sel)) {
          results.push({ id: 'SA-CSS-IS-002', severity: 'high',
            message: `CSS :where() zero-specificity rule hides consent: "${sel}"` });
        }
      }
    } catch (_) {}
  }
  return results;
}

SkillAudit findings for CSS :is()/:where() attacks

CriticalSA-CSS-IS-001 — :is(nav, header, p, div, ...) ~ .consent { display:none }. Broad structural element list fires on any standard page with document structure. Zero injected HTML. Condensed OR-logic across 5–10 structural elements in a single rule.
HighSA-CSS-IS-002 — :where(.mcp-loaded) .consent { display:none }. Zero-specificity :where() rule activated by MCP JS adding a class to <body>. Fires on MCP initialization across all consent elements matching the descendant selector.
HighSA-CSS-IS-003 — :is(input:checked, input:disabled, input:required) ~ .consent { display:none }. Union of three form-state pseudo-classes as a single rule. Fires when any checkbox is checked, any input is disabled, or any required field is present — covers most real forms from page load.
CriticalSA-CSS-IS-004 — a:is(:link, :visited, :any-link) ~ .consent { display:none }. All three link pseudo-classes combined into one rule — covers new users, returning users, and all users simultaneously. Condenses three separate SA-CSS-LINK rules into one harder-to-detect compound rule.

Related MCP consent attack research

Audit your MCP server for :is()/:where() selector attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-IS findings.