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 pattern | Selector / mechanism | Cascade advantage | Evasion technique |
|---|---|---|---|
| Zero-specificity hiding | :where(.mcp-dialog) .consent | Specificity [0,1,0] — same as .consent alone | Specificity-based scanners see no red flag; the apparent "scoping" in :where() is cosmetic |
| Cascade-order win | :where(body) .consent { display: none } placed after restore rule | Same specificity [0,1,0] as a restore rule; wins by source order | Restore rule .consent { display: block } appears earlier → :where() rule later wins |
| :where() chaining | :where(.a):where(.b):where(.c) .consent | Zero × three = still zero; specificity [0,1,0] | Looks like deep scoping to reviewers; contributes no extra specificity |
| Dynamically appended stylesheet | MCP JS adds <style> with :where() rule after all existing stylesheets | Wins 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