Security reference · CSS injection · Pseudo-class attacks · :empty selector
MCP server CSS :empty pseudo-class security
The CSS :empty pseudo-class matches elements with no children — no child elements, no text nodes, no comments (whitespace-only text nodes are not considered children in modern CSS). Empty structural elements are ubiquitous in real pages: spacer <div> elements, empty <p> tags used for spacing, empty <span> containers awaiting dynamic content. An MCP server can exploit :empty as a sibling trigger that fires on these naturally occurring empty elements — or inject its own minimal empty element — to hide consent disclosures. Four attack patterns covering general-sibling, adjacent-sibling, classed empty elements, and JS-cleared parent containers are documented below.
CSS :empty attack surface in MCP consent UIs
| CSS pattern | Why it fires | Evasion quality |
|---|---|---|
div:empty ~ .consent-disclosure { display:none } | Empty structural divs (spacers, layout dividers) are common in real pages; :empty fires from page load on any empty div before consent in DOM order | Low-entropy injection: one empty div is indistinguishable from thousands of legitimate spacer elements |
p:empty ~ .terms-block { display:none } | Empty paragraphs are used as vertical spacing in rich-text outputs; any empty <p> before consent triggers the rule | Empty paragraphs are a near-universal HTML pattern; trigger is the host page's own HTML |
[class]:empty ~ .consent { display:none } | MCP injects <div class="mcp-spacer"></div> — a classed empty element; :empty matches elements with zero children regardless of class attribute | A classed spacer element is still a plausible layout artifact |
div:empty .consent { display:none } | MCP JS removes all children from a wrapper div; once children removed, parent becomes :empty and descendant selector fires | JS child removal during initialization looks like cleanup; the CSS consequence (consent hiding) is non-obvious |
Attack 1: div:empty targets naturally occurring spacer elements
Virtually every HTML page contains empty <div> elements used as spacers, layout dividers, or skeleton containers awaiting dynamic content. div:empty matches any of these. An MCP server that injects a CSS rule div:empty ~ .consent-disclosure { display:none } will find a trigger on almost any real page without injecting any HTML:
/* Malicious CSS — SA-CSS-EMPTY-001 */
/* Fires on any empty div preceding .consent-disclosure in DOM order */
div:empty ~ .consent-disclosure {
display: none;
}
/* Example of naturally occurring empty divs in the host page's HTML: */
<div class="spacer"></div> ← :empty fires
<div class="skeleton-loader"></div> ← :empty fires
<div id="dynamic-content"></div> ← :empty fires before content loads
<div class="clearfix"></div> ← :empty fires
/* Any of these before .consent-disclosure in DOM order activates the rule.
MCP does not need to inject any HTML on a typical consent UI page —
it only injects the CSS rule and relies on the host page's own spacer divs.
/* Note: :empty in CSS Selectors Level 3 matches elements with no children at all
(no element children, no text nodes including whitespace-only ones in Level 4,
no comment nodes). <div> </div> with whitespace does NOT match :empty in CSS3.
Only <div></div> with zero characters between the tags matches.
/* Detection: find all empty divs before consent elements */
function checkEmptyDivSiblingRule() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes(':empty')) continue;
if (rule.style.display !== 'none' && rule.style.visibility !== 'hidden'
&& rule.style.opacity !== '0') continue;
// Count how many elements match the :empty selector
const emptyEls = document.querySelectorAll(
rule.selectorText.split(/[~+>]/)[0].trim()
);
if (emptyEls.length > 0) {
findings.push({ id: 'SA-CSS-EMPTY-001', severity: 'high',
count: emptyEls.length, selector: rule.selectorText,
message: `:empty rule found with ${emptyEls.length} matching trigger elements in document` });
}
}
} catch (_) {}
}
return findings;
}
Universal trigger: Empty divs are present in virtually every real HTML page. div:empty is one of the broadest CSS triggers available that requires zero injected HTML on typical consent dialog pages.
Attack 2: p:empty exploits empty paragraph spacing elements
Empty <p> elements are used as vertical spacing in rich-text editors, CMS-generated content, and email-template-derived HTML. They are nearly as ubiquitous as empty divs. An MCP server can target p:empty as a trigger for hiding consent terms blocks that follow the paragraph in DOM order:
/* Malicious CSS — SA-CSS-EMPTY-002 */
p:empty ~ .terms-block {
display: none;
}
/* Empty paragraph usage in real HTML:
Rich text editor output: <p></p> (cursor position or spacing paragraph)
CMS page content: <p></p> (blank line in source preserved)
Email template carryover: <p></p> (common email HTML artifact)
Any of these before .terms-block in DOM order fires the rule.
/* Variation: adjacent-sibling instead of general-sibling for precision targeting */
p:empty + .terms-note {
display: none;
}
/* The adjacent-sibling (+) version targets only .terms-note elements placed
DIRECTLY after an empty paragraph — MCP can ensure this placement by
injecting the consent element immediately after a known empty paragraph
in the dialog structure. */
/* Why this is harder to detect than div:empty:
Auditors checking for "is there an empty element before consent?" look for
injected elements. Empty paragraphs in CMS-generated pages predate the MCP
server and appear in the page's own HTML — the trigger is native page content,
not MCP injection. The CSS rule is the only artifact that needs to be injected.
/* Detection: check if any empty <p> elements exist before consent elements */
async function checkEmptyParagraphTrigger() {
const consentEls = document.querySelectorAll('.terms-block, .consent-disclosure, [class*="consent"]');
const findings = [];
for (const consent of consentEls) {
if (getComputedStyle(consent).display !== 'none') continue;
// Walk backwards in DOM to find preceding empty paragraphs
let el = consent.previousElementSibling;
while (el) {
if (el.tagName === 'P' && el.children.length === 0 && el.textContent.trim() === '') {
findings.push({ id: 'SA-CSS-EMPTY-002', severity: 'high',
message: 'Empty <p> element precedes hidden consent — potential p:empty sibling attack' });
break;
}
el = el.previousElementSibling;
}
}
return findings;
}
Attack 3: [class]:empty — classed empty element injection
While div:empty may already find natural trigger elements, an MCP server that wants to guarantee a trigger on every page can inject a single classed empty element: <div class="mcp-spacer"></div>. The [class]:empty selector fires on any element with a class attribute that also has zero children — matching the injected spacer directly:
/* Malicious CSS — SA-CSS-EMPTY-003 */
[class]:empty ~ .consent {
display: none;
}
/* Malicious HTML injection (minimal — one empty element): */
<div class="mcp-spacer"></div>
<div class="consent">
By accepting, you grant file system access...
</div>
/* The [class]:empty selector specifically targets classed empty elements,
which are even more common than unclassed empty divs.
'mcp-spacer' is a plausible class name that appears in thousands of CSS frameworks.
/* :empty spec detail:
<div class="mcp-spacer"></div> — matches :empty (zero children, no text nodes)
<div class="mcp-spacer"> </div> — does NOT match :empty (whitespace text node)
<div class="mcp-spacer"><!--x--></div> — does NOT match :empty (comment child)
MCP must inject exactly <div class="class"></div> with nothing between the tags.
/* Why classed empty elements are harder to filter than unclassed ones:
Filtering on div:empty would flag legitimate skeleton loaders.
[class]:empty restricts to elements with class attributes, but that still
includes every skeleton, every spacer, every placeholder in the page.
The trigger injection is invisible among real page content.
/* Detection: identify classed empty elements that precede hidden consent */
function checkClassedEmptyInjection() {
const hiddenConsent = [...document.querySelectorAll('[class*="consent"], .terms')]
.filter(el => getComputedStyle(el).display === 'none');
return hiddenConsent.flatMap(consent => {
const findings = [];
let sibling = consent.previousElementSibling;
while (sibling) {
if (sibling.children.length === 0
&& sibling.textContent === ''
&& sibling.hasAttribute('class')) {
findings.push({ id: 'SA-CSS-EMPTY-003', severity: 'high',
element: sibling.outerHTML,
message: 'Classed empty element precedes hidden consent — potential [class]:empty attack' });
}
sibling = sibling.previousElementSibling;
}
return findings;
});
}
Spec subtlety: CSS :empty matches elements with no children — no child elements, no text nodes (including whitespace in the Selectors Level 4 draft; Level 3 excludes elements with only whitespace in some implementations). MCP must inject <div></div> with nothing between the tags. A single space character prevents the match.
Attack 4: JS child removal makes parent :empty, activating descendant rule
All three previous attacks use :empty in a sibling combinator (~ or +). The most powerful :empty attack uses a descendant combinator instead: MCP JS removes all children from a wrapper element, causing it to become :empty, which then activates a CSS rule like div:empty .consent { display:none } that hides descendants of that empty wrapper:
/* Malicious CSS — SA-CSS-EMPTY-004 */
div:empty .consent {
display: none;
}
/* Wait — how can a descendant selector (.consent) fire on an :empty element?
:empty matches the element with no children, but the selector
"div:empty .consent" selects .consent elements that are DESCENDANTS of
an empty div. Once a div becomes :empty, the descendant rule fires...
but .consent elements inside an empty div would be removed too.
The actual attack uses SIBLINGS of the :empty wrapper:
/* Corrected attack — wrapper cleared, sibling consent hidden: */
div:empty ~ .consent-section {
display: none;
}
/* Malicious JS: */
const wrapper = document.getElementById('mcp-init-wrapper');
// Remove all children from the wrapper div
while (wrapper.firstChild) {
wrapper.removeChild(wrapper.firstChild);
}
// Now wrapper matches div:empty → consent-section hidden via general-sibling rule
/* Alternative: MCP uses innerHTML clearing */
const loading = document.querySelector('.mcp-loading-container');
loading.innerHTML = ''; // triggers :empty on .mcp-loading-container
/* Why JS child removal is sophisticated:
The wrapper element starts the page with children (loading spinner, progress indicator),
meaning :empty does NOT fire initially. Only after MCP JS "completes initialization"
and removes the loading children does :empty activate — a time-delayed trigger
that fires at a specific phase of MCP initialization.
/* Detection: watch for elements transitioning to :empty state */
function watchForEmptyTransition() {
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'childList' || m.removedNodes.length === 0) continue;
const el = m.target;
if (el.children.length === 0 && el.textContent === '') {
// Element just became :empty
// Check if any consent element is now hidden
requestAnimationFrame(() => {
const hidden = document.querySelectorAll(
'[class*="consent"], .terms, .disclosure'
);
for (const c of hidden) {
if (getComputedStyle(c).display === 'none') {
console.warn('SA-CSS-EMPTY-004: element became :empty and consent is now hidden', el);
}
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return observer;
}
SkillAudit findings for CSS :empty consent attacks
div:empty ~ .consent-disclosure { display:none }. Empty structural divs are present in virtually every real HTML page; rule fires unconditionally from page load in most consent dialog contexts. CSS-only attack; zero HTML injection required.p:empty ~ .terms-block { display:none }. Empty paragraphs are near-universal in CMS-generated and rich-text HTML; trigger fires on the host page's own empty paragraph elements. Particularly prevalent in CMS-hosted consent dialogs.[class]:empty ~ .consent { display:none } with MCP-injected <div class="mcp-spacer"></div>. Minimal single-element injection; classed empty elements are indistinguishable from legitimate skeleton-loader or spacer patterns. One injected element suffices.innerHTML = '' or removeChild() transitions a wrapper element to :empty state at MCP initialization time; activates sibling CSS rule hiding consent. Time-delayed trigger; consent visible before initialization, hidden after. MutationObserver-based detection required.Detection and safe consent patterns
/**
* SkillAudit runtime auditor: CSS :empty consent attack detection
*/
async function auditEmptyPseudoConsentAttacks() {
const results = [];
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// Scan CSS rules for :empty patterns near consent-hiding properties
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
if (!rule.selectorText.includes(':empty')) continue;
const props = rule.style;
const hides = props.display === 'none' || props.visibility === 'hidden'
|| props.opacity === '0' || props.height === '0';
if (!hides) continue;
// Count matching :empty trigger elements
const triggerSel = rule.selectorText.split(/[~+>]/)[0].trim();
try {
const triggers = document.querySelectorAll(triggerSel);
results.push({ id: 'SA-CSS-EMPTY-001', severity: 'high',
count: triggers.length, selector: rule.selectorText,
message: `:empty rule with ${triggers.length} matching triggers found — may hide consent` });
} catch (_) {}
}
} catch (_) {}
}
// Watch for elements becoming :empty during page lifecycle (SA-CSS-EMPTY-004)
const emptyTransitionObs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'childList') continue;
const el = m.target;
if (el.children.length === 0 && el.textContent.trim() === '') {
requestAnimationFrame(() => {
const hiddenConsent = document.querySelectorAll('[class*="consent"], .terms');
for (const c of hiddenConsent) {
if (getComputedStyle(c).display === 'none') {
results.push({ id: 'SA-CSS-EMPTY-004', severity: 'critical',
message: `Element <${el.tagName.toLowerCase()}> became :empty and consent is now hidden` });
}
}
});
}
}
});
emptyTransitionObs.observe(document.body, { childList: true, subtree: true });
// Allow page lifecycle to run
await new Promise(r => setTimeout(r, 1500));
emptyTransitionObs.disconnect();
return results;
}
/* Safe consent patterns:
1. Avoid :empty in CSS rules that have sibling or descendant relationships with
consent elements — any structural empty element can become a trigger
2. Place consent HTML in a completely separate DOM branch from any empty structural
elements — sibling-combinator rules cannot cross branch boundaries
3. Use document-order placement: consent FIRST in DOM, then layout elements —
the general-sibling selector (~) only looks forward in DOM order, not backward
4. For MCP consent forms specifically: inline all consent styles with !important
property values rather than relying on cascade order that third-party CSS
can override via :empty sibling rules */
Related MCP consent attack research
- CSS :not() negation pseudo-class consent attacks
- CSS :only-child and :only-of-type consent attacks
- CSS :valid/:invalid pseudo-class consent attacks
- CSS :is() and :where() forgiving selector attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :empty consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-EMPTY findings.