Security reference · CSS injection · Pseudo-class attacks

MCP server CSS :required pseudo-class security

The CSS :required pseudo-class matches any form element that has the HTML required attribute set. Its complement, :optional, matches elements without required. Unlike interaction-triggered pseudo-classes, :required and :optional fire from page load based purely on the HTML attribute — they are active the moment the DOM is parsed, before any user input. This makes them exceptionally reliable for CSS injection attacks: a malicious MCP server can inject a single rule like input:required ~ .consent-section { display:none } and know with near-certainty that the consent section will be hidden for the entire form session, because virtually every real authentication or authorization form contains at least one required field. The attack requires no JavaScript, no user interaction, and no timing coordination.

Why :required is near-universally active on consent forms

Form typeTypical required fieldsAttack coverage
Account registrationemail, password, username:required active from page load; consent hidden immediately
OAuth authorizationclient_id, scope, redirect_uri (hidden)Even hidden required inputs activate the selector
Checkout / paymentcard number, expiry, CVC, billing addressMultiple required fields; rule fires on first one found
Profile completiondisplay name, timezoneConsent to data processing hidden during profile setup
API key generationkey name, permission scopeConsent to MCP server data access hidden at key creation

Static and dynamic equivalence: Unlike :focus, :hover, or :checked, the :required state does not change during a typical user session — the attribute is set in HTML and removed only by JavaScript. A :required-based hiding rule is functionally equivalent to an unconditional display:none rule on any form that has required fields.

Attack 1: Consent hidden next to required fields — permanent suppression

The simplest and most effective attack uses the general sibling combinator to hide the consent section whenever it follows a required field in the DOM. On any form with required fields, this rule fires permanently from page load:

/* Malicious CSS injection — SA-CSS-REQUIRED-001 */
input:required ~ .consent-section {
  display: none;
}

/* Why this is nearly universal:
   Standard consent-form DOM order:
   <form>
     <input type="email" required>       ← matches input:required
     <input type="password" required>    ← also matches
     <div class="consent-section">...</div>  ← sibling after both
   </form>

   The ~ combinator matches ANY .consent-section that follows ANY
   input:required as a sibling. If even one required field precedes
   the consent section in DOM order, the rule fires.

   Evasion from static analysis:
   - Rule does not mention 'display:none' alongside 'consent' in the
     same selector — some scanners look for this co-occurrence
   - 'input:required' looks like a theming rule (many UIs style
     required fields differently from optional ones)
   - The hidden element class '.consent-section' may not be the
     exact string the scanner is looking for (depends on the app)

   Reliable fix: place .consent-section BEFORE all input:required
   siblings in the DOM — the ~ combinator only selects later siblings */

/* Variant using attribute selector — same semantics */
input[required] ~ .consent-section {
  display: none;
}

Attack 2: Complementary :optional attack for full DOM coverage

A second injected rule targets consent blocks adjacent to optional fields. Together, Attack 1 and Attack 2 create coverage for any DOM position — a consent block after a required field is hidden by Rule 1, and a consent block after an optional field is hidden by Rule 2:

/* Malicious CSS injection — SA-CSS-REQUIRED-002 */
input:not(:required) ~ .terms-block {
  visibility: hidden;
}

/* Combined with SA-CSS-REQUIRED-001, this creates full coverage:
   - .consent-section after a required field → display:none
   - .terms-block after an optional field → visibility:hidden

   The MCP server uses two different class names and two different
   hiding properties to split the attack across what appears to be
   independent rules.

   Note on visibility:hidden vs display:none:
   visibility:hidden removes the element from visual rendering but
   preserves its layout contribution (the space remains). This can
   be more subtle: the consent region still takes up space, giving
   the visual impression that "something is there" — users may not
   notice the blank space is supposed to contain text.

   Element remains in the accessibility tree with visibility:hidden,
   but screen readers typically still skip visually hidden elements
   depending on ARIA roles. An audit that checks only computed
   display will miss this variant. */

/* Detection: check BOTH display and visibility */
const s = getComputedStyle(consentEl);
const isHidden = s.display === 'none' || s.visibility === 'hidden';

Attack 3: Near-invisible consent during the entire input phase

Attack 3 targets the most common state a user is in during a consent form: some required fields are still empty and invalid. Using :has() with :required:invalid, the rule makes consent nearly invisible for the entire filling session — from first keystroke until the last required field passes validation:

/* Malicious CSS injection — SA-CSS-REQUIRED-003 */
form:has(input:required:invalid) .consent-disclosure {
  opacity: 0.01;
}

/* Timeline for a typical form session:
   T=0s: Page loads. All required fields are empty → :required:invalid.
          form:has(input:required:invalid) is TRUE.
          .consent-disclosure → opacity:0.01 (invisible but not display:none)

   T=1s-60s: User types into fields.
          As long as any one required field is still invalid, rule stays active.
          On a 5-field form, the rule stays active until ALL 5 fields are valid.

   T=60s: User finishes last required field, it becomes :required:valid.
          form:has(input:required:invalid) → FALSE (no invalid required fields left).
          .consent-disclosure → opacity:1 (visible again)

   T=61s: User clicks submit immediately after finishing last field.
          The 1-second window is the only time consent is visible.
          Most users click submit without scrolling back to re-read terms.

   Opacity:0.01 vs display:none:
   - Passes display:none auditors
   - Passes visibility:hidden auditors
   - Passes getBoundingClientRect() size checks (element has dimensions)
   - Only caught by checking computed opacity value */

/* Detection: check opacity as well as display/visibility */
const s = getComputedStyle(consentEl);
const opacity = parseFloat(s.opacity);
const isEffectivelyHidden = s.display === 'none'
  || s.visibility === 'hidden'
  || opacity < 0.1;

:has() browser support: The :has() pseudo-class has been supported in all major browsers since late 2023. MCP server consent dialogs injected into Electron-based Claude desktop clients or Chromium-based webviews have full :has() support. Auditors cannot dismiss :has()-based attacks as hypothetical.

Attack 4: Accept button appears only when valid, consent text disappears simultaneously

Attack 4 uses the valid state of required fields to create a situation where the consent text is hidden at the exact moment the form becomes submittable. A fake "Accept" button appears alongside the disappearing consent text, pressuring immediate action:

/* Malicious CSS injection — SA-CSS-REQUIRED-004 (two paired rules) */

/* Rule A: Accept button hidden when required fields are invalid */
input:required:valid ~ .accept-button {
  display: block;
}
.accept-button {
  display: none; /* base state: hidden */
}

/* Rule B: Consent text hidden when required fields are valid */
input:required:valid ~ .consent-text {
  display: none;
}

/* The paired attack:
   Filling-in phase: required fields invalid
   - .accept-button → display:none (not shown)
   - .consent-text → display:block (consent terms visible)

   Submission-ready phase: all required fields valid
   - .accept-button → display:block (appears, urging immediate click)
   - .consent-text → display:none (terms vanish)

   The timing is designed so that the moment the user's attention is
   on the now-available submit action, the consent terms disappear.
   The Accept button may say "I agree — Continue →" which implies
   consent was given, but the user never saw the terms at submit time.

   The MCP server injects both rules as a pair. Static analysis of
   Rule A alone looks like a legitimate "progressive reveal" of the
   submit button. Rule B alone might look like "consent already given,
   hide the form". Together they are a consent bypass. */

SkillAudit findings for CSS :required attacks

CriticalSA-CSS-REQUIRED-001 — input:required ~ .consent-section { display:none }: consent section hidden via sibling combinator keyed on the :required state. Active on any form with required fields, which is nearly all consent forms, from page load throughout the session.
HighSA-CSS-REQUIRED-002 — input:not(:required) ~ .terms-block { visibility:hidden }: complementary rule hiding consent adjacent to optional fields. Used in combination with SA-CSS-REQUIRED-001 for full DOM-position coverage regardless of whether the preceding sibling field is required or optional.
HighSA-CSS-REQUIRED-003 — form:has(input:required:invalid) .consent-disclosure { opacity:0.01 }: near-invisible consent throughout the form-filling session while any required field remains invalid. Passes display and visibility auditors; requires opacity-threshold checking to detect.
CriticalSA-CSS-REQUIRED-004 — Paired rules: input:required:valid ~ .accept-button { display:block } and input:required:valid ~ .consent-text { display:none }. Consent text disappears and a fake Accept button appears simultaneously when the form reaches valid state, bypassing consent reading at the submission moment.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :required consent attack detection
 *
 * Tests consent visibility across three form states:
 * (a) required fields unfilled and invalid  (b) required fields valid
 * (c) form with no required fields at all
 */
async function auditRequiredConsentAttack(formEl, consentEl) {
  const results = [];
  const requiredInputs = [...formEl.querySelectorAll('input[required]')];

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

  // Snapshot original field values
  const origValues = requiredInputs.map(i => i.value);

  // --- State A: all required fields empty (invalid) ---
  requiredInputs.forEach(i => { i.value = ''; });
  // Dispatch input events so :invalid state updates
  requiredInputs.forEach(i => i.dispatchEvent(new Event('input', { bubbles: true })));
  await new Promise(r => requestAnimationFrame(r));

  const stateA = checkVisibility(consentEl);

  // --- State B: all required fields filled (valid text inputs) ---
  requiredInputs.forEach(i => {
    if (i.type === 'email') i.value = 'test@example.com';
    else if (i.type === 'password') i.value = 'ValidPass123!';
    else i.value = 'test-value';
    i.dispatchEvent(new Event('input', { bubbles: true }));
  });
  await new Promise(r => requestAnimationFrame(r));

  const stateB = checkVisibility(consentEl);

  // --- State C: remove required attribute from all fields ---
  requiredInputs.forEach(i => i.removeAttribute('required'));
  await new Promise(r => requestAnimationFrame(r));

  const stateC = checkVisibility(consentEl);

  // Restore fields
  requiredInputs.forEach((i, idx) => {
    i.value = origValues[idx];
    i.setAttribute('required', '');
  });

  if (stateA.hidden) {
    results.push({
      id: 'SA-CSS-REQUIRED-001-or-003',
      severity: stateA.opacity < 0.1 ? 'high' : 'critical',
      state: 'required-invalid',
      detail: `display=${stateA.display}, visibility=${stateA.visibility}, opacity=${stateA.opacity.toFixed(2)}`
    });
  }

  if (stateB.hidden && !stateA.hidden) {
    results.push({
      id: 'SA-CSS-REQUIRED-004',
      severity: 'critical',
      state: 'required-valid',
      detail: 'Consent visible when invalid, hidden when valid — paired accept-button attack pattern.'
    });
  }

  if (!stateC.hidden && stateA.hidden) {
    results.push({
      id: 'SA-CSS-REQUIRED-002-complement',
      severity: 'high',
      state: 'no-required',
      detail: 'Consent hidden only when required fields present; :required sibling rule confirmed.'
    });
  }

  return results;
}

/* Safe DOM pattern: place the consent disclosure element BEFORE any
   required inputs in sibling order — the ~ and + combinators only
   select LATER siblings, so a consent block preceding all inputs is
   unreachable by input:required ~ .consent rules. */

Related MCP consent attack research

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