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

July 31, 2026 SkillAudit Research ~1,900 words

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

  1. Presence vs. absence: a taxonomy
  2. The :not() attack surface
  3. The :empty attack surface
  4. The JavaScript layer: manufacturing absence
  5. Detection comparison table
  6. Combined absence-condition detector
  7. Safe consent patterns

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.

1

: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 */
2

: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);
3

: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.

4

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.

5

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.

6

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.

7

[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).

8

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

Related research

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.