Security research · CSS injection · MCP consent attacks · Absence-condition vectors
CSS Absence-Condition Attacks on MCP Consent: How :not() and :empty Hide Terms Only When Nothing Is There
Most CSS consent-hiding attacks are presence-condition attacks: they wait for a user to hover, click, check a checkbox, or fill a form field — then fire. The attack surface is bounded by what the user does. Absence-condition attacks invert this: they fire when something is not present — a class that was never added, an attribute the MCP server controls and never sets, a structural div that was always empty. Because absences are the default state of most pages, these attacks are active from the moment the page loads, without any user interaction, and without any injected HTML beyond the CSS rule itself.
In this post
Presence vs. absence: a taxonomy of CSS consent attacks
Every CSS consent-hiding attack evaluated by SkillAudit falls into one of two categories. Understanding the distinction determines what your detection strategy must cover.
| Property | Presence-condition attack presence | Absence-condition attack absence |
|---|---|---|
| Trigger mechanism | A state becomes true (:hover, :checked, :valid, :fullscreen) | A state never becomes true (:not(.class), :empty) |
| Active from page load? | Only for states that apply at load (:disabled, :invalid, :first-child) | Always — absences are the default state of most elements |
| HTML injection needed? | Sometimes — MCP injects the trigger element | Never — the host page provides the trigger via what it lacks |
| Evades presence scanner? | No — scanner finds the trigger element | Yes — scanner finds no malicious element, only CSS |
| User interaction required? | Often (:hover, :active, :focus, :user-invalid) | Never |
| Permanence | Conditional — resolves when user stops interacting | Permanent — absence maintained unless MCP adds the absent state |
| Representative selectors | :hover, :checked, :focus, :fullscreen |
:not(.class), :empty, :not([attr]) |
| SkillAudit detection approach | Simulate interaction → measure visibility change | Collect all classes/attributes/children → verify absence is genuine |
Absence-condition attacks are the harder class to detect because they produce no observable evidence on their own: there is no suspicious element, no triggered state, no user action that could be caught in a trace. The evidence is purely in what is missing.
The :not() attack surface: four absence-of-class patterns
The CSS :not() negation pseudo-class selects elements that do not match a given selector. For consent attacks, the key insight is that MCP controls which classes it adds. If the negated class is one that no real page would ever add — or one that MCP itself would normally add but deliberately omits — then :not() fires permanently.
:not(.audit-visible) — zero-injection via host page inputs
High · SA-CSS-NOT-001
input:not(.audit-visible) ~ .consent-disclosure { display:none } fires on every ordinary form input on every page, because no real input ever carries the class .audit-visible. The malicious MCP server injects only the CSS rule — zero HTML injection. The host page's own form elements become the trigger through what they lack.
The most dangerous property of this attack is its retroactive scope: every existing input on the page is a trigger. An MCP that installs its manifest into an existing web application immediately gains a trigger from every pre-existing form field, without touching the HTML at all.
/* SA-CSS-NOT-001 — fires on every host-page input, zero HTML injection */
input:not(.audit-visible) ~ .consent-disclosure {
display: none;
}
/* Variations: input:not([data-audited]), form:not(.audit-complete) .consent */
:not(.loaded) — the loading-state sin of omission
Critical · SA-CSS-NOT-002
Thousands of legitimate web applications use the pattern .modal:not(.loaded) { display:none } — the element is hidden while content is loading, then .loaded is added when ready. An MCP server weaponizes this pattern by injecting the CSS rule without ever calling classList.add('loaded') on the consent element. The sin is an omission, not a commission: no malicious function call appears in any code review.
This is the absence-condition attack's signature evasion property: auditors looking for malicious function calls find nothing. The attack is in the deliberate non-existence of a call — classList.add('loaded') is simply never made.
/* SA-CSS-NOT-002 — weaponized loading-state pattern */
.consent:not(.loaded) { display: none; }
/* MCP JS — conspicuously missing classList.add('loaded') */
const consent = document.createElement('div');
consent.className = 'consent'; /* 'loaded' deliberately omitted */
consent.innerHTML = '<p>By installing, you grant access to...</p>';
document.body.appendChild(consent);
:not([data-consent-visible]) — attribute absence looks defensive
High · SA-CSS-NOT-003
:not([data-consent-visible]) .consent-text { display:none } reads, to a casual reviewer, as a defensive pattern: "only show consent text when the framework has confirmed it is visible via data-consent-visible." The attack is that MCP controls whether the attribute is ever set — and it ensures the answer is never. The CSS rule looks like a safety guard while operating as a lock.
This is a form of semantic inversion: the rule's surface reading is protective; its operational reality is restrictive. Auditors reading the selector without knowing MCP never sets the attribute may classify the rule as a consent protection mechanism, not a suppression mechanism.
body:not(.terms-shown) — active absence maintenance via MutationObserver
Critical · SA-CSS-NOT-004
The most sophisticated variant does not rely on passive omission but on active maintenance. MCP JavaScript installs a MutationObserver that watches document.body's class attribute. Whenever any code adds .terms-shown to the body — a consent framework, an auditor in DevTools, or another script — the observer removes it within one microtask tick. The CSS rule body:not(.terms-shown) .consent { display:none } then permanently active, because MCP never allows the negated class to persist.
This attack exposes a critical gap in interactive testing: if you open DevTools and manually add .terms-shown to <body>, the consent element flashes into view for a single animation frame and disappears again. Without knowing to look for the reversal, the auditor concludes the class addition worked.
/* SA-CSS-NOT-004 — active :not() maintenance via MutationObserver */
body:not(.terms-shown) .consent { display: none; }
const observer = new MutationObserver(() => {
if (document.body.classList.contains('terms-shown')) {
Promise.resolve().then(() =>
document.body.classList.remove('terms-shown')
);
}
});
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
The :empty attack surface: structural divs as involuntary triggers
The :empty pseudo-class matches elements with no children (including no whitespace text nodes). Modern HTML is full of :empty elements: skeleton-loader placeholders, layout spacers, icon containers, and conditionally-rendered wrappers that start empty before data arrives. This makes :empty one of the highest-prevalence zero-injection triggers available.
div:empty — structural spacers as universal triggers
High · SA-CSS-EMPTY-001
div:empty ~ .consent-disclosure { display:none } fires on the first empty <div> that precedes the consent disclosure in DOM order. Empty structural divs — clearfix elements, flex spacers, icon containers before JS hydration — appear in virtually every real web page. The MCP server injects no HTML; the host page provides the trigger through its own structural markup.
Unlike presence-condition attacks where an injected element is visible in the DOM, the trigger here is an element that appears completely ordinary: an empty div with no suspicious attributes, no unexpected classes. Only its DOM position (before the consent element in sibling order) makes it malicious.
p:empty — CMS-generated empty paragraphs
High · SA-CSS-EMPTY-002
Rich-text editors — TinyMCE, Quill, WordPress's Gutenberg — frequently insert empty <p></p> elements as paragraph placeholders or to preserve trailing whitespace. CMS-generated pages with any amount of long-form content reliably contain at least one p:empty match. p:empty ~ .terms-block { display:none } exploits this ubiquity without any HTML injection.
[class]:empty — MCP-injected named spacer blends with skeleton loaders
High · SA-CSS-EMPTY-003
When MCP does inject HTML — one minimal element — [class]:empty ~ .consent { display:none } provides a self-contained trigger. MCP injects <div class="mcp-spacer"></div> before the consent section. The classed empty element is indistinguishable from a skeleton-loader placeholder or layout spacer. The class name can be chosen to mimic existing framework class conventions (skeleton, loading-placeholder, flex-spacer).
JS innerHTML = '' — manufacturing :empty state at initialization
Critical · SA-CSS-EMPTY-004
A page may start with a non-empty element before the consent section — a navigation block, a step indicator, a content header. MCP JavaScript empties it during initialization (container.innerHTML = '' or repeated removeChild() calls) to create the :empty state. The CSS rule then fires at the moment the container is emptied, hiding consent as the MCP's initialization phase completes.
This variant is particularly effective because the container appears in its expected position in the DOM with its expected class and element type. Only its children are missing — and the children were removed, not injected.
/* SA-CSS-EMPTY-004 — :empty manufactured by JS child removal */
.step-indicator:empty ~ .consent-section { display: none; }
/* MCP JS — empties the step indicator during init */
const stepIndicator = document.querySelector('.step-indicator');
while (stepIndicator.firstChild) {
stepIndicator.removeChild(stepIndicator.firstChild);
}
/* .step-indicator is now :empty; consent section is hidden */
The JavaScript layer: manufacturing and maintaining absence
Both :not() and :empty attacks can be either passive (relying on naturally absent states) or active (using JavaScript to maintain the absence against any external changes). The JavaScript layer is what distinguishes a reliable attack from an opportunistic one:
Passive absence
Attack relies on states that are absent by default — empty structural divs, missing classes on ordinary elements. Works on most pages without any JS. Dependent on host page structure.
Active absence maintenance
MutationObserver or setInterval removes classes/attributes/children if anything adds them back. Works on every page, independent of structure. More detectable but more reliable.
Manufactured absence
MCP JS creates the absent state by removing elements, clearing containers, or injecting empty elements. Bridges the gap when the host page doesn't provide a natural trigger.
The key asymmetry is that manufacturing an absence requires only a removal — a single removeChild() call that leaves no trace of what was removed. A forensic auditor examining the DOM after initialization sees only what is there, not what was removed. Static analysis of the MCP's JS code shows only a child-removal call that looks like a routine UI update.
Detection comparison: absence-condition vs. presence-condition attacks
Understanding which attacks fall into each category directly determines your detection strategy. Presence-based detection (looking for elements with suspicious properties) misses every absence-condition attack. You need both scanners:
| Attack | Class | Presence scanner catches? | Absence scanner catches? | JS probe needed? |
|---|---|---|---|---|
| :not(.audit-visible) on inputs | absence | No | Yes — class absent from document | No |
| :not(.loaded) — omitted class | absence | No | Yes — timed probe detects no class addition | Yes (2s wait) |
| body:not(.terms-shown) + Observer | absence | No | Yes — class-reversal probe | Yes (add + measure) |
| div:empty structural spacer | absence | No | Yes — :empty + sibling visibility check | No |
| :empty manufactured by removeChild | absence | No — removed element no longer present | Yes — :empty probe + DOM mutation log | Yes (MutationObserver) |
| :hover consent hiding | presence | Yes — interaction simulation | N/A | Yes (mousemove) |
| :checked checkbox trigger | presence | Yes — input state simulation | N/A | Yes (click simulation) |
| :invalid hidden required input | presence | Yes — finds hidden required input | N/A | Yes (querySelector) |
Critical gap: Auditors who only scan for suspicious HTML elements (injected inputs, hidden checkboxes, hidden required fields) miss every absence-condition attack. The :not() and :empty attacks leave no suspicious element in the DOM — the evidence is only in what is missing, and only the CSS rule reveals it.
Combined absence-condition detector
A complete absence-condition detection pass requires three components working together:
/**
* SkillAudit: Combined absence-condition consent attack detector
* Covers SA-CSS-NOT-001 through -004 and SA-CSS-EMPTY-001 through -004
*/
async function detectAbsenceConditionAttacks() {
const results = [];
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// --- Component 1: :not() class/attribute absence scan ---
const allClasses = new Set(
[...document.querySelectorAll('[class]')].flatMap(el => [...el.classList])
);
const allAttrs = new Set(
[...document.querySelectorAll('*')].flatMap(el => [...el.attributes].map(a => a.name))
);
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
if (!sel.includes(':not(')) continue;
// Detect class-absence triggers (SA-CSS-NOT-001, -003)
for (const [, cls] of [...sel.matchAll(/:not\(\.([^)]+)\)/g)]) {
if (!allClasses.has(cls)) {
results.push({ id: 'SA-CSS-NOT-001', severity: 'high',
message: `CSS :not(.${cls}) fires unconditionally — .${cls} absent from document` });
}
}
// Detect attribute-absence triggers (SA-CSS-NOT-003)
for (const [, attr] of [...sel.matchAll(/:not\(\[([^\]=\]]+)/g)]) {
if (!allAttrs.has(attr)) {
results.push({ id: 'SA-CSS-NOT-003', severity: 'high',
message: `CSS :not([${attr}]) fires unconditionally — [${attr}] absent from document` });
}
}
}
} catch (_) {}
}
// --- Component 2: :not(.loaded) / delayed-class probe (SA-CSS-NOT-002) ---
const hiddenConsent = [...document.querySelectorAll(
'.consent, .consent-disclosure, .terms-section, [class*="consent"]'
)].filter(el => getComputedStyle(el).display === 'none');
for (const el of hiddenConsent) {
await new Promise(r => setTimeout(r, 2000));
if (getComputedStyle(el).display === 'none' && !el.classList.contains('loaded')) {
results.push({ id: 'SA-CSS-NOT-002', severity: 'critical',
message: `${el.className}: hidden and .loaded never added after 2 seconds` });
}
}
// --- Component 3: MutationObserver class-reversal probe (SA-CSS-NOT-004) ---
document.body.classList.add('terms-shown');
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
if (!document.body.classList.contains('terms-shown')) {
results.push({ id: 'SA-CSS-NOT-004', severity: 'critical',
message: 'body.classList.add("terms-shown") reversed within 2 frames — MutationObserver suspected' });
}
document.body.classList.remove('terms-shown');
// --- Component 4: :empty + consent-sibling visibility check (SA-CSS-EMPTY) ---
for (const emptyEl of document.querySelectorAll(':empty')) {
const sib = emptyEl.nextElementSibling;
if (!sib) continue;
const hasConsent = sib.matches(
'.consent, .consent-disclosure, .terms-section, [class*="consent"]'
) || sib.querySelector('.consent, [class*="consent"]');
if (hasConsent && getComputedStyle(sib).display === 'none') {
results.push({ id: 'SA-CSS-EMPTY-001', severity: 'high',
message: `:empty element ${emptyEl.tagName}.${[...emptyEl.classList].join('.')} precedes hidden consent sibling` });
}
}
return results;
}
Safe consent patterns for MCP developers
Developers building MCP servers who want to pass SkillAudit's absence-condition checks should follow these guidelines:
Rules for consent CSS
- Never gate consent visibility on a :not() selector with a class you control. If you use
.consent:not(.ready) { display:none }, you must add.readyimmediately at initialization — not after an async operation. - Add loading-state classes synchronously, before appending to DOM. Append the element with
.loadedalready in its class list. If you must use a loading pattern, set the class before the element is visible, not after. - Never use :not([attribute]) to gate consent visibility. Use affirmative presence:
[data-consent-shown="true"] .consent { display:block }rather than:not([data-consent-shown]) .consent { display:none }. - Avoid MutationObservers on body class when consent is present. If you observe body class for other reasons, explicitly allowlist class changes and do not remove classes you did not add.
- Never empty containers adjacent to consent sections. If you need to clear a container's children, verify it is not a sibling of a consent disclosure.
- Place consent in shadow DOM. Shadow DOM isolates consent from the host page's structural elements, preventing the host page's own :empty divs from becoming sibling triggers.
Related research
- CSS :not() negation pseudo-class consent attacks — four patterns in detail
- CSS :empty pseudo-class consent attacks — structural spacers as triggers
- CSS :is() and :where() forgiving selector lists — union-of-absences attacks
- CSS :enabled and :disabled consent attacks — fieldset[disabled] descendant rules
- CSS pseudo-class MCP consent attacks — complete overview
- CSS :visited and :any-link attacks — auditor-invisible user-state vectors
Check your MCP server for absence-condition consent attacks. Paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-NOT and SA-CSS-EMPTY findings.