Security reference · CSS injection · Selector attacks · Consent hiding
MCP server CSS :not() pseudo-class security
CSS :not() is a negation pseudo-class that matches every element that does not satisfy its argument selector. The attack pattern is the inverse of standard consent-hiding: instead of selecting the consent element directly and applying a destructive style, an MCP server selects everything except the install form and applies visibility: hidden, color: transparent, or height: 0. If the consent element lacks the class or attribute that the :not() argument requires for exemption, consent is caught by the destructive rule while the install form is protected. The install form appears normal; the consent disclosure is invisible.
:not() attack surface
| Attack pattern | Selector | Destructive property | How consent is caught |
|---|---|---|---|
| Classless element targeting | :not([class]) | visibility: hidden | Consent lacks a class attribute; install form elements have classes and are exempted |
| Container exclusion | :not(.mcp-install) * | display: none | All elements not inside the install container are hidden; consent is outside the container |
| Focus-state inversion | :not(:focus-visible) scoped to consent | opacity: 0 | Consent is invisible unless actively focused by keyboard; users never focus it during install |
| Cascade override | High-specificity :not() compound | Any hiding property | div:not([data-visible="true"]) overrides consent's own .consent { visibility: visible } rule due to higher specificity |
Negation selector detection requires positive selector logic: An audit that scans for visibility: hidden on the consent element's own rules may miss a rule that targets consent via negation. The rule :not([class]) { visibility: hidden } does not appear in any selector that names the consent element — but its computed effect on the consent element is visibility: hidden. Detection must check the computed style of the consent element, not just scan selectors that explicitly target it.
Attack 1: :not([class]) { visibility: hidden } — classless element targeting
The simplest :not() attack targets elements without a class attribute. Install UI components (the input, button, and container) always have classes for styling. Consent disclosures, especially when injected by MCP JavaScript, are often plain <p> or <span> elements without an explicit class. The rule :not([class]) { visibility: hidden } is destructive to those elements while leaving the styled install UI intact:
/* Malicious CSS — SA-CSS-NOT-001 */
/* This rule appears to be a "reset" for unstyled elements */
:not([class]) {
visibility: hidden; /* hides all elements without a class attribute */
}
/* Install form elements: have classes, survive the :not([class]) rule */
.mcp-install-input { visibility: visible; /* explicit rule preserves it */ }
.mcp-install-button { visibility: visible; }
/* Consent disclosure: no class → caught by :not([class]) → hidden */
/* By installing this MCP server, you agree to...
*/
/* This p element has no class → visibility:hidden */
/* Detection: scan all consent-matching elements for computed visibility/opacity/display */
function detectClasslessHiding() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy/i;
for (const el of document.querySelectorAll('*')) {
if (!CONSENT.test(el.textContent || '')) continue;
const s = getComputedStyle(el);
/* Check computed values — regardless of which selector caused them */
if (s.visibility === 'hidden' || s.display === 'none' || parseFloat(s.opacity) < 0.1) {
const rect = el.getBoundingClientRect();
findings.push({ id: 'SA-CSS-NOT-001', severity: 'critical',
message: `Consent element has computed visibility:${s.visibility}, display:${s.display}, opacity:${s.opacity}. Element has ${el.className ? `class="${el.className}"` : 'no class'}. Check if :not([class]) or :not(.install-form) rule is responsible.`,
element: el.tagName + (el.id ? '#'+el.id : '') + (el.className ? '.'+el.className : '[no class]') });
}
}
return findings;
}
Attack 2: :not(.mcp-install) * — container exclusion
A more surgical variant uses a descendant combinator with :not(): :not(.mcp-install) * selects all elements that are descendants of any element that is not .mcp-install. This means every element whose ancestor chain does not include the install container is hidden. The install UI elements are inside .mcp-install (exempted); the consent element, rendered outside the install container (often appended to the document body or a different parent), is caught:
/* Malicious CSS — SA-CSS-NOT-002 */
/* Appears to scope all content inside the install UI — actually hides everything outside it */
:not(.mcp-install) > * {
display: none; /* hide all direct children of non-install elements */
}
/* More precise: */
body > :not(.mcp-install) {
display: none; /* hide all direct children of body except .mcp-install */
}
/* Install UI: .mcp-install > [children] → children of .mcp-install are exempt */
/* Consent: appended to body directly → body > p.consent → caught by body > :not(.mcp-install) rule */
/* Real-world pattern: MCP JS appends consent to body: */
/* const p = document.createElement('p'); p.textContent = 'Terms: ...'; document.body.appendChild(p); */
/* → body > p → caught by :not(.mcp-install) rule */
/* Detection: check if consent element's direct parent is the excluded container */
function detectContainerExclusion() {
const findings = [];
for (const el of document.querySelectorAll('*')) {
if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
const s = getComputedStyle(el);
if (s.display !== 'none') continue;
/* Check for :not(.install) parent pattern by inspecting CSSOM rules */
let foundNegationRule = false;
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!rule.selectorText) continue;
if (/:\s*not\s*\(/.test(rule.selectorText) && rule.style.display === 'none') {
foundNegationRule = true;
}
}
} catch {}
}
findings.push({ id: 'SA-CSS-NOT-002', severity: 'critical',
message: `Consent element has computed display:none. ${foundNegationRule ? 'Negation :not() rule with display:none found in stylesheet — likely source.' : 'No negation rule found in accessible stylesheets (may be in shadow DOM or injected style).'}` });
}
return findings;
}
Attack 3: :not(:focus-visible) opacity collapse — focus-gate hiding
A more subtle attack uses a state-based negation: .mcp-consent-disclosure:not(:focus-visible) { opacity: 0 }. The consent element is invisible (opacity: 0) in all states except when the element itself has :focus-visible. Since users never Tab-focus a consent paragraph during a normal install flow, consent is permanently invisible. The rule looks like an "accessibility enhancement" (visible only when focused), but its practical effect is consent suppression in all realistic user interactions:
/* Malicious CSS — SA-CSS-NOT-003 */
/* Appears to enhance keyboard accessibility — actually hides consent */
.mcp-consent-disclosure:not(:focus-visible) {
opacity: 0;
/* consent is invisible unless the consent element itself receives :focus-visible */
/* users click the install button, not the consent element → consent never focused */
}
/* More aggressive: make it non-focusable so it can never satisfy :focus-visible */
.mcp-consent-disclosure {
pointer-events: none; /* cannot be clicked */
user-select: none; /* cannot be selected */
/* Without tabindex or interactive content, it cannot receive focus at all */
/* :focus-visible is never satisfied → opacity:0 always applies */
}
/* Detection: check opacity + whether element is focusable */
function detectFocusGateOpacity() {
const findings = [];
for (const el of document.querySelectorAll('*')) {
if (!/consent|disclosure|terms|privacy/i.test(el.textContent || '')) continue;
const s = getComputedStyle(el);
if (parseFloat(s.opacity) > 0.1) continue;
/* Is the element practically focusable? */
const tabindex = el.getAttribute('tabindex');
const isFocusable = tabindex !== null && parseInt(tabindex) >= 0;
const rect = el.getBoundingClientRect();
findings.push({ id: 'SA-CSS-NOT-003', severity: 'critical',
message: `Consent element opacity:${s.opacity} (effectively invisible). Focusable: ${isFocusable} (tabindex=${tabindex}). If hidden via :not(:focus-visible), element must be focusable for consent to ever appear. pointer-events:${s.pointerEvents}. Rect: w=${Math.round(rect.width)},h=${Math.round(rect.height)}.` });
}
return findings;
}
Attack 4: High-specificity :not() compound selector cascade override
In CSS Selectors Level 4, :not() accepts complex selectors (selector lists), and its specificity is determined by the most specific argument. div:not([data-visible="true"]) has specificity [0,1,1] — higher than a simple class selector [0,1,0]. If the consent element is a <div> without data-visible="true", a rule targeting it with a high-specificity :not() overrides the consent element's own positive style rule:
/* Malicious CSS — SA-CSS-NOT-004 */
/* High-specificity :not() overrides .mcp-consent { visibility: visible } */
div:not([data-visible="true"]) {
visibility: hidden; /* specificity [0,1,1] — higher than .class [0,1,0] */
}
/* Consent stylesheet rule: */
.mcp-consent-disclosure {
visibility: visible; /* specificity [0,1,0] — loses to div:not([data-visible]) */
}
/* Consent element:
→ is a div AND does not have data-visible="true"
→ caught by div:not([data-visible="true"]) → visibility:hidden
→ its own .mcp-consent-disclosure { visibility:visible } rule LOSES the cascade battle */
/* Level 4 :not() with selector list — even higher specificity: */
div:not(.install-form, .install-button, .install-input) {
color: transparent; /* specificity = max of [.install-form] = [0,1,0] on the :not() argument */
/* total: div [0,0,1] + :not(.install-form) [0,1,0] = [0,1,1] */
}
/* Detection: check if consent element's computed visibility contradicts its own stylesheet rules */
function detectCascadeOverride() {
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
|| s.color === 'transparent' || s.color === 'rgba(0, 0, 0, 0)') {
/* Element appears to have explicit consent class — check if computed style contradicts it */
findings.push({ id: 'SA-CSS-NOT-004', severity: 'critical',
message: `Consent-classed element computed: visibility=${s.visibility}, display=${s.display}, opacity=${s.opacity}, color=${s.color}. Despite having consent class, element is hidden — possible :not() high-specificity cascade override. Check for :not() rules in MCP stylesheet targeting element type without data-visible or explicit allowlist attributes.` });
}
}
return findings;
}
Detection must use computed style, not selector scanning: A CSS rule containing :not() does not mention the consent element by name. Scanning the stylesheet text for "consent" or "disclosure" will not find a :not([class]) { visibility: hidden } rule. SkillAudit checks the computed style of every element matching consent patterns — if the computed value is destructive, it flags the finding regardless of which rule caused it, then traces back to the responsible CSSOM rule for the report.
SkillAudit findings for CSS :not() consent attacks
visibility: hidden, display: none, or opacity < 0.1; element lacks a class attribute. A :not([class]) destructive rule in the MCP stylesheet is the likely source — install form elements with classes are exempt while classless consent disclosures are caught.display: none; a :not(.install-container) descendant rule found in accessible stylesheets. Consent is rendered outside the install container and is caught by the container-exclusion negation rule.opacity: 0 and element is not focusable (tabindex missing or negative, no interactive content). Focus-gate pattern: :not(:focus-visible) hides consent permanently because the element cannot receive focus during a normal install interaction.:not() compound selector in MCP stylesheet overrides the consent element's own positive styles via cascade specificity.Related MCP consent attack research
- CSS :is() attacks — forgiving selector list consent targeting
- CSS :has() attacks — relational parent-state consent hiding
- CSS visibility attacks — direct visibility:hidden on consent
- CSS opacity attacks — opacity:0 transparency on consent
- CSS cascade value attacks — revert, initial, unset on consent properties
Audit your MCP server for :not() negation selector consent hiding: paste your GitHub URL at skillaudit.dev for a free report including SA-CSS-NOT findings. SkillAudit checks computed style — not just selector text — to catch negation-based indirect hiding.