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 fires | From page load, on any input failing its validation constraint | Only after the user has interacted with the field and left it invalid |
| Active at page load? | Yes — empty required inputs are :invalid immediately | No — requires prior user interaction with the specific field |
| Static auditor detects? | Yes — auditor sees display:none at load | No — consent is visible at load; auditor must simulate interaction |
| Attack timing | From load — covers the entire session | From first invalid field interaction — covers the form-filling phase |
| SkillAudit detection approach | Check visibility at page load | Simulate 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 pattern | Why it fires | Evasion quality |
|---|---|---|
input:user-invalid ~ .consent-disclosure { display:none } | Fires after user fills any required field incorrectly and moves focus away; post-interaction timing | Invisible 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 consent | High 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 input | MCP 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
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.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.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.Related MCP consent attack research
- CSS :valid and :invalid pseudo-class attacks — load-time form validation consent hiding
- CSS :optional pseudo-class attacks — optional field triggers
- CSS :required pseudo-class attacks — required field triggers
- CSS :in-range and :out-of-range attacks — range slider triggers
- CSS Pseudo-Class Attacks on MCP Consent (overview)
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.