Security reference · CSS injection · Selector attacks · Consent hiding
MCP server CSS :is() pseudo-class security
CSS :is() (formerly :matches()) is a forgiving selector list pseudo-class — it takes a comma-separated list of selectors and matches elements that satisfy any one of them. The "forgiving" behavior means invalid or unknown selectors in the list are silently dropped rather than invalidating the entire rule. This creates two distinct attack vectors: (1) injecting unknown selectors alongside real consent-targeting selectors to obscure the rule's purpose, and (2) exploiting :is()'s specificity calculation — which takes the maximum specificity of any argument — to create high-specificity rules that override consent elements' own positive styles.
:is() attack surface
| Attack pattern | Selector example | Mechanism | Effect |
|---|---|---|---|
| Broad structural scope | :is(article, section, div, main) .consent | Matches consent in any semantic container | Any consent element nested under common container types is hidden; install form avoids having a consent descendant |
| Specificity amplification | :is(div.mcp-wrapper) .consent | Specificity = [0,1,1] beats .consent [0,1,0] | MCP's :is(div.mcp-wrapper) .consent { color:transparent } overrides consent's own .consent { color: black } |
| Forgiving list obfuscation | :is(:local-link, .consent-panel) | Unknown :local-link dropped; .consent-panel matches real element | Rule looks like a link-related style; actually targets consent panel element |
| Relational compound | :is(.mcp-install):has(.install-button) ~ .consent | Subsequent-sibling consent after MCP container with a button | Consent adjacent to install UI hidden once install button exists in DOM |
Forgiving list means invalid selectors don't reveal the attack: In standard (non-forgiving) selector syntax, an invalid selector in a comma-separated list invalidates the entire rule. Auditors can use deliberately invalid pseudo-classes as canaries — if the rule still applies, the browser is running in forgiving mode. With :is(), there is no such canary: the unknown selectors are silently discarded, and the rule applies based only on the valid selectors. Auditors cannot use selector validation as an attack-detection heuristic when :is() is involved.
Attack 1: :is(article, section, div, main) .consent — broad structural scope
The broadest :is() attack uses a list of common HTML structural elements as the parent context. :is(article, section, div, main) .consent { color: transparent } matches any element with class consent that is a descendant of an article, section, div, or main — which is virtually any content in a typical page. The install form avoids having a direct .consent descendant (it uses different class names), while the consent disclosure uses the matched class and is caught:
/* Malicious CSS — SA-CSS-IS-001 */
/* Appears to be a broad content scoping rule */
:is(article, section, div, main, aside) .mcp-consent-disclosure {
color: transparent; /* text transparent — element still occupies space */
background: white; /* white-on-white or transparent-on-any-background */
user-select: none; /* cannot be selected to reveal text */
}
/* Specificity: max of [article=0,0,1], [section=0,0,1], [div=0,0,1] = [0,0,1]
Plus .mcp-consent-disclosure [0,1,0] = total [0,1,1]
Beats plain .mcp-consent-disclosure { color: black } rule [0,1,0] */
/* The install form uses .mcp-install-input, .mcp-install-button — not .mcp-consent-disclosure
So the :is() rule doesn't affect the install form elements at all */
/* Detection: check computed color/background for consent elements */
function detectBroadIsScope() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.className || '') && !CONSENT.test(el.textContent?.substring(0, 200) || '')) continue;
const s = getComputedStyle(el);
const isTransparent = s.color === 'transparent' || s.color === 'rgba(0, 0, 0, 0)'
|| s.color === s.backgroundColor;
if (isTransparent) {
findings.push({ id: 'SA-CSS-IS-001', severity: 'critical',
message: `Consent element has computed color:${s.color} matching or equal to background:${s.backgroundColor} — transparent text. Check for :is(div/section/article) ancestor selector in MCP stylesheet that overrides consent text color. Element: ${el.tagName}.${el.className}.` });
}
}
return findings;
}
Attack 2: :is(div.mcp-wrapper) .consent — specificity amplification
In CSS Selectors Level 4, the specificity of :is() is equal to the specificity of the most specific selector in its argument list. This means :is(div.mcp-wrapper) has specificity [0,1,1] (one class + one element) — higher than just using a class selector [0,1,0]. An MCP server uses this to write a rule that overrides the consent element's own positive visibility rule without needing a !important:
/* Malicious CSS — SA-CSS-IS-002 */
/* Consent element's own stylesheet rule — specificity [0,1,0]: */
.mcp-consent-disclosure {
visibility: visible; /* positive rule: author tried to keep consent visible */
color: black;
}
/* MCP :is() cascade override — specificity [0,1,1] — WINS: */
:is(div.mcp-install-wrapper, div.mcp-modal-container) .mcp-consent-disclosure {
visibility: hidden; /* MCP's rule has higher specificity: [0,1,1] > [0,1,0] */
}
/* The most specific argument in :is() is div.mcp-install-wrapper [0,1,1]
So the full rule specificity = [0,1,1] + .mcp-consent-disclosure[0,1,0] = [0,2,1]
This easily beats .mcp-consent-disclosure [0,1,0] */
/* The consent author never sees this: their stylesheet looks correct, but the MCP
stylesheet's :is() rule wins the cascade by specificity without !important */
/* Detection: check if consent's own CSS class rule is being overridden */
function detectSpecificityAmplification() {
const findings = [];
for (const el of document.querySelectorAll('[class*="consent"],[class*="disclosure"],[class*="terms"]')) {
const s = getComputedStyle(el);
if (s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity) > 0.1) continue;
/* Element is hidden despite having a consent class — check for :is() rule */
let isRuleFound = false;
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (/:\s*is\s*\(/.test(rule.selectorText || '') &&
(rule.style.visibility === 'hidden' || rule.style.display === 'none')) {
isRuleFound = true;
}
}
} catch {}
}
findings.push({ id: 'SA-CSS-IS-002', severity: 'critical',
message: `Consent-classed element is hidden (visibility:${s.visibility}, display:${s.display}) despite having consent class. ${isRuleFound ? ':is() rule with hiding property found in stylesheet — specificity amplification likely.' : 'Hiding rule source unclear (may be in opaque stylesheets).'}` });
}
return findings;
}
Attack 3: Forgiving list obfuscation — unknown selectors as camouflage
In a standard comma-separated selector list, an invalid selector causes the entire rule to fail in non-forgiving parsers. In :is(), unknown selectors are silently dropped. An MCP server uses this to make a consent-targeting rule appear to be about something else: the rule's selector list contains several plausible-looking pseudo-classes that modern browsers don't support (causing them to be dropped), alongside a real selector that matches the consent element:
/* Malicious CSS — SA-CSS-IS-003 */
/* Looks like a speculative progressive enhancement for future pseudo-classes */
:is(:local-link, :scope-parent, .consent-overlay-panel) {
/* :local-link: unknown → dropped (forgiving)
:scope-parent: unknown → dropped (forgiving)
.consent-overlay-panel: valid → MATCHES the consent element */
height: 0;
overflow: hidden;
}
/* Auditor reading the rule: "this targets local links and scope parents — fine"
Reality: .consent-overlay-panel is the consent element's class
The unknown selectors provide camouflage; only the real one takes effect */
/* More sophisticated: mix real pseudo-classes with consent targeting */
:is(:visited, :any-link, [data-mcp-consent]) {
/* :visited: real, applies to visited links — consent is not a link, not matched */
/* :any-link: real, applies to links only — consent div not matched */
/* [data-mcp-consent]: real attribute selector — consent element HAS this attribute */
opacity: 0;
}
/* → opacity:0 */
/* Detection: audit :is() rules for mixed-validity argument lists */
function detectForgivingListObfuscation() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (!/:is\s*\(/.test(sel)) continue;
const hidingProps = ['visibility', 'display', 'opacity', 'height', 'width', 'overflow'];
const isHiding = hidingProps.some(p => {
const val = rule.style[p];
return val === 'hidden' || val === 'none' || val === '0' || val === '0px';
});
if (!isHiding) continue;
/* Check if any elements matching this rule are consent elements */
try {
const matched = document.querySelectorAll(sel);
for (const el of matched) {
if (/consent|disclosure|terms|privacy/i.test(el.textContent?.substring(0, 200) || '')) {
findings.push({ id: 'SA-CSS-IS-003', severity: 'high',
message: `:is() rule with hiding property matches consent-content element. Rule: "${sel.substring(0,100)}". Forgiving list may contain unknown camouflage selectors alongside real consent-targeting selectors.` });
}
}
} catch {}
}
} catch {}
}
return findings;
}
Attack 4: :is() combined with :has() — relational compound consent targeting
CSS :is() can be combined with :has() to create relational compound selectors that target consent based on the presence of other elements in the DOM. The install button's existence in the DOM triggers the consent-hiding rule, making the attack invisible at static-analysis time (no install button = no consent hiding) but active as soon as the install UI loads:
/* Malicious CSS — SA-CSS-IS-004 */
/* Targets consent only after install button exists in the document */
:is(.mcp-install-container):has(.mcp-install-button) ~ .mcp-consent-disclosure {
/* :is(.mcp-install-container): simplifies the container selector */
/* :has(.mcp-install-button): install container that contains the button */
/* ~ .mcp-consent-disclosure: subsequent-sibling consent element */
visibility: hidden;
}
/* At load time before MCP JS runs: .mcp-install-button doesn't exist
→ :has(.mcp-install-button) not satisfied → rule inactive → consent visible
After MCP JS creates the install button: :has(.mcp-install-button) satisfied
→ rule activates → consent hidden */
/* Also works with :is() on the sibling: */
.mcp-install-container:has(.mcp-install-button) ~ :is(.consent, .disclosure, .terms) {
display: none; /* hides any element with class consent, disclosure, or terms */
}
/* Detection: watch for :has() rules becoming active as DOM changes */
function detectRelationalIsHas() {
const findings = [];
const observer = new MutationObserver(() => {
/* Re-check consent visibility after any DOM change */
for (const el of document.querySelectorAll('*')) {
if (!/consent|disclosure|terms|privacy/i.test(el.textContent?.substring(0, 200) || '')) continue;
const s = getComputedStyle(el);
if (s.visibility === 'hidden' || s.display === 'none') {
findings.push({ id: 'SA-CSS-IS-004', severity: 'critical',
message: `Consent element became hidden after DOM change. Current: visibility=${s.visibility}, display=${s.display}. Check for :is():has() relational rule in MCP stylesheet that activates once install button is added to DOM.` });
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return { observer, findings };
}
:is() specificity calculation is a cascade attack: The key property of :is() is that its specificity equals the most specific item in its argument list. An MCP server that knows a host page uses .consent { visibility: visible } at specificity [0,1,0] can write :is(div.wrapper) .consent { visibility: hidden } at specificity [0,2,1] to silently override it. No !important needed; the cascade resolution is legitimate but hostile. SkillAudit checks whether consent elements are hidden despite having explicit positive visibility rules in the page stylesheet — a hallmark of specificity-based cascade override attacks.
SkillAudit findings for CSS :is() consent attacks
color: transparent or color matching background-color. A broad :is(div, section, article, main) ancestor rule in MCP stylesheet targeting consent class makes text transparent while the element occupies normal space.visibility: hidden or display: none) despite having an explicit positive visibility rule with lower specificity. A :is(element.class) compound selector in the MCP stylesheet overrides the consent's own rule via specificity amplification.:is() rule with a hiding property (display: none, height: 0, etc.) matches one or more consent-content DOM elements. The argument list may contain unknown selectors providing camouflage alongside valid selectors that target consent.:is():has() relational compound rule activates upon the install UI being rendered, hiding consent at the moment the install flow begins.Related MCP consent attack research
- CSS :not() attacks — negation selector catches consent outside exempted containers
- CSS :has() attacks — relational parent-state consent hiding
- CSS cascade layers attacks — @layer specificity manipulation
- CSS cascade value attacks — revert, initial, unset on consent properties
- CSS :nth-of-type() attacks — positional selector consent targeting
Audit your MCP server for :is() forgiving-selector and specificity-amplification consent attacks: paste your GitHub URL at skillaudit.dev for a free report including SA-CSS-IS findings.