Security reference · CSS injection · Pseudo-class attacks · Custom element registration
MCP server CSS :defined pseudo-class security
The CSS :defined pseudo-class (CSS Selectors 4) matches any element that has been defined — either a standard built-in HTML element (which is defined by the browser from page load) or a custom element that has been registered via customElements.define(). For MCP server consent attacks, :defined creates two distinct attack windows: a breadth attack using standard HTML elements (all of which are :defined immediately, making the rule fire from page load with zero injected HTML) and a timing attack using MCP's own custom elements (which become :defined at the precise moment MCP's registration script runs, letting MCP control when consent hides).
:defined scope — what is and isn't :defined
| Element type | :defined at page load? | When does :defined change? |
|---|---|---|
All standard HTML elements (div, p, section, input, etc.) | Yes — defined by the browser spec from initial render | Never changes; always :defined |
Registered custom elements (my-widget after customElements.define('my-widget', ...)) | Only if registration runs before render | Becomes :defined when customElements.define() is called |
Unknown/unregistered custom elements (unknown-tag with no registration) | No — browser treats as HTMLUnknownElement, never :defined | Remains :not(:defined) until registered; if never registered, never :defined |
| Autonomous custom elements (valid name, not yet registered) | No — valid custom element names that are not yet registered are not :defined | Becomes :defined when registration runs |
Key insight: Because all standard HTML elements are :defined from page load, a rule like div:defined ~ .consent { display:none } fires immediately at render with zero injected HTML. The attacker needs only to ensure a div exists before the consent disclosure in the DOM — which is almost universally true on any real page.
CSS :defined attack surface in MCP consent UIs
| CSS pattern | Why it fires | Evasion quality |
|---|---|---|
div:defined ~ .consent-disclosure { display:none } | Every div on the page is :defined from load; fires immediately at render | Zero injected HTML required; fires as soon as a div precedes consent in the DOM |
mcp-widget:defined ~ .consent { display:none } | Fires when MCP registers its custom element via customElements.define() | Timing-controlled by MCP; consent hides at the moment MCP's script registers its element |
:defined.mcp-component ~ .consent-section { display:none } | Compound: element must be both :defined AND have the .mcp-component class | Two conditions both under MCP control; class applied at same moment as element registration |
*:not(:defined) ~ .consent { display:none } | :not(:defined) fires on unregistered custom element tags; MCP injects <unknown-mcp-el> before consent | Absence-condition inversion; the injected element is :not(:defined) by design — zero registration needed |
Attack 1: div:defined — standard element breadth attack at page load
Because all standard HTML elements are :defined from the moment the browser renders them, a rule targeting div:defined fires immediately at page load on any page that contains a div before the consent disclosure — which is effectively all pages. No injected HTML is required. The MCP server need only include this CSS rule in its injected stylesheet:
/* Malicious CSS — SA-CSS-DEF-001 */
/* div:defined fires from page load — every div is always :defined */
div:defined ~ .consent-disclosure {
display: none;
}
/* Why this works without injected HTML:
- The general sibling combinator (~) matches ANY div that precedes .consent-disclosure
- Any real install page has multiple divs before the consent section
- The browser renders divs as :defined immediately (no customElements.define() needed)
- Static auditor checks visibility at load → consent is ALREADY hidden → SA-CSS-DEF-001 fires
/* Other standard HTML variants that are equally effective: */
p:defined ~ .consent-disclosure { display:none } /* p elements are all :defined from load */
section:defined ~ .consent-disclosure { display:none } /* section elements are all :defined */
header:defined ~ .consent-disclosure { display:none } /* header elements are all :defined */
/* Detection: :defined on standard elements should never be used in sibling selectors
targeting consent-related class names. Standard elements being :defined is a
browser invariant — the meaningful check is the SIBLING SELECTOR targeting consent. */
function detectDefinedBreadthAttack() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
// Standard tag + :defined targeting consent via sibling combinator
if (/^(div|p|span|section|article|header|main|footer):defined\s*~/.test(sel) &&
/consent|disclosure|terms|privacy/i.test(sel)) {
findings.push({ id: 'SA-CSS-DEF-001', severity: 'high',
message: `Standard element :defined sibling rule hiding consent: "${sel}"` });
}
}
} catch (_) {}
}
return findings;
}
Zero HTML required: SA-CSS-DEF-001 is one of the simplest CSS consent-hiding attacks — the only injected artifact is a CSS rule. No HTML injection, no JavaScript, no custom element registration. The attack relies entirely on a browser invariant (standard elements are always :defined) that auditors rarely check for in selector patterns.
Attack 2: mcp-widget:defined — custom element registration timing attack
When MCP injects a custom element (<mcp-widget></mcp-widget>) and then registers it via customElements.define('mcp-widget', MCP_Widget), the element transitions from :not(:defined) to :defined at the exact moment the define() call executes. MCP can time this registration to occur after any initial consent visibility checks — for example, in a setTimeout or after a user interaction event — making the consent hiding happen asynchronously:
/* Malicious CSS — SA-CSS-DEF-002 */
/* mcp-widget:defined fires when MCP calls customElements.define('mcp-widget', ...) */
mcp-widget:defined ~ .consent {
display: none;
}
/* Injected HTML — MCP places this before the consent disclosure */
<mcp-widget id="mcp-core"></mcp-widget>
<div class="consent">By installing, you grant access to your workspace...</div>
/* Malicious JS — timed registration hides consent after auditor has already checked */
// At page load: mcp-widget is :not(:defined) → consent is visible → auditor passes
// After 500ms: customElements.define() runs → mcp-widget becomes :defined → consent hides
setTimeout(() => {
customElements.define('mcp-widget', class extends HTMLElement {
connectedCallback() { /* MCP functionality */ }
});
}, 500);
/* Timeline:
t=0ms: Page renders. mcp-widget is :not(:defined). Consent visible.
t=0ms: Auditor checks consent visibility → PASSES (consent visible)
t=500ms: customElements.define() runs. mcp-widget becomes :defined.
t=500ms: CSS rule fires. .consent hidden. User now sees the install form.
t=500ms: User fills form without seeing consent — MCP timing controlled. */
/* Detection: scan for custom element name :defined rules in stylesheets */
async function detectCustomElementDefinedAttack() {
const findings = [];
const customElementPattern = /[a-z]+-[a-z][a-z0-9-]*:defined/i;
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (customElementPattern.test(sel) &&
/consent|disclosure|terms|privacy/i.test(sel)) {
findings.push({ id: 'SA-CSS-DEF-002', severity: 'critical',
message: `Custom element :defined timing attack detected in selector: "${sel}"` });
}
}
} catch (_) {}
}
// Also check for deferred customElements.define() calls
const scripts = [...document.querySelectorAll('script')].map(s => s.textContent);
const hasTimedRegistration = scripts.some(s =>
/setTimeout|requestAnimationFrame|addEventListener/.test(s) &&
/customElements\.define/.test(s)
);
if (hasTimedRegistration) {
findings.push({ id: 'SA-CSS-DEF-002', severity: 'high',
message: 'Deferred customElements.define() call detected — potential :defined timing attack; recheck consent visibility after all registrations complete' });
}
return findings;
}
Attack 3: :defined.mcp-component — compound selector with class control
The compound selector :defined.mcp-component requires two conditions to both be true: the element must be :defined and it must have the class .mcp-component applied. Since MCP controls both conditions (it registers the custom element and applies the class), both conditions become true simultaneously. The compound selector adds specificity (0-1-1 vs 0-0-1 for plain :defined) and is harder for automated selectors to recognize as malicious:
/* Malicious CSS — SA-CSS-DEF-003 */
/* Compound :defined + class — both conditions controlled by MCP */
:defined.mcp-component ~ .consent-section {
display: none;
}
/* Injected HTML — MCP assigns .mcp-component class to its custom element */
<mcp-core class="mcp-component"></mcp-core>
/* JS — registers element AND applies class simultaneously */
customElements.define('mcp-core', class extends HTMLElement {
connectedCallback() {
this.classList.add('mcp-component');
// Both :defined (element now registered) and .mcp-component (class just added)
// The compound selector :defined.mcp-component is now true on this element
// All .consent-section siblings are now hidden
}
});
/* Why the compound selector is harder to detect:
- ":defined.mcp-component" doesn't look like a consent-hiding attack
- The class name ".mcp-component" is a legitimate-looking component class
- The compound selector has specificity 0-1-1, overriding most consent-show rules
- A rule scanner checking for ":defined" alone may miss compound forms */
/* Detection: check for any :defined compound selector targeting consent siblings */
function detectCompoundDefinedAttack() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (/:defined[.\[#]/.test(sel) || /[.\[#[a-z]]:defined/.test(sel)) {
if (/consent|disclosure|terms|privacy/i.test(sel) ||
/~\s*(\.consent|\.terms|\.disclosure)/.test(sel)) {
findings.push({ id: 'SA-CSS-DEF-003', severity: 'high',
message: `Compound :defined selector targeting consent: "${sel}"` });
}
}
}
} catch (_) {}
}
return findings;
}
Attack 4: *:not(:defined) — absence-condition inversion with unknown elements
The :not(:defined) inversion selects elements that are not defined — specifically, unknown custom element tags that have not been registered. MCP injects an unknown tag (<unknown-mcp-el></unknown-mcp-el>) into the page before the consent disclosure. Because no customElements.define() ever registers unknown-mcp-el, it remains permanently :not(:defined). The CSS rule *:not(:defined) ~ .consent { display:none } fires from page load and never resolves:
/* Malicious CSS — SA-CSS-DEF-004 */
/* :not(:defined) fires on any unregistered custom element tag */
*:not(:defined) ~ .consent {
display: none;
}
/* Injected HTML — MCP places an unknown custom tag before consent */
<unknown-mcp-el></unknown-mcp-el>
<div class="consent">Terms and permissions disclosure...</div>
/* Why this is an absence-condition attack:
- "unknown-mcp-el" is never registered via customElements.define()
- Any unregistered element with a hyphenated name is permanently :not(:defined)
- No JavaScript needed — the CSS rule fires from page load based on element presence
- The element name "unknown-mcp-el" looks like a web component; it blends in
- This is the inverse of SA-CSS-DEF-002: instead of waiting for :defined to become true,
the attack relies on :not(:defined) being permanently true for unregistered elements
/* Detection: check for :not(:defined) in sibling selectors targeting consent */
function detectNotDefinedAbsenceAttack() {
const findings = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const sel = rule.selectorText || '';
if (/:not\(:defined\)/.test(sel) &&
/consent|disclosure|terms|privacy/i.test(sel)) {
findings.push({ id: 'SA-CSS-DEF-004', severity: 'critical',
message: `Absence-condition :not(:defined) attack targeting consent: "${sel}"` });
}
}
} catch (_) {}
}
// Also check for unknown custom elements in DOM that have no registration
for (const el of document.querySelectorAll('*')) {
const tag = el.tagName.toLowerCase();
if (tag.includes('-') && !customElements.get(tag)) {
const nextSibling = el.nextElementSibling;
if (nextSibling?.matches('[class*="consent"], .terms-section, [class*="disclosure"]')) {
findings.push({ id: 'SA-CSS-DEF-004', severity: 'high',
message: `Unregistered custom element <${tag}> directly precedes consent element — :not(:defined) attack candidate` });
}
}
}
return findings;
}
:defined vs. :not(:defined) — opposite conditions, same attack goal: SA-CSS-DEF-002 waits for registration to fire (:defined becomes true when customElements.define() runs). SA-CSS-DEF-004 relies on permanent non-registration (:not(:defined) remains true because no customElements.define() ever runs). SkillAudit's detection must cover both directions: checking for :defined timing attacks and for :not(:defined) absence-condition attacks.
SkillAudit findings for CSS :defined consent attacks
div:defined ~ .consent-disclosure { display:none }. Standard HTML elements are :defined from page load — fires immediately with zero injected HTML. The only injected artifact is a CSS rule. All real install pages have divs before consent disclosures.mcp-widget:defined ~ .consent { display:none }. Custom element :defined timing attack — MCP controls when customElements.define() runs. A deferred registration (setTimeout/rAF) hides consent after page-load audits have already passed. Requires post-registration consent recheck.:defined.mcp-component ~ .consent-section { display:none }. Compound :defined + class selector with 0-1-1 specificity. Both conditions controlled by MCP — the class is applied in the same connectedCallback() that fires when the element is registered. Harder for automated scanners to identify as malicious.*:not(:defined) ~ .consent { display:none }. Absence-condition inversion — an unregistered custom element tag is injected before consent. It is permanently :not(:defined), so the rule fires from page load without ever requiring a customElements.define() call. No JavaScript needed.Related MCP consent attack research
- CSS :has() relational pseudo-class attacks — parent selector consent hiding
- CSS :matches() deprecated alias attacks — Safari-specific consent hiding
- CSS custom state pseudo-class (:state()) attacks — custom element state machines
- CSS ::slotted() pseudo-element attacks — shadow DOM slot targeting
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :defined consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-DEF findings.