Security reference · CSS injection · Pseudo-class attacks · Post-interaction form validation

MCP server CSS :user-invalid pseudo-class security

The CSS :user-invalid pseudo-class (CSS Selectors 4) fires when a user has interacted with an input field and left it in an invalid state — as opposed to :invalid, which fires from page load on any field that fails validation constraints. For MCP server consent attacks, this post-interaction timing is a critical advantage: :user-invalid is invisible in fresh-load tests and invisible in static audits, activating only when a real user begins filling the form. It fires at peak form-filling engagement — after the first field interaction — hiding consent at the moment users are most focused on the form and least likely to notice it disappeared.

:user-invalid vs. :invalid — the critical timing distinction

Property:invalid:user-invalid
When it firesFrom page load, on any input failing its validation constraintOnly after the user has interacted with the field and left it invalid
Active at page load?Yes — empty required inputs are :invalid immediatelyNo — requires prior user interaction with the specific field
Static auditor detects?Yes — auditor sees display:none at loadNo — consent is visible at load; auditor must simulate interaction
Attack timingFrom load — covers the entire sessionFrom first invalid field interaction — covers the form-filling phase
SkillAudit detection approachCheck visibility at page loadSimulate focus, type, blur on required fields; check visibility after each

Note on the existing :user-valid/:user-invalid page: The combined :user-valid/:user-invalid security reference covers both pseudo-classes together. This page focuses exclusively on :user-invalid, its post-interaction timing mechanism, and how that timing differs from :invalid in terms of attack evasion and detection strategy. These are distinct attack vectors requiring separate detection probes.

CSS :user-invalid attack surface in MCP consent UIs

CSS patternWhy it firesEvasion quality
input:user-invalid ~ .consent-disclosure { display:none }Fires after user fills any required field incorrectly and moves focus away; post-interaction timingInvisible at page load; requires dynamic interaction simulation to detect
form:user-invalid .consent-section { display:none }form:user-invalid fires when any field in the form is :user-invalid; one bad field hides all consentHigh specificity on form element; fires after first invalid interaction regardless of which field
input:user-invalid + .terms-text { display:none }Adjacent combinator hides consent text placed directly after the :user-invalid inputMCP structures payload so required field is placed directly before terms footnote
:user-invalid vs. :invalid attack comparison:user-invalid is post-interaction; :invalid fires at load on empty required fields — different detection paths required:user-invalid evades :invalid-detection scanners that check state at page load

Attack 1: input:user-invalid fires after first invalid field interaction

When a user focuses on a required input, types something incorrect (or nothing and tabs away), :user-invalid becomes active on that field. The sibling combinator in input:user-invalid ~ .consent-disclosure { display:none } then hides all consent disclosures that follow that field in DOM order. This fires at the moment users are most actively engaged with the form:

/* Malicious CSS — SA-CSS-USERINV-001 */
/* Fires after user interacts with a field and leaves it invalid */
input:user-invalid ~ .consent-disclosure {
  display: none;
}

/* Why post-interaction timing is the attack's core strength:
   - At page load: :user-invalid is NOT active — consent visible
   - Auditor loads page, checks consent visibility at load → PASSES
   - User starts filling the form, makes a typo in field 1, tabs to field 2
   - :user-invalid activates on field 1 → consent-disclosure disappears
   - User is now focused on fixing their typo; doesn't notice consent vanished

/* The :user-invalid pseudo-class lifecycle on a required email input:
   1. Page loads: input is blank, required but untouched → :user-invalid = false, :invalid = true
   2. User focuses the field: still :user-invalid = false
   3. User types "notanemail", presses Tab: :user-invalid = true ← attack fires
   4. User corrects to "user@example.com", presses Tab: :user-invalid = false ← attack resolves
      But consent section may still be affected if other fields remain :user-invalid

/* Detection approach — must simulate user interaction */
async function detectUserInvalidConsentHiding() {
  const findings = [];
  const inputs = [...document.querySelectorAll('input[required], textarea[required]')];

  for (const input of inputs) {
    const originalValue = input.value;

    // Simulate focus + invalid input + blur (triggers :user-invalid)
    input.focus();
    input.value = '';  // empty required field is invalid
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.blur();

    // Wait for :user-invalid state to apply
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

    // Check if consent became hidden
    const hiddenConsent = [...document.querySelectorAll(
      '.consent-disclosure, .consent, .terms-section, [class*="consent"]'
    )].filter(el => getComputedStyle(el).display === 'none');

    if (hiddenConsent.length > 0) {
      findings.push({ id: 'SA-CSS-USERINV-001', severity: 'high',
        message: `Consent hidden after :user-invalid triggered on input#${input.id || input.name || '(no id)'} — :user-invalid attack detected` });
    }

    // Restore original value
    input.value = originalValue;
    input.blur();
    await new Promise(r => requestAnimationFrame(r));
  }
  return findings;
}

Auditor blind spot: SA-CSS-USERINV-001 is completely invisible to any auditor who only tests at page load. Unlike :invalid which is active from the first render, :user-invalid requires prior user interaction. SkillAudit's dynamic interaction simulation probe is required to detect this attack.

Attack 2: form:user-invalid fires when any field becomes :user-invalid

The form:user-invalid selector fires on the <form> element itself when any descendant field is currently :user-invalid. Using the descendant combinator on the form element allows a single CSS rule to hide consent anywhere within the form, regardless of which specific field triggered it:

/* Malicious CSS — SA-CSS-USERINV-002 */
/* form:user-invalid fires when any field within the form is :user-invalid */
form:user-invalid .consent-section {
  display: none;
}

/* Why targeting form:user-invalid is more powerful than input:user-invalid:
   1. The descendant combinator hides ANY .consent-section inside the form
   2. Only one field needs to be :user-invalid to trigger the form-level rule
   3. Users who tab through the form and make one mistake trigger the rule
      for all consent disclosures in the entire form
   4. The form:user-invalid selector has 0-1-1 specificity — higher than
      many consent-show rules that use simple class selectors (0-1-0)

/* Example form structure where form:user-invalid hides all consent sections: */
/* <form id="install-form">
     <input type="email" required placeholder="Your email">    ← :user-invalid trigger
     <div class="consent-section">...</div>                    ← hidden by form rule
     <div class="consent-section data-section">...</div>       ← also hidden
     <div class="consent-section marketing-section">...</div> ← also hidden
     <button type="submit">Install</button>
   </form>

   One invalid email hides all three consent sections simultaneously. */

async function detectFormUserInvalidConsentHiding() {
  const findings = [];
  for (const form of document.querySelectorAll('form')) {
    const firstRequired = form.querySelector('input[required], textarea[required]');
    if (!firstRequired) continue;

    firstRequired.focus();
    firstRequired.value = 'x';  // invalid for most required field types (email, url, etc.)
    firstRequired.dispatchEvent(new Event('input', { bubbles: true }));
    firstRequired.blur();
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

    const hiddenInForm = [...form.querySelectorAll('[class*="consent"], .terms-section')]
      .filter(el => getComputedStyle(el).display === 'none');

    if (hiddenInForm.length > 0) {
      findings.push({ id: 'SA-CSS-USERINV-002', severity: 'high',
        message: `${hiddenInForm.length} consent element(s) hidden inside form after :user-invalid triggered — form:user-invalid attack` });
    }

    // Restore
    firstRequired.value = '';
    await new Promise(r => requestAnimationFrame(r));
  }
  return findings;
}

Attack 3: input:user-invalid + .terms-text adjacent combinator

The adjacent sibling combinator requires DOM adjacency: the :user-invalid input must be the immediate previous sibling of the target. An MCP server can inject a required field directly before a terms footnote — the specific fine-print section that describes data sharing or third-party access rights — while leaving the primary consent header visible. This hides only the most legally significant content:

/* Malicious CSS — SA-CSS-USERINV-003 */
/* Adjacent combinator hides terms text directly after the :user-invalid input */
input:user-invalid + .terms-text {
  display: none;
}

/* Malicious HTML — required field injected directly before terms footnote */
<input type="email" required placeholder="Your email address">
<p class="terms-text">
  By installing, you grant this server permission to read, write, and delete
  files in your workspace. Data may be transmitted to third-party services.
  See our <a href="/privacy">privacy policy</a> for details.
</p>

/* What remains visible: the primary consent header ("I accept the terms")
   What becomes hidden: the specific terms text explaining the permissions
   User sees: a checkbox with "I accept" but no explanation of what they accept
   The attack is targeted at the maximum-liability section of the consent flow */

/* Detection: check adjacent pattern after :user-invalid simulation */
async function detectAdjacentUserInvalidConsent() {
  const findings = [];
  for (const input of document.querySelectorAll('input[required]')) {
    const nextEl = input.nextElementSibling;
    if (!nextEl?.matches('[class*="consent"], .terms-text, .terms-section')) continue;

    input.focus();
    input.value = '';
    input.blur();
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

    if (getComputedStyle(nextEl).display === 'none') {
      findings.push({ id: 'SA-CSS-USERINV-003', severity: 'high',
        message: `Terms text directly after required input hidden when input becomes :user-invalid — adjacent combinator attack` });
    }

    input.value = 'test@example.com';
    await new Promise(r => requestAnimationFrame(r));
  }
  return findings;
}

Attack 4: :user-invalid vs. :invalid — distinct attack vectors, distinct detection paths

The key distinction between :invalid and :user-invalid is their activation timing: :invalid fires at page load on any field that fails its constraint (even before the user has seen the field), while :user-invalid requires prior interaction. This makes them distinct attack vectors requiring entirely separate detection paths:

/* SA-CSS-USERINV-004: Timing distinction — detection strategy comparison */

/* :invalid detection (from existing SA-CSS-VALID page):
   - Check visibility at page load
   - No user interaction needed
   - Example: checkConsentVisibility() at DOMContentLoaded
   → Catches :invalid-based attacks; misses :user-invalid attacks */

/* :user-invalid detection (this page):
   - Must simulate user interaction: focus, type/clear, blur
   - Must check visibility AFTER the blur event, not at load
   - Example: simulate form fill → check visibility → restore
   → Catches :user-invalid attacks; not needed for pure :invalid attacks */

/* A single stylesheet may contain BOTH :invalid and :user-invalid rules —
   each hiding different consent elements at different times: */

/* Time 0 (page load): :invalid fires, hides .consent-primary */
input:invalid ~ .consent-primary { display: none; }  /* SA-CSS-VALID-002 */

/* Time T1 (after first user interaction): :user-invalid fires, hides .consent-secondary */
input:user-invalid ~ .consent-secondary { display: none; }  /* SA-CSS-USERINV-001 */

/* The user never sees either consent section:
   .consent-primary is hidden from load (via :invalid)
   .consent-secondary is hidden after their first field interaction (via :user-invalid)
   Even "both are hidden from the start" is partially wrong — .consent-secondary
   is visible at load, which might be the one auditors see and remember. */

/* Combined detection for both pseudo-classes: */
async function auditAllFormValidationConsentAttacks() {
  const results = [];

  // Phase 1: :invalid — check at page load (no interaction)
  const hiddenAtLoad = [...document.querySelectorAll('[class*="consent"]')]
    .filter(el => getComputedStyle(el).display === 'none');
  if (hiddenAtLoad.length > 0) {
    results.push({ id: 'SA-CSS-VALID-002', severity: 'high',
      message: `${hiddenAtLoad.length} consent element(s) hidden at page load — possible :invalid attack` });
  }

  // Phase 2: :user-invalid — simulate interaction on first required field
  const firstRequired = document.querySelector('input[required], textarea[required]');
  if (firstRequired) {
    firstRequired.focus();
    firstRequired.value = '';
    firstRequired.blur();
    await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

    const hiddenAfterInteraction = [...document.querySelectorAll('[class*="consent"]')]
      .filter(el => getComputedStyle(el).display === 'none');

    if (hiddenAfterInteraction.length > hiddenAtLoad.length) {
      results.push({ id: 'SA-CSS-USERINV-001', severity: 'high',
        message: `Additional consent hidden after :user-invalid triggered: ${hiddenAfterInteraction.length - hiddenAtLoad.length} new element(s) hidden` });
    }
    firstRequired.value = 'valid@example.com';
    await new Promise(r => requestAnimationFrame(r));
  }

  return results;
}

Layer 2 consent attack: SA-CSS-USERINV-001 through -003 represent a "second wave" attack that fires after any load-time detection would have already passed. An auditor who correctly identifies and fixes a load-time :invalid consent-hiding attack may overlook a companion :user-invalid rule that fires once a user starts filling the form — both can coexist in the same stylesheet targeting different consent elements.

SkillAudit findings for CSS :user-invalid consent attacks

HighSA-CSS-USERINV-001 — input:user-invalid ~ .consent-disclosure { display:none }. Fires after user interacts with any required field and leaves it invalid. Invisible at page load; requires dynamic interaction simulation. Activates at peak form-filling engagement.
HighSA-CSS-USERINV-002 — form:user-invalid .consent-section { display:none }. Form-level rule hides all consent sections inside the form when any field becomes :user-invalid. One invalid interaction hides multiple consent elements simultaneously.
HighSA-CSS-USERINV-003 — input:user-invalid + .terms-text { display:none }. Adjacent combinator targets the specific terms footnote directly after a required field; hides maximum-liability content while primary consent header remains visible. MCP structures payload for guaranteed adjacency.
MediumSA-CSS-USERINV-004 — :user-invalid evades :invalid-specific detection. A CSS rule using :user-invalid is invisible to any auditor or scanner that only checks at page load. Requires a separate dynamic interaction-simulation probe distinct from the :invalid detection path.

Related MCP consent attack research

Audit your MCP server for :user-invalid consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-USERINV findings.