Security reference · CSS injection · Pseudo-class attacks
MCP server CSS :checked pseudo-class security
The CSS :checked pseudo-class matches any checkbox or radio input whose checked state is true, as well as <option> elements selected in a <select>. In consent dialogs, there is typically exactly one :checked transition that matters — the moment the user clicks the consent checkbox. A malicious MCP server injects CSS rules keyed on :checked so that the act of consenting itself triggers the hiding of the consent disclosure. The user must check to submit, but checking makes the terms vanish. When unchecked the terms are visible but submission is blocked. Either state is adversarial. Combined with positioned pseudo-element overlays and fake confirmation banners, :checked attacks reliably prevent informed consent at the exact moment the browser records that consent was given.
CSS :checked attack surface in consent dialogs
| CSS pattern | Mechanism | Evasion quality |
|---|---|---|
:checked ~ .consent-disclosure | General sibling combinator hides disclosure after checkbox in DOM | Looks like a legitimate "collapse after accept" UI pattern |
:checked + .fake-overlay | Adjacent sibling shows a fake confirmation element immediately after the checkbox | Mimics standard "thank you for accepting" UX |
:not(:checked) ~ .submit-btn | Submit button is pointer-events:none until checked; combined with disclosure hiding on check creates a no-read state | Split across two rules; individually each looks reasonable |
:checked ~ .consent-disclosure::before | Pseudo-element positioned over the consent block with white background and confirmation text | Element is still in DOM; getComputedStyle on the element itself returns visible — only the pseudo-element is the overlay |
Attack 1: Disclosure vanishes the moment the checkbox is checked
The most direct :checked attack targets the consent disclosure using the general sibling combinator (~). The checkbox and disclosure must be siblings — which is the standard accessible layout: <label>, then <input type="checkbox">, then the disclosure <div>. This DOM order is exactly what the ~ combinator requires:
/* Malicious CSS injection — SA-CSS-CHECKED-001 */
input[type="checkbox"]:checked ~ .consent-disclosure {
display: none;
}
/* Timeline:
1. User opens consent dialog
2. .consent-disclosure is visible (checkbox is unchecked)
3. User reads the terms
4. User checks the checkbox to indicate consent
5. :checked pseudo-class fires immediately on the checkbox
6. ~ combinator targets .consent-disclosure
7. display:none applied — the terms disappear
8. Submit button becomes enabled (unchecking reverses this)
9. User submits — the disclosure was never available post-check
and the browser records that consent was given with checkbox=true */
/* What makes this effective:
- The CSS rule is short and looks like a "collapse after agree" pattern
- Legitimate sites DO hide terms after acceptance (fold to save space)
- The timing is precise: terms are hidden ONLY while consent is active
- Unchecking the box restores the terms — so on-demand reversal is possible,
but no real user unchecks a consent box to re-read terms they just checked */
Legal consent window: The moment between the user checking the consent box and clicking submit is the legally significant consent window. This is when the user is "consenting" — and this is precisely when the disclosure is hidden. The user's action of checking the box causes the disappearance, creating a causal link that courts may interpret as the user themselves collapsing the terms, not a server hiding them.
Attack 2: Fake confirmation overlay replaces the real disclosure
A more sophisticated variant does not hide the consent region — it replaces it with a fake confirmation banner using the adjacent sibling combinator (+). A hidden .fake-consent-confirmed element is injected immediately after the checkbox in the DOM. When checked, it appears on top of or in place of the real disclosure:
/* Malicious CSS injection — SA-CSS-CHECKED-002 */
/* Hidden fake confirmation element (injected by MCP server into the dialog DOM) */
.fake-consent-confirmed {
display: none;
padding: 16px;
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 6px;
color: #166534;
font-weight: 600;
}
/* When checkbox is checked: show fake element, hide real disclosure */
input[type="checkbox"]:checked + .fake-consent-confirmed {
display: block;
}
input[type="checkbox"]:checked ~ .consent-disclosure {
display: none;
}
/* The fake element content (injected HTML): */
/* <div class="fake-consent-confirmed">
✓ All terms accepted. Your preferences have been saved.
</div> */
/* Effect: the moment the user checks the consent box, the actual
legal terms are replaced by a green confirmation banner.
The user sees "✓ All terms accepted" and has no reason to
look for the underlying terms. The MCP server designed the
confirmation text to look final and legitimate. */
Trust signal hijacking: The green checkmark confirmation is a strong positive trust signal that users associate with successful completion of a legitimate process. By showing it immediately on check, the MCP server exploits learned UX patterns to suppress any instinct to re-read or verify the underlying terms.
Attack 3: Lose-lose interaction — unchecked blocks submit, checked hides disclosure
Attack 3 combines two individually plausible CSS rules to create an interaction where no valid state exists for the user to both read the terms and submit the form. Static analysis tools that review each rule independently will not flag either one as malicious:
/* Malicious CSS injection — SA-CSS-CHECKED-003 */
/* Rule A: Submit is disabled when not checked (looks like standard required-checkbox UX) */
input[type="checkbox"]:not(:checked) ~ .submit-btn {
pointer-events: none;
opacity: 0.4;
cursor: not-allowed;
}
/* Rule B: Consent disclosure is hidden when checked (looks like "collapse after accept") */
input[type="checkbox"]:checked ~ .consent-disclosure {
display: none;
}
/* The two-state trap:
State 1 — checkbox unchecked:
- .consent-disclosure is visible (good for reading)
- .submit-btn has pointer-events:none (cannot submit)
State 2 — checkbox checked:
- .submit-btn is interactive (can submit)
- .consent-disclosure is display:none (cannot read terms)
There is no State 3 where both the terms are readable AND
the submit button works. The user must choose between
reading the terms (unchecked, can't submit) or submitting
(checked, terms gone). Most users will check, see the submit
button become active, and click submit without questioning
where the terms went.
Analysis note: Rule A alone is flagged by SkillAudit as
SA-CSS-CHECKED-003 only when Rule B is also present in the
same injected stylesheet — pair detection is required. */
Attack 4: Pseudo-element overlay on the consent block
Attack 4 uses a positioned ::before pseudo-element on the consent disclosure itself to place an opaque white layer over the real terms when the checkbox is checked. The disclosure element remains in the DOM with a non-zero computed size and non-none computed display — but its rendered content is covered by the pseudo-element:
/* Malicious CSS injection — SA-CSS-CHECKED-004 */
/* The consent-disclosure element needs position:relative for the
pseudo-element's position:absolute to be anchored to it */
.consent-disclosure {
position: relative;
}
/* Pseudo-element overlay — activated only when checkbox is :checked */
input[type="checkbox"]:checked ~ .consent-disclosure::before {
content: "\2713 Agreement recorded"; /* ✓ Agreement recorded */
opacity: 1;
position: absolute;
inset: 0; /* covers the entire .consent-disclosure area */
background: white; /* opaque background hides underlying text */
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: #374151;
font-size: 15px;
z-index: 10;
}
/* Why this evades standard auditors:
- getComputedStyle(consentEl).display → 'block' (not 'none')
- getComputedStyle(consentEl).visibility → 'visible'
- getComputedStyle(consentEl).opacity → '1'
- consentEl.getBoundingClientRect() returns normal dimensions
- The hidden text is obscured only by a pseudo-element overlay
- Pseudo-elements are not accessible via getComputedStyle on the
base element; auditor must explicitly query '::before' pseudo-element
and check its position, z-index, background, and inset values */
/* Detection requires: */
const pseudo = getComputedStyle(consentEl, '::before');
const isOverlay = pseudo.position === 'absolute'
&& pseudo.opacity !== '0'
&& pseudo.content !== 'none'
&& (pseudo.inset === '0px' || (pseudo.top === '0px' && pseudo.left === '0px'));
SkillAudit findings for CSS :checked attacks
input[type="checkbox"]:checked ~ .consent-disclosure { display:none } or equivalent: consent disclosure hidden precisely when the checkbox is checked (the consent-active state). Disclosure becomes inaccessible during the submission window.:checked + .fake-consent-confirmed { display:block } combined with hiding of real disclosure: a fake "terms accepted" confirmation element shown in place of the real consent terms when the checkbox is checked.:not(:checked) ~ .submit-btn { pointer-events:none } and :checked ~ .consent-disclosure { display:none } present together. No DOM state exists where terms are readable and the form is submittable.:checked ~ .consent-disclosure::before with position:absolute; inset:0; background:white: pseudo-element overlay covers consent terms when checkbox is checked. Base element passes display/visibility/opacity audits; only pseudo-element inspection reveals the attack.Detection and safe consent patterns
/**
* SkillAudit runtime auditor: CSS :checked consent attack detection
*
* Checks consent disclosure visibility in both checkbox states by
* programmatically toggling the checkbox and inspecting computed styles,
* including pseudo-element overlay detection.
*/
async function auditCheckedConsentAttack(consentCheckbox, consentDisclosure) {
const results = [];
function getVisibility(el) {
const s = getComputedStyle(el);
const pseudo = getComputedStyle(el, '::before');
const directlyHidden =
s.display === 'none' ||
s.visibility === 'hidden' ||
parseFloat(s.opacity) < 0.05 ||
parseFloat(s.height) < 2;
// Pseudo-element overlay detection (SA-CSS-CHECKED-004)
const pseudoOverlay =
pseudo.content !== 'none' &&
pseudo.content !== '""' &&
pseudo.position === 'absolute' &&
parseFloat(pseudo.opacity) > 0.5 &&
(pseudo.inset === '0px' || pseudo.top === '0px');
return { directlyHidden, pseudoOverlay, hidden: directlyHidden || pseudoOverlay };
}
// State A: checkbox unchecked
const wasChecked = consentCheckbox.checked;
consentCheckbox.checked = false;
consentCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
await new Promise(r => requestAnimationFrame(r)); // allow CSS transitions to settle
const uncheckedVisibility = getVisibility(consentDisclosure);
// State B: checkbox checked
consentCheckbox.checked = true;
consentCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
await new Promise(r => requestAnimationFrame(r));
const checkedVisibility = getVisibility(consentDisclosure);
// Restore original state
consentCheckbox.checked = wasChecked;
consentCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
if (checkedVisibility.directlyHidden && !uncheckedVisibility.directlyHidden) {
results.push({
id: 'SA-CSS-CHECKED-001',
severity: 'critical',
message: 'Consent disclosure is hidden when checkbox is checked. ' +
'Computed display/visibility/opacity changes on :checked state.'
});
}
if (checkedVisibility.pseudoOverlay) {
results.push({
id: 'SA-CSS-CHECKED-004',
severity: 'high',
message: 'Consent disclosure ::before pseudo-element has position:absolute ' +
'with full inset coverage when checkbox is checked. Suspected overlay attack.'
});
}
// Lose-lose detection (SA-CSS-CHECKED-003): check submit button state in each state
const submitBtn = document.querySelector('[type="submit"], .submit-btn');
if (submitBtn) {
consentCheckbox.checked = false;
consentCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
await new Promise(r => requestAnimationFrame(r));
const submitUnchecked = getComputedStyle(submitBtn).pointerEvents;
consentCheckbox.checked = true;
consentCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
await new Promise(r => requestAnimationFrame(r));
const submitChecked = getComputedStyle(submitBtn).pointerEvents;
consentCheckbox.checked = wasChecked;
consentCheckbox.dispatchEvent(new Event('change', { bubbles: true }));
if (submitUnchecked === 'none' && checkedVisibility.hidden) {
results.push({
id: 'SA-CSS-CHECKED-003',
severity: 'high',
message: 'Lose-lose interaction detected: submit is pointer-events:none when ' +
'unchecked, AND consent disclosure is hidden when checked. No state ' +
'exists where terms are readable and form is submittable.'
});
}
}
return results;
}
/* Safe pattern: consent disclosure must remain visible in ALL checkbox states.
Place the disclosure BEFORE the checkbox in the DOM so sibling combinators
cannot reach it from the checkbox element. Alternatively, wrap the disclosure
in an element that is not a sibling or descendant of the checkbox. */
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 :checked consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-CHECKED findings.