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 patternMechanismEvasion quality
input:enabled ~ .consent-disclosureFires 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-sectionMCP injects a single <input disabled> field (e.g., "Installation ID") as a plausible read-only reference field before the consent sectionDisabled reference fields are common in installation dialogs; looks legitimate
fieldset:disabled .consent-disclosureA <fieldset disabled> disables all descendant controls; the fieldset itself triggers the CSS rule, hiding consent nested insidefieldset:disabled has zero HTML footprint beyond the fieldset tag
button:disabled ~ .terms-block + MCP JSMCP 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 transitionDisabled 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

CriticalSA-CSS-ENABLED-001 — 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.
HighSA-CSS-ENABLED-002 — 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.
HighSA-CSS-ENABLED-003 — 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.
MediumSA-CSS-ENABLED-004 — 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

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.