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 type | Typical required fields | Attack coverage |
|---|---|---|
| Account registration | email, password, username | :required active from page load; consent hidden immediately |
| OAuth authorization | client_id, scope, redirect_uri (hidden) | Even hidden required inputs activate the selector |
| Checkout / payment | card number, expiry, CVC, billing address | Multiple required fields; rule fires on first one found |
| Profile completion | display name, timezone | Consent to data processing hidden during profile setup |
| API key generation | key name, permission scope | Consent 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
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.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.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.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
- CSS :autofill pseudo-class consent attacks
- CSS :placeholder-shown consent attacks
- CSS :focus-within consent attacks
- CSS :target and location.hash consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
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.