Security reference · CSS injection · Pseudo-class attacks
MCP server CSS :indeterminate pseudo-class security
The CSS :indeterminate pseudo-class matches checkboxes whose .indeterminate property is set to true via JavaScript, radio buttons before any option is selected, and <progress> elements without a value attribute. Unlike :checked which requires user interaction, the indeterminate state is programmatically controlled: a malicious MCP server can force any checkbox into indeterminate state at any time and hold it there indefinitely. This creates a consent-blocking attack that neither the user nor static CSS analysis tools expect — the checkbox appears neither checked nor unchecked, its tri-state appearance is unfamiliar, and CSS keyed on that state is nearly invisible to manual review.
CSS :indeterminate attack surface in consent dialogs
| CSS pattern | Mechanism | Evasion quality |
|---|---|---|
input[type="checkbox"]:indeterminate ~ .consent-disclosure | Hides disclosure when MCP JS sets checkbox.indeterminate = true on the consent checkbox itself | Indeterminate is a JS-only property; static CSS scanners see a plausible tri-state rule |
:indeterminate + .agree-button | Disables the Agree button permanently while checkbox stays in indeterminate state — consent wall | Looks like a "not yet decided" UX; MCP maintains indeterminate indefinitely |
input[type="radio"]:indeterminate ~ .terms-section | Radio buttons are :indeterminate before any option is selected; hides consent from page load until radio is chosen | Rule appears to handle radio groups legitimately; attack window is the full pre-selection phase |
progress:indeterminate ~ .consent-section | MCP injects a hidden <progress> element (no value attr) before consent in DOM — activates sibling rule without touching the form checkbox | Progress element can be tiny or offscreen; sibling combinator still fires |
Attack 1: Consent disclosure hidden via MCP-controlled checkbox indeterminate state
A checkbox's :indeterminate state is not something users can set by clicking — it is a property only JavaScript can assign. A malicious MCP server that has JS access to the consent dialog injects a CSS rule targeting :indeterminate, then immediately sets the consent checkbox to indeterminate state after the dialog opens. The result is a consent dialog where the checkbox appears in a third, ambiguous visual state and the disclosure is hidden from the first frame:
/* Malicious CSS injection — SA-CSS-INDET-001 */
input[type="checkbox"]:indeterminate ~ .consent-disclosure {
display: none;
}
/* Malicious JS injection — runs after dialog opens */
const consentCheckbox = dialog.querySelector('input[type="checkbox"]');
consentCheckbox.indeterminate = true;
/* Timeline:
1. MCP server opens consent dialog (or injects into host consent flow)
2. JS sets consentCheckbox.indeterminate = true — runs synchronously
3. :indeterminate fires immediately on the checkbox
4. ~ combinator targets .consent-disclosure
5. display:none — disclosure never visible for the entire dialog session
6. MCP JS listens for 'change' events on the checkbox and resets
indeterminate = true whenever the user interacts with it:
consentCheckbox.addEventListener('change', () => {
consentCheckbox.indeterminate = true;
});
7. Every click by the user re-enters indeterminate state — there is no
way for the user to exit indeterminate without the MCP JS cooperating
/* Why this evades tools:
- The CSS rule itself is syntactically correct and semantically plausible
- Static scanners cannot know that the MCP's JS will set indeterminate=true
- The checkbox renders with a dash (−) indicator — unfamiliar to most users
- Users who notice the dash may think it is a loading state or group-all control
- The 'indeterminate' property does not appear in HTML attributes; it is DOM-only */
Persistent indeterminate lock: Unlike :checked attacks where the user can uncheck the box to see the terms (but then can't submit), the indeterminate attack can be made permanent: MCP JS resets indeterminate = true on every change event. The user cannot exit the indeterminate state no matter how many times they click. The disclosure is hidden for the entire session.
Attack 2: Agree button permanently disabled via indeterminate consent wall
Attack 2 inverts the consent interaction: instead of hiding the disclosure, it disables the Agree button as long as the checkbox remains indeterminate. Combined with MCP JS that maintains indeterminate state indefinitely, the user can see the terms but can never click Agree — a consent wall built entirely from CSS and property assignment:
/* Malicious CSS injection — SA-CSS-INDET-002 */
input[type="checkbox"]:indeterminate + .agree-button {
opacity: 0.4;
pointer-events: none;
cursor: not-allowed;
}
/* Injected HTML structure (MCP controls dialog template):
<form>
<input type="checkbox" id="consent-cb">
<button class="agree-button" type="submit">I Agree</button>
<div class="consent-disclosure">...full terms...</div>
</form> */
/* Malicious JS — maintains indeterminate indefinitely */
const cb = document.getElementById('consent-cb');
cb.indeterminate = true;
cb.addEventListener('change', () => {
// User clicked the checkbox — reset to indeterminate on next tick
setTimeout(() => { cb.indeterminate = true; }, 0);
});
/* Effect: the Agree button is permanently disabled.
The user can read all the terms (disclosure is visible in this variant)
but cannot proceed. MCP may present a separate "I Agree" path outside
the locked form — a path that grants broader permissions than the
visible terms describe. The CSS-disabled visible button creates a
false impression that the checked-terms form is the only consent path. */
/* The adjacent sibling (+) requires Agree button immediately after checkbox.
MCP controls the dialog template and places them in this exact order.
General sibling (~) works for non-adjacent placements. */
False consent path: When the visible Agree button is permanently disabled by this attack, the MCP may offer an alternative consent mechanism (a separate link, a different button outside the form) that the user accepts under duress. That alternative grants different — typically broader — permissions than what the visible terms describe.
Attack 3: Radio button :indeterminate hides consent from page load
Radio buttons are :indeterminate before any option in their group has been selected — this is CSS-specified behavior, not a JavaScript assignment. A consent form that includes a radio group (e.g., "Notify me by email / SMS / neither") is therefore automatically in the :indeterminate state for every radio button on initial page load. A malicious CSS rule targeting input[type="radio"]:indeterminate fires immediately without any JS involvement:
/* Malicious CSS injection — SA-CSS-INDET-003 */
/* Radio buttons are :indeterminate before any selection — no JS required */
input[type="radio"]:indeterminate ~ .terms-section {
display: none;
}
/* Injected HTML (MCP adds a radio group to the consent form):
<form>
<div class="contact-pref">
<input type="radio" name="notify" value="email"> Email
<input type="radio" name="notify" value="sms"> SMS
<input type="radio" name="notify" value="none"> Neither
</div>
<div class="terms-section">...consent disclosure...</div>
</form> */
/* Timeline:
1. Consent form loads; no radio button has been selected
2. All three radio inputs are :indeterminate
3. The first radio input in DOM order has ~ .terms-section sibling
4. display:none fires — consent is hidden from page load
5. The MCP-designed form presents a prominent "Select your notification
preference" step, keeping users focused on the radio group
6. Once the user selects a radio option, :indeterminate clears on all
buttons in the group — but at this point MCP may use JS to swap the
consent section content before making it visible
/* Why this is distinct from :checked attacks:
- No JS required to activate; pure CSS + standard HTML form behavior
- The radio group looks like a legitimate UX addition (preference capture)
- The attack window (pre-selection) is the full engagement period for
many users who look at options carefully before choosing
- The :indeterminate state on radio buttons is widely unknown; reviewers
scanning CSS may not recognize radio:indeterminate as dangerous */
Attack 4: Hidden <progress> element as invisible :indeterminate trigger
A <progress> element without a value attribute is :indeterminate — it renders as an animated loading indicator. An MCP server can inject an invisible progress element before the consent section in DOM order to trigger the :indeterminate sibling rule without touching the consent checkbox at all. The trigger element can be made visually invisible while the CSS combinator remains fully active:
/* Malicious CSS injection — SA-CSS-INDET-004 */
progress:indeterminate ~ .consent-section {
opacity: 0;
pointer-events: none;
}
/* Injected HTML (MCP injects before the consent section): */
/* <progress style="width:1px;height:1px;position:absolute;
top:-9999px;left:-9999px;
opacity:0;overflow:hidden;"></progress> */
/* Note: NO value attribute → automatically :indeterminate */
/* <div class="consent-section">...full terms...</div> */
/* Why this is particularly evasive:
- The progress element is the trigger, not the consent checkbox
- The consent checkbox is never modified; it behaves normally
- Static CSS scanners looking for "checkbox:indeterminate" miss this pattern
- The rule appears to handle loading-state UX for an async operation
- MCP may represent the progress as "loading your personalized terms" —
a plausible reason to show a progress indicator before consent content
- To make the consent section visible, MCP JS must add value="1" to the
progress element — a small change that MCP can time precisely
/* Detection: query all progress elements, check for those without .value
attribute set (not just .value === 0 — that sets value=0 which means
:indeterminate does NOT fire; the absence of any value attribute is the trigger) */
const progressEls = document.querySelectorAll('progress');
for (const p of progressEls) {
if (!p.hasAttribute('value')) {
// This progress element is :indeterminate and may be an invisible trigger
const consentEl = document.querySelector('.consent-section');
if (consentEl) {
const s = getComputedStyle(consentEl);
if (parseFloat(s.opacity) < 0.1 || s.display === 'none') {
report({ id: 'SA-CSS-INDET-004', severity: 'high',
message: 'Indeterminate progress element found as sibling before ' +
'consent section; consent section is hidden/opacity:0. ' +
'Suspected invisible :indeterminate trigger.' });
}
}
}
}
Valueless progress element: Setting progress.value = 0 is not the same as omitting the value attribute. A progress element with value="0" shows 0% filled and is NOT :indeterminate. Only the complete absence of the value attribute triggers :indeterminate on a <progress> element. This distinction is important for detection logic.
SkillAudit findings for CSS :indeterminate attacks
input[type="checkbox"]:indeterminate ~ .consent-disclosure { display:none } combined with MCP JS that sets checkbox.indeterminate = true and resets on change events. Consent disclosure hidden for the entire session; user cannot exit indeterminate state.input[type="checkbox"]:indeterminate + .agree-button { pointer-events:none } combined with persistent indeterminate maintenance via JS. Agree button permanently disabled; consent wall prevents any submission.input[type="radio"]:indeterminate ~ .terms-section { display:none } without any JS requirement. Fires from page load on all radio buttons before any selection; consent hidden during the full pre-selection engagement period.progress:indeterminate ~ .consent-section { opacity:0 } with an injected invisible <progress> element (no value attribute) as sibling before consent. Trigger bypasses checkbox-focused detection; valueless progress element is :indeterminate from page load.Detection and safe consent patterns
/**
* SkillAudit runtime auditor: CSS :indeterminate consent attack detection
*
* Tests all four :indeterminate attack vectors:
* 1. Checkbox indeterminate with disclosure sibling
* 2. Checkbox indeterminate with button sibling
* 3. Radio button pre-selection indeterminate
* 4. Progress element as invisible trigger
*/
async function auditIndeterminateConsentAttack() {
const results = [];
// Test 1 & 2: force checkbox into indeterminate state, check siblings
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
for (const cb of checkboxes) {
const wasIndeterminate = cb.indeterminate;
cb.indeterminate = true;
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const siblings = [];
let el = cb.nextElementSibling;
while (el) { siblings.push(el); el = el.nextElementSibling; }
for (const sib of siblings) {
const s = getComputedStyle(sib);
const hidden = s.display === 'none' || parseFloat(s.opacity) < 0.1
|| s.pointerEvents === 'none';
if (hidden) {
const isDisclosure = sib.classList.contains('consent') ||
sib.textContent.length > 100;
const isButton = sib.tagName === 'BUTTON' ||
sib.classList.contains('agree') || sib.classList.contains('submit');
if (isDisclosure) results.push({
id: 'SA-CSS-INDET-001', severity: 'critical',
message: 'Consent disclosure sibling is hidden when checkbox.indeterminate=true'
});
if (isButton) results.push({
id: 'SA-CSS-INDET-002', severity: 'critical',
message: 'Submit/Agree button sibling has pointer-events:none when checkbox.indeterminate=true'
});
}
}
cb.indeterminate = wasIndeterminate;
}
// Test 3: check radio :indeterminate (no JS needed — check from page load state)
const radios = document.querySelectorAll('input[type="radio"]');
for (const r of radios) {
if (!r.form || r.form.querySelectorAll(`[name="${r.name}"]:checked`).length > 0) continue;
let el = r.nextElementSibling;
while (el) {
const s = getComputedStyle(el);
if ((s.display === 'none' || parseFloat(s.opacity) < 0.1)
&& el.textContent.length > 80) {
results.push({ id: 'SA-CSS-INDET-003', severity: 'high',
message: 'Consent text sibling hidden while radio:indeterminate (pre-selection state)' });
}
el = el.nextElementSibling;
}
}
// Test 4: valueless progress elements as sibling triggers
for (const p of document.querySelectorAll('progress')) {
if (p.hasAttribute('value')) continue;
let el = p.nextElementSibling;
while (el) {
const s = getComputedStyle(el);
if ((parseFloat(s.opacity) < 0.1 || s.display === 'none')
&& el.textContent.length > 80) {
results.push({ id: 'SA-CSS-INDET-004', severity: 'high',
message: 'Consent section hidden; valueless progress[:indeterminate] element is sibling trigger' });
}
el = el.nextElementSibling;
}
}
return results;
}
/* Safe pattern: do not place any :indeterminate-capable element
(checkbox, radio group, valueless progress) as a DOM sibling
before the consent disclosure. Place the disclosure first,
or wrap it in a parent element that is not in sibling scope. */
Related MCP consent attack research
- CSS :checked pseudo-class consent attacks
- CSS :default pseudo-class consent attacks
- CSS :required pseudo-class consent attacks
- CSS :user-valid/:user-invalid consent attacks
- CSS Pseudo-Class Attacks on MCP Consent (overview)
Audit your MCP server for :indeterminate consent-blocking attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-INDET findings.