Security reference · CSS injection · Pseudo-class attacks

MCP server CSS :user-valid and :user-invalid pseudo-class security

The CSS :user-valid and :user-invalid pseudo-classes were standardized in the CSS Selectors Level 4 specification and reached full cross-browser support in 2024. They are interaction-gated versions of :valid and :invalid: where :valid fires immediately at page load based on constraint attributes alone, :user-valid fires only after the user has focused the field, typed in it, and then blurred away. This post-blur timing is what makes these pseudo-classes uniquely dangerous in consent attack scenarios. A malicious MCP server can inject a rule that hides consent only after field interaction — so the page load state looks clean to any static or page-load-time auditor, and the attack is invisible until a real user session simulates blur events on form fields.

:user-valid / :user-invalid vs :valid / :invalid — the critical difference

Pseudo-classWhen it activatesAttack implication
:validImmediately at page load if constraint is met (e.g., no required attribute)Static auditors can detect by inspecting styles at load time
:invalidImmediately at page load if constraint fails (e.g., empty required field)Static auditors can detect; :invalid attacks are well-studied
:user-validOnly after user has interacted with AND blurred the field with a valid valueInvisible at page load; static auditors produce false negatives
:user-invalidOnly after user has interacted with AND blurred the field with an invalid valueTriggers during the error-correction moment — peak distraction window

Static CSS scanner blind spot: A security scanner that loads a page and queries getComputedStyle on the consent element at page-load time will see the consent element as fully visible — because :user-valid and :user-invalid are not active until blur events occur. The page passes the audit. The attack activates only during live user sessions when blur events occur, which the scanner never simulates.

Attack 1: Consent hidden after a field is successfully completed

:user-valid fires on a field after the user has typed a value that passes the field's validation constraints (pattern, type, required, minlength) and then moved focus away. A malicious rule using this state hides consent precisely when the user successfully completes a field — a moment of cognitive closure where the user is moving forward through the form:

/* Malicious CSS injection — SA-CSS-USER-VALID-001 */
input:user-valid ~ .consent-panel {
  display: none;
}

/* Activation sequence for a registration form:
   T=0s: Page loads.
         input:user-valid is FALSE for all fields (no blur yet).
         .consent-panel → visible (normal computed styles).
         Static auditor checks here: PASSES (no hiding detected).

   T=5s: User types a valid email address and presses Tab.
         focus leaves the email field → blur fires.
         email:user-valid becomes TRUE.
         ~ combinator reaches .consent-panel.
         .consent-panel → display:none (hidden).

   T=5s-60s: Consent panel is now hidden for the remainder of the
         session. The user has passed the first field and consent
         disappears. They continue filling the form without ever
         seeing the disclosure they are about to agree to.

   Why :user-valid and not :valid?
   Using :valid would hide the panel at page load for fields without
   required attributes (e.g., optional email pre-filled from localStorage),
   which would trigger a page-load audit. :user-valid hides the attack
   behind a real interaction requirement that auditors cannot simulate
   without full browser automation and explicit blur-event dispatch. */

/* Variant with :focus-visible gap for extra stealth */
input:user-valid:not(:focus) ~ .consent-panel {
  display: none;
}
/* This also excludes the currently-focused field from the selector,
   meaning the rule only fires AFTER the user has fully left the field. */

Attack 2: Consent hidden at the error moment — peak distraction

:user-invalid fires when the user has interacted with a field, blurred away, and the value fails validation. This is the error state — the moment the user sees a red border or error message on their email field. It is also the moment they are most distracted from reading a consent disclosure:

/* Malicious CSS injection — SA-CSS-USER-INVALID-001 */
input:user-invalid ~ .consent-panel {
  display: none;
}

/* Psychological targeting:
   The :user-invalid state is the highest-distraction moment in a
   form session. The user has made a mistake — typed an invalid email,
   chosen a password that is too short, or left a required field empty.
   Their attention is entirely on fixing the error. The consent panel
   being hidden at this moment is the least likely to be noticed.

   When does :user-invalid fire?
   - User types "john@" in an email field and presses Tab
     → field is :user-invalid → consent hidden
   - User types a 5-character password when min=8 is set
     → field is :user-invalid → consent hidden
   - User leaves a required field empty and tabs away
     → field is :user-invalid → consent hidden

   The state persists until the user returns to the field and corrects
   the value, then blurs again — at which point :user-valid fires
   (if correct) or :user-invalid remains (if still wrong).

   Combined with Attack 1 (SA-CSS-USER-VALID-001), the consent panel
   is hidden in BOTH the error state AND the success state after blur.
   The only window it is visible is before the user interacts with
   any field — which is the first few seconds of the form session
   before they start typing. */

Both states covered: With SA-CSS-USER-VALID-001 and SA-CSS-USER-INVALID-001 deployed together, the consent panel is visible only at page load before any field interaction. The moment the user begins filling the form — which triggers blur events on each completed field — the consent panel disappears and does not return. The combined attack requires just two CSS rules with no JavaScript.

Attack 3: Smooth fade-out as fields are progressively completed

Attack 3 uses :has() on the form element combined with transition:opacity to create a gradual, smooth fade-out of the consent terms as the user fills and validates each field. The progressive nature makes the disappearance feel like a natural UI state change rather than a sudden hiding:

/* Malicious CSS injection — SA-CSS-USER-VALID-002 */
form:has(input:user-valid) .consent-terms {
  opacity: 0;
  transition: opacity 0.5s ease-out;
}

/* The :has() selector matches the form as soon as ANY child input
   enters :user-valid state. So the fade begins after the very
   first field is successfully completed and blurred.

   Progressive UX deception:
   - First field completed → consent begins fading (0.5s transition)
   - If user notices: "the consent terms faded as the form filled in,
     that's just a nice animation" — perceived as intentional design
   - Second field completed → consent already at opacity:0 (stays there)
   - The 0.5s transition is fast enough not to seem alarming but slow
     enough to not be a jarring jump

   Why opacity:0 instead of display:none?
   - transition cannot animate display changes; opacity can
   - opacity:0 elements remain in the accessibility tree and layout
   - getComputedStyle reports opacity:'0' which requires numeric
     comparison; a scanner checking display and visibility will miss it
   - The element has non-zero dimensions and is still "there" — only
     the visual rendering is removed

   Detection requirement:
   getComputedStyle(consentEl).opacity → '0' after user-valid state
   Auditor must dispatch blur events AND check opacity numerically. */

/* Additional stealth: add pointer-events:none to prevent click
   after opacity reaches 0 (so the user can't accidentally interact
   with what looks like empty space over the hidden terms) */
form:has(input:user-valid) .consent-terms {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.5s ease-out;
}

Attack 4: Consent hidden only in the "all validated, no errors" state

Attack 4 targets the submit-ready state: all fields have been interacted with, all pass validation (user-valid is true for fields with values), and none are currently in an error state (no user-invalid). This is the state immediately before the user clicks submit — the final window in which they could review the terms:

/* Malicious CSS injection — SA-CSS-USER-VALID-003 */
form:not(:has(input:user-invalid)):has(input:user-valid) .consent-block {
  display: none;
}

/* Selector breakdown:
   form:not(:has(input:user-invalid))  → form has NO user-invalid inputs
   :has(input:user-valid)              → form HAS at least one user-valid input
   .consent-block                      → the consent disclosure target

   This fires when:
   - At least one field has been successfully completed and blurred
   - Zero fields are currently in the user-invalid error state
   = The "all green" state just before submission

   Form session timeline:
   T=0s: Page loads. No :user-valid, no :user-invalid.
         Selector: NOT has(invalid)=TRUE, has(valid)=FALSE → FALSE
         consent-block: visible

   T=10s: User fills email correctly and blurs.
          has(user-valid)=TRUE, NOT has(user-invalid)=TRUE → TRUE
          consent-block: display:none

   T=20s: User types bad password and blurs.
          has(user-invalid)=TRUE → NOT has(user-invalid)=FALSE → FALSE
          consent-block: visible again (error state, not attacking here)

   T=30s: User fixes password and blurs.
          has(user-invalid)=FALSE, has(user-valid)=TRUE → TRUE
          consent-block: display:none again

   T=31s: User clicks submit. Consent never read at submission moment.

   This attack is especially precise: consent is visible ONLY when
   there are form errors (when the user is distracted by fixing them)
   and hidden ONLY when everything is valid (when they're about to submit).
   The attack is exactly backwards from correct consent UX. */

SkillAudit findings for CSS :user-valid and :user-invalid attacks

CriticalSA-CSS-USER-VALID-001 — input:user-valid ~ .consent-panel { display:none }: consent hidden after the user successfully completes and blurs any form field. Invisible at page load; only detectable by dispatching blur events with valid field values and re-checking computed styles.
CriticalSA-CSS-USER-INVALID-001 — input:user-invalid ~ .consent-panel { display:none }: consent hidden during the form error state, at the highest-distraction moment in the session. Complements SA-CSS-USER-VALID-001 to suppress consent in both valid and invalid post-blur states.
HighSA-CSS-USER-VALID-002 — form:has(input:user-valid) .consent-terms { opacity:0; transition:opacity 0.5s }: smooth animated fade-out of consent terms as fields are progressively completed. Passes display/visibility auditors; requires opacity numeric check after simulated blur with valid values.
HighSA-CSS-USER-VALID-003 — form:not(:has(input:user-invalid)):has(input:user-valid) .consent-block { display:none }: consent hidden in the submit-ready "all validated" state. Consent is paradoxically visible during error states and hidden precisely when the form is ready to submit.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :user-valid / :user-invalid consent attack detection
 *
 * CRITICAL: Static CSS scanning cannot detect these attacks.
 * :user-valid and :user-invalid require actual user interaction (blur events)
 * to activate. This auditor simulates blur events programmatically.
 *
 * Must use .focus() → value assignment → .blur() sequence,
 * NOT just value assignment — browsers track whether the field
 * was actually focused before the blur for :user-valid/:user-invalid.
 */
async function auditUserValidConsentAttack(formEl, consentEl) {
  const results = [];
  const inputs = [...formEl.querySelectorAll('input, textarea, select')];

  function checkVisibility(el) {
    const s = getComputedStyle(el);
    return {
      hidden: s.display === 'none'
           || s.visibility === 'hidden'
           || parseFloat(s.opacity) < 0.1,
      opacity: parseFloat(s.opacity),
      display: s.display,
      visibility: s.visibility
    };
  }

  // Snapshot initial (page-load) visibility — should be visible
  await new Promise(r => requestAnimationFrame(r));
  const initialState = checkVisibility(consentEl);

  // --- Test: user-valid state on each input ---
  // Simulate: focus → set valid value → blur
  for (const input of inputs) {
    const origValue = input.value;
    const origType = input.type;

    // Set a valid value for the field type
    input.focus();
    if (origType === 'email') input.value = 'auditor@skillaudit.dev';
    else if (origType === 'password') input.value = 'AuditPass123!@#';
    else if (origType === 'tel') input.value = '+15555550100';
    else if (origType === 'url') input.value = 'https://example.com';
    else if (origType === 'number') input.value = '42';
    else input.value = 'audit-test-value';

    // Dispatch input event so validation constraint checks update
    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.dispatchEvent(new Event('change', { bubbles: true }));

    // Blur — this is what triggers :user-valid
    input.blur();
    input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));

    // Allow browser style recalculation
    await new Promise(r => requestAnimationFrame(r));
    await new Promise(r => setTimeout(r, 50)); // allow transition start

    const afterValidBlur = checkVisibility(consentEl);

    if (afterValidBlur.hidden && !initialState.hidden) {
      results.push({
        id: 'SA-CSS-USER-VALID-001-or-002',
        severity: 'critical',
        trigger: `user-valid on ${input.name || input.type || 'unknown'}`,
        detail: `opacity=${afterValidBlur.opacity.toFixed(2)}, display=${afterValidBlur.display}`
      });
    }

    // Restore field
    input.value = origValue;
  }

  // --- Test: user-invalid state ---
  // Simulate: focus → set invalid value → blur
  for (const input of inputs.filter(i => i.required || i.pattern || i.type === 'email')) {
    input.focus();
    if (input.type === 'email') input.value = 'not-a-valid-email';
    else if (input.required) input.value = ''; // empty = invalid for required
    else input.value = '!!invalid!!';

    input.dispatchEvent(new Event('input', { bubbles: true }));
    input.blur();
    input.dispatchEvent(new FocusEvent('blur', { bubbles: true }));
    await new Promise(r => requestAnimationFrame(r));

    const afterInvalidBlur = checkVisibility(consentEl);

    if (afterInvalidBlur.hidden && !initialState.hidden) {
      results.push({
        id: 'SA-CSS-USER-INVALID-001',
        severity: 'critical',
        trigger: `user-invalid on ${input.name || input.type || 'unknown'}`,
        detail: 'Consent hidden during form error state — highest distraction window.'
      });
    }

    // Restore
    input.value = '';
  }

  return results;
}

/* Safe consent pattern:
   Wrap the consent disclosure in a 
that is not a descendant OR sibling of any form input. Place it outside the
element or use a Shadow DOM boundary to isolate consent from CSS rules injected into the main document stylesheet. If the consent must be inside the form, ensure it is the FIRST child element — before any inputs — so sibling combinators originating from inputs cannot reach it. */

Related MCP consent attack research

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