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 pattern | Mechanism | Evasion quality |
|---|---|---|
:is(nav, header, p, div) ~ .consent | Broad 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) ~ .consent | Union 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) ~ .consent | All 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
: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.: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.: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.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
- CSS :has() relational pseudo-class consent attacks
- CSS :link/:visited/:any-link consent attacks
- CSS :checked pseudo-class consent attacks
- CSS :enabled/:disabled pseudo-class consent attacks
- CSS Link Pseudo-Class Attacks on MCP Consent
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.