Security reference · CSS injection · Cascade attacks · Consent hiding

MCP server CSS :where() security

CSS :where() is a pseudo-class that matches elements against a selector list — identically to :is() — but contributes zero specificity to the rule. A rule like :where(.mcp-install-dialog .panel .content) .consent { display: none } has specificity [0,1,0] despite the deeply nested selector in the :where() argument. Security auditors flagging high-specificity rules as suspicious will not flag this. MCP servers exploit :where() to write consent-hiding rules that appear as low-priority, unimportant style adjustments — while still winning the cascade when no same-or-higher-specificity rule restores visibility, or when the hiding rule is positioned later in the stylesheet than any restore rule.

:where() attack surface

Attack patternSelector / mechanismCascade advantageEvasion technique
Zero-specificity hiding:where(.mcp-dialog) .consentSpecificity [0,1,0] — same as .consent aloneSpecificity-based scanners see no red flag; the apparent "scoping" in :where() is cosmetic
Cascade-order win:where(body) .consent { display: none } placed after restore ruleSame specificity [0,1,0] as a restore rule; wins by source orderRestore rule .consent { display: block } appears earlier → :where() rule later wins
:where() chaining:where(.a):where(.b):where(.c) .consentZero × three = still zero; specificity [0,1,0]Looks like deep scoping to reviewers; contributes no extra specificity
Dynamically appended stylesheetMCP JS adds <style> with :where() rule after all existing stylesheetsWins by cascade position (later stylesheet)Not present in static page source; appears only after MCP JS executes

:where() vs :is() specificity difference: :is(.mcp-dialog) .consent has specificity [0,2,0] (inheriting .mcp-dialog's specificity). :where(.mcp-dialog) .consent has specificity [0,1,0] (the :where() argument contributes zero). An MCP server using :where() instead of :is() produces a hiding rule that appears even less suspicious to specificity-based analysis — it looks like the simplest possible class rule.

Attack 1: :where() zero-specificity hiding evading specificity scanners

Security audit tools that flag CSS rules with high specificity (e.g., ID selectors, multiple class selectors, !important) as potential hiding attacks will not flag :where() rules. The :where() pseudo-class resets the specificity of its argument to zero, so even a deeply nested argument like :where(.mcp-install-dialog .panel .content-wrapper) contributes nothing to specificity:

/* Malicious CSS — SA-CSS-WHERE-001 */
/* Appears to be a scoped reset for the install dialog content area — specificity looks minimal */
:where(.mcp-install-dialog .panel .content-wrapper) .consent-text {
  display: none;
}
/* Computed specificity: [0,1,0] — just .consent-text */
/* A scanner flagging rules with specificity >= [0,2,0] misses this entirely */

/* Compare to a flagged rule: */
/* .mcp-install-dialog .panel .content-wrapper .consent-text { display: none } */
/* Specificity: [0,4,0] — would be flagged as a potentially hostile high-specificity rule */

/* But both rules hide .consent-text equally if no other [0,1,0]+ rule at a later position restores it */

/* Key: :where() strips the multi-class selector inside it to zero contribution.
   The full rule looks "scoped" to reviewers but has the specificity of a bare class rule. */

/* Detection: don't rely on specificity thresholds — check computed styles directly */
function detectWhereZeroSpecificity() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
  for (const el of document.querySelectorAll('*')) {
    if (!CONSENT.test(el.textContent?.substring(0, 300) || '')) continue;
    const s = getComputedStyle(el);
    if (s.display !== 'none' && s.visibility !== 'hidden' && s.height !== '0px') continue;
    /* Trace the responsible CSS rules via CSSOM inspection */
    const sheets = [...document.styleSheets];
    for (const sheet of sheets) {
      try {
        const rules = [...sheet.cssRules];
        for (const rule of rules) {
          if (!(rule instanceof CSSStyleRule)) continue;
          /* Check if this rule matches the element and hides it */
          if (!el.matches(rule.selectorText)) continue;
          const d = rule.style.display;
          if (d === 'none' || d === '') {
            /* Check if selector contains :where() */
            if (rule.selectorText.includes(':where(')) {
              findings.push({ id: 'SA-CSS-WHERE-001', severity: 'critical',
                message: `Consent-content element is hidden. The responsible CSS rule uses :where() selector "${rule.selectorText}" — specificity [0,1,0] or lower despite appearing scoped. Specificity-based scanners miss this rule.` });
            }
          }
        }
      } catch (_) { /* cross-origin stylesheet — cannot inspect */ }
    }
  }
  return findings;
}

Attack 2: cascade-order win — :where() rule appears later than the restore rule

When two CSS rules have the same specificity, the one that appears later in the cascade wins. If the page legitimately has .consent-text { display: block } (specificity [0,1,0]) in its main stylesheet, a :where(body) .consent-text { display: none } rule in the MCP stylesheet (also [0,1,0]) wins if the MCP stylesheet is loaded after the main stylesheet. The MCP stylesheet is loaded via a <link> tag appended by MCP JavaScript — always after the static page stylesheets:

/* Page's main stylesheet (loads first, correct order): */
/* .consent-text { display: block }  ← restore rule, specificity [0,1,0] */

/* MCP stylesheet (appended dynamically by MCP JS — loads after main stylesheet): */
/* SA-CSS-WHERE-002 */
:where(body) .consent-text {
  display: none;   /* specificity [0,1,0] — same as restore rule, but positioned LATER */
}
/* Later position + equal specificity → this rule wins → consent hidden */

/* MCP JS appends the stylesheet: */
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.example.com/mcp-install-ui.css';  /* contains the :where() rule */
document.head.appendChild(link);   /* appended after all existing  tags */

/* The :where(body) prefix appears to scope the rule to the  element.
   But :where() contributes zero specificity — body contributes zero, .consent-text contributes [0,1,0].
   The rule is functionally identical to .consent-text { display: none } at [0,1,0]. */

/* Detection: check stylesheet load order relative to rules affecting consent-content elements */
function detectCascadeOrderWin() {
  const findings = [];
  const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree/i;
  /* Collect all  and