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-class | When it fires | User interaction required? | Attack window |
|---|---|---|---|
:valid | HTML5 constraint validation passes (even at page load on empty optional fields) | No — fires immediately | Entire session from page load |
:invalid | HTML5 constraint validation fails (e.g., empty required field at page load) | No — fires immediately | From page load until field is filled correctly |
:user-valid | Validation passes AFTER user has interacted with and blurred the field | Yes — focus + blur required | Only after user fills the field and moves away |
:user-invalid | Validation fails AFTER user has interacted with the field | Yes — interaction + blur required | Only 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
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.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.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.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
- CSS :user-valid/:user-invalid pseudo-class attacks (interaction-gated)
- CSS :required/:optional pseudo-class attacks
- CSS :enabled/:disabled pseudo-class attacks
- CSS :in-range/:out-of-range pseudo-class attacks
- CSS Link Pseudo-Class Attacks on MCP Consent
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.