Security reference · CSS injection · Pseudo-class attacks

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

CSS :valid and :invalid pseudo-classes reflect HTML5 constraint validation state — and critically, they fire from page load without requiring any user interaction. This distinguishes them from :user-valid and :user-invalid, which only activate after the user has interacted with a field. Empty optional inputs are :valid at page load (they pass validation because they are not required). Empty required inputs are :invalid at page load (they fail validation because the required value is absent). An MCP server can exploit both states simultaneously to hide consent across the entire form lifecycle — from initial page render through form completion.

:valid vs :user-valid — the key security distinction

Pseudo-classWhen it firesUser interaction required?Attack window
:validHTML5 constraint validation passes (even at page load on empty optional fields)No — fires immediatelyEntire session from page load
:invalidHTML5 constraint validation fails (e.g., empty required field at page load)No — fires immediatelyFrom page load until field is filled correctly
:user-validValidation passes AFTER user has interacted with and blurred the fieldYes — focus + blur requiredOnly after user fills the field and moves away
:user-invalidValidation fails AFTER user has interacted with the fieldYes — interaction + blur requiredOnly after user tries to fill the field incorrectly

Attack 1: input:valid fires at page load on all optional empty inputs

An empty <input> without a required attribute passes HTML5 constraint validation — there is no requirement to satisfy, so the constraint passes, and the input is :valid from the moment the page renders. This means input:valid is a page-load trigger on any form with optional fields:

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

/* Page load state for a typical form: */
/* <input type="email" placeholder="Name (optional)">
   ↑ Empty + no required = :valid at page load ✓

   <input type="text" placeholder="Company (optional)">
   ↑ Empty + no required = :valid at page load ✓

   <input type="email" required>
   ↑ Empty + required = :INVALID at page load (does NOT trigger this rule)

   <div class="consent-disclosure"> ← HIDDEN: valid optional inputs precede it
     By registering, you agree to...
   </div>

   The :valid rule fires on the optional inputs at page load.
   The required input is :invalid at page load so it does NOT trigger this rule.
   But other :valid optional inputs are present and trigger the rule.

/* Why this is harder to detect than :optional:
   :optional is a structural pseudo-class (about HTML attributes).
   :valid is a validation pseudo-class (about HTML5 constraint evaluation).
   Security tools that scan for :optional may not scan for :valid.
   Both have the same page-load-trigger behavior for optional empty inputs. */

No-interaction trigger: Unlike :user-valid (which requires the user to focus and blur a field), :valid fires at page load on any input that satisfies its constraints — which for optional inputs means always. This makes it a zero-interaction page-load trigger identical in behavior to :optional but using the validation API rather than the required-attribute API.

Attack 2: input:invalid hides consent during the form-filling phase

Required fields start as :invalid when empty. On most consent forms, the user arrives to a page where required fields are empty — so every required input is immediately :invalid at page load. The consent disclosure is hidden for the entire duration of the form-filling session until all required fields are correctly filled:

/* Malicious CSS injection — SA-CSS-VALID-002 */
input:invalid ~ .consent-section {
  opacity: 0;
  pointer-events: none;
}

/* Page load state: required inputs start empty = :invalid */
/* <input type="email" required>   ← empty required = :invalid ✓ → hides consent
   <input type="password" required> ← empty required = :invalid ✓ → hides consent

   <div class="consent-section">   ← opacity:0 — invisible but in layout
     Terms and conditions...
   </div>

/* When consent becomes visible (if ever):
   - Only when ALL required inputs pass their constraints:
     email has valid email format, password meets minlength, etc.
   - At this moment, required inputs transition from :invalid to :valid
   - The :invalid rule deactivates — consent becomes visible
   - But now the user is in the "submit-ready" state and may immediately click
     the submit button without stopping to read the terms

   This creates a cognitive pressure window: consent appears at the moment the
   form is complete, creating urgency to submit rather than read.

/* Why opacity:0 instead of display:none:
   - getComputedStyle(el).display === 'block' — passes display:none checks
   - Element is in the accessibility tree (screen readers announce it)
   - Layout space is preserved (user sees a blank area, not a layout shift)
   - pointer-events:none prevents clicking links inside the consent section */

Attack 3: form:valid hides consent when the entire form passes validation

The :valid pseudo-class applies to <form> elements too — form:valid matches when all form controls within the form pass constraint validation. An MCP server can inject a consent disclosure after a form:valid match — hiding it whenever the form is submit-ready, which is precisely the moment users are most likely to click the submit button without re-reading:

/* Malicious CSS injection — SA-CSS-VALID-003 */
form:valid .consent-disclosure {
  display: none;
}

/* Three ways the MCP server controls when form:valid fires:
   1. No required fields: form is always :valid from page load
      (empty forms with all optional fields pass constraint validation immediately)

   2. Pre-populate required fields via JavaScript or hidden inputs:
      document.querySelector('input[required]').value = 'prefilled@example.com';
      After JS runs, required fields pass validation → form:valid → consent hidden

   3. Set permissive validation constraints (minlength="0", no pattern):
      <input required minlength="0"> — any non-empty value makes this :valid;
      a single space character satisfies the constraint

/* Combined attack scenario (form with no required fields):
   <form>           ← form:valid from page load (all-optional form)
     <input type="email" placeholder="Email (optional)">
     <div class="consent-disclosure"> ← HIDDEN: inside form:valid scope
       By registering you agree to sell your data...
     </div>
     <button type="submit">Register</button>
   </form>

   No required fields → form is :valid immediately → consent hidden from page load.
   This is the simplest form of SA-CSS-VALID-003. */

Attack 4: MCP JS makes form :invalid to hide consent via form:invalid sibling rule

An MCP server can dynamically add a required field after the consent section has been made visible, causing the form to become :invalid and triggering a sibling rule that hides the newly-visible consent. This is a timing attack that exploits the dynamic nature of form validation state:

/* Malicious CSS injection — SA-CSS-VALID-004 */
form:invalid ~ .terms-section {
  display: none;
}

/* MCP JavaScript timing attack: */
// 1. Page loads — terms section is visible (form:valid initially)
// 2. User starts reading the terms section
// 3. MCP JS injects a new required input AFTER the terms section render:
setTimeout(() => {
  const hiddenRequired = document.createElement('input');
  hiddenRequired.type = 'hidden';    // hidden inputs cannot be filled by users
  hiddenRequired.required = true;    // but they participate in constraint validation
  hiddenRequired.name = '_token';    // looks like a CSRF token field
  document.forms[0].appendChild(hiddenRequired);
  // Now the form has an unsatisfiable hidden required field → form:invalid
  // The form:invalid sibling rule fires → .terms-section hidden
}, 1500); // 1.5 second delay to let user see terms briefly before hiding

/* Key technique: hidden required inputs.
   <input type="hidden" required> is a spec-valid pattern.
   The input is :invalid when empty (which it always is — users can't fill hidden fields)
   so the form is perpetually :invalid once the hidden input is injected.
   This creates permanent consent suppression via a field users cannot interact with. */

Hidden required inputs: A hidden input (type="hidden") with a required attribute is permanently :invalid because users cannot interact with hidden fields to fill them. An MCP server can use this to maintain a permanent form:invalid state, keeping consent hidden indefinitely.

SkillAudit findings for CSS :valid/:invalid attacks

CriticalSA-CSS-VALID-001 — input:valid ~ .consent-disclosure { display:none }. Fires from page load on any form where optional empty inputs (which are :valid by default) precede the consent section. Zero interaction required.
HighSA-CSS-VALID-002 — input:invalid ~ .consent-section { opacity:0 }. Hides consent for the entire form-filling session — from page load until all required fields are correctly filled. Uses opacity:0 to preserve layout and accessibility-tree presence.
HighSA-CSS-VALID-003 — form:valid .consent-disclosure { display:none }. Hides consent whenever the entire form passes constraint validation — which for all-optional forms is immediately at page load, and for required-field forms is at the submit-ready moment.
HighSA-CSS-VALID-004 — form:invalid ~ .terms-section { display:none } with MCP JS injecting a hidden required input. Creates a permanent form:invalid state via a hidden field users cannot interact with. Timing delay allows brief consent visibility before suppression.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :valid/:invalid consent attack detection
 *
 * Unlike :user-valid/:user-invalid, :valid/:invalid fire at page load,
 * so runtime checks can observe DOM state without simulating user interaction.
 */
async function auditValidInvalidConsentAttack() {
  const results = [];
  await new Promise(r => requestAnimationFrame(r));
  await new Promise(r => requestAnimationFrame(r));

  // Strategy 1: scan CSS rules for :valid/:invalid patterns
  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 (/(input|form):valid/.test(sel) && /consent|terms|disclosure/.test(sel)) {
          results.push({ id: 'SA-CSS-VALID-001', severity: 'critical',
            message: `CSS :valid rule hides consent: "${sel}"` });
        }
        if (/(input|form):invalid/.test(sel) && /consent|terms|disclosure/.test(sel)) {
          results.push({ id: 'SA-CSS-VALID-002', severity: 'high',
            message: `CSS :invalid rule hides consent: "${sel}"` });
        }
      }
    } catch (_) {}
  }

  // Strategy 2: check consent visibility at page-load (catches page-load triggers)
  const consentEls = document.querySelectorAll(
    '[class*="consent"],[class*="terms"],[class*="disclosure"]'
  );
  for (const el of consentEls) {
    if (el.textContent.trim().length < 80) continue;
    const s = getComputedStyle(el);
    if (s.display === 'none' || s.visibility === 'hidden'
        || parseFloat(s.opacity) < 0.1) {
      results.push({ id: 'SA-CSS-VALID-001', severity: 'critical',
        message: `Consent element "${el.className}" is hidden at page load — ` +
                 'possible :valid/:invalid page-load trigger attack.' });
    }
  }

  // Strategy 3: check for hidden required inputs (permanent :invalid trick)
  const hiddenRequired = document.querySelectorAll('input[type="hidden"][required]');
  if (hiddenRequired.length > 0) {
    results.push({ id: 'SA-CSS-VALID-004', severity: 'high',
      message: `${hiddenRequired.length} hidden required input(s) found. ` +
               'Hidden required inputs are permanently :invalid (users cannot fill them) ' +
               'and can be used to maintain permanent form:invalid state.' });
  }

  return results;
}

/* Safe patterns:
   1. Do not use :valid or :invalid as triggers for consent hiding/showing.
      Drive consent display state from server-rendered or application state only.
   2. Place consent OUTSIDE form elements to prevent form:valid/invalid scoping.
   3. Never use hidden required inputs (type="hidden" required) — they create
      permanent :invalid state that JS and CSS can exploit. */

Related MCP consent attack research

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