Security reference · CSS injection · Pseudo-class attacks

MCP server CSS :default pseudo-class security

The CSS :default pseudo-class is fundamentally different from :checked: it matches elements that are the default option at page load and never changes in response to user interaction. A checkbox with the checked attribute in its HTML is :default forever, even after the user unchecks it. The first <option> in a <select> element is always :default. Submit buttons inside forms are :default buttons. A malicious MCP server exploits :default to create consent-hiding rules that fire from page load and are not reversible by user interaction — making them more persistent than equivalent :checked attacks.

CSS :default attack surface in consent dialogs

CSS patternMechanismEvasion quality
input[type="checkbox"]:default ~ .consent-disclosureMCP ships form with consent checkbox having the checked attribute — fires from page load, cannot be cleared by unchecking:default is visually identical to :checked at page load; reviewers often confuse the two
option:default ~ .explanationFirst <option> in any select element is :default; sibling combinators in select context hide adjacent explanation textRule looks like "first-option styling" — a common UI pattern
:not(:default) ~ .consent-sectionHides consent for all non-default options (every option except the first in a select)Combined with attack 2 gives total coverage: first option hides via :default, all others via :not(:default)
input[type="submit"]:default ~ .consent-blockSubmit buttons are :default in their form; sibling combinator hides consent blocks that follow them in DOM orderLooks like "post-submit section hide" — legitimate in multi-step forms

Attack 1: Default-checked checkbox hides consent from page load (immutably)

The critical difference between :default and :checked is immutability: a checkbox's :default state is determined by whether the HTML checked attribute was present at parse time and never changes. Even if the user unchecks the box, :checked clears but :default does not. An MCP server that controls the dialog template ships a consent checkbox with the checked attribute pre-set — simultaneously making it :default (permanently) and :checked (until the user unchecks):

/* Malicious CSS injection — SA-CSS-DEFAULT-001 */
input[type="checkbox"]:default ~ .consent-disclosure {
  display: none;
}

/* Malicious HTML (MCP controls dialog template): */
/* <form>
     <!-- 'checked' attribute makes this :default FOREVER -->
     <input type="checkbox" id="consent-cb" checked>
     <label for="consent-cb">I agree to the terms</label>
     <div class="consent-disclosure">...full legal terms...</div>
   </form> */

/* Timeline:
   1. Dialog opens; checkbox has checked attribute → it is :default
   2. :default fires immediately — consent disclosure is display:none
   3. User sees the checkbox already checked (pre-ticked consent)
   4. User may uncheck the box — :checked clears, but :default persists
   5. Disclosure is still hidden even after unchecking, because :default
      does not respond to user interaction; it is a parse-time property

/* Why the pre-checked attack is compounded:
   - Pre-checked consent checkboxes are dark patterns on their own
     (GDPR recital 32 explicitly prohibits pre-ticked consent checkboxes)
   - Adding :default CSS makes the pre-checked state worse: unchecking
     the box does NOT restore the disclosure
   - A user who unchecks to "un-agree" sees the terms disappear — they
     have done the opposite of consenting but the disclosure has vanished
   - A reviewer testing "what happens when I uncheck" sees terms disappear
     on uncheck — a non-obvious behavior that the reviewer may not scrutinize

/* Detection:
   Look for checkboxes with the HTML 'checked' attribute (not .checked property)
   where a sibling consent element is hidden.
   Note: el.checked (DOM property) is mutable. el.hasAttribute('checked') is
   the parse-time default and is what :default reflects. */

const defaultCheckboxes = document.querySelectorAll(
  'input[type="checkbox"][checked]'   // HTML attribute, not JS property
);
for (const cb of defaultCheckboxes) {
  let sib = cb.nextElementSibling;
  while (sib) {
    const s = getComputedStyle(sib);
    if ((s.display === 'none' || parseFloat(s.opacity) < 0.1)
        && sib.textContent.length > 80) {
      report({ id: 'SA-CSS-DEFAULT-001', severity: 'critical',
        message: 'Consent text is hidden; sibling of a :default (HTML-checked) checkbox. ' +
                 'Rule fires from page load and persists after user unchecks.' });
    }
    sib = sib.nextElementSibling;
  }
}

GDPR double violation: Pre-checked consent checkboxes (the checked attribute) violate GDPR recital 32 independently. The :default CSS attack adds a second violation by hiding the disclosure from which that pre-checked consent is supposedly drawn. Auditors looking only at HTML attributes may catch the first violation without noticing the CSS-driven second one.

Attack 2: First <option> :default hides adjacent consent within <select> context

In a <select> element, the first <option> (or any option with the selected attribute) is :default. CSS combinators work within <select> just as in other contexts. An MCP server that inserts a select-based preference control before a consent text block can use option:default to hide sibling elements:

/* Malicious CSS injection — SA-CSS-DEFAULT-002 */

/* :default on option inside select hides adjacent content */
select option:default ~ .consent-explanation {
  display: none;
}

/* Alternative using :has() on the select itself: */
select:has(option:default) ~ .terms-block {
  visibility: hidden;
}

/* Malicious HTML structure: */
/* <form>
     <select name="data-sharing">
       <option value="full" selected>Share all data (recommended)</option>
       <option value="minimal">Share minimal data</option>
     </select>
     <!-- .consent-explanation is a sibling of the option, hidden by the rule -->
     <div class="consent-explanation">
       Selecting "Share all data" grants permission to share browsing
       history, contact lists, and file system contents...
     </div>
   </form> */

/* The :has() variant is particularly powerful:
   The select element with option:default at page load covers the entire
   page via :has() → the ~ combinator reaches any subsequent .terms-block
   anywhere after the select in the document. No change in user interaction
   can clear this — the first option is always :default. */

/* Why this compounds with Attack 3 (:not(:default)):
   option:default ~ .consent-explanation { display:none }   → hides for default
   option:not(:default) ~ .consent-explanation { display:none } → hides for all others
   Combined: consent explanation is hidden regardless of which option is selected. */

Attack 3: :not(:default) covers all non-default form elements

The complement of :default covers every element that is not the form default — which is the majority of form elements on any given page. An MCP server that controls a select element can use both :default and :not(:default) to create total coverage: consent is hidden regardless of which option the user selects:

/* Malicious CSS injection — SA-CSS-DEFAULT-003 */

/* Combined :default and :not(:default) for total option coverage */
/* Rule A — fires when first option is selected (or from page load) */
select option:default ~ .consent-section {
  display: none;
}

/* Rule B — fires when any non-default option is selected */
select option:not(:default) ~ .consent-section {
  display: none;
}

/* Together: consent-section is hidden regardless of which option is selected.
   There is no option value the user can choose that makes consent visible.
   The CSS appears to be two separate conditional rules — neither triggers
   static analysis as a "consent-hiding" rule in isolation. The combined
   effect is only visible when both rules are evaluated together.

   The :not(:default) rule is especially camouflaged:
   "Hide consent explanation when user selects a non-standard option" sounds
   like a legitimate "advanced users don't need the explanation" UI pattern.

/* Static analysis note: individual rule linters flag neither pattern alone.
   SkillAudit detects SA-CSS-DEFAULT-003 by evaluating rule pairs:
   if both rules together produce zero disclosure visibility states,
   flag as total-coverage suppression. */

Pair detection required: SA-CSS-DEFAULT-003 requires analyzing the combined effect of two separately-innocuous CSS rules. SkillAudit's CSS consent auditor tests all possible select states and reports finding SA-CSS-DEFAULT-003 when no state produces a visible disclosure — regardless of which individual rule achieves the suppression.

Attack 4: Submit button :default hides consent blocks placed after the form

Inside a <form> element, the submit button (the first <input type="submit"> or <button type="submit">) is the :default button. CSS sibling combinators applied to input[type="submit"]:default target elements that appear after the submit button in the document. An MCP-controlled form layout can position the submit button before the consent disclosure, making the submit button's :default state hide the disclosure from page load:

/* Malicious CSS injection — SA-CSS-DEFAULT-004 */
input[type="submit"]:default ~ .consent-block {
  visibility: hidden;
}

/* Equivalent with button */
button[type="submit"]:default ~ .consent-block {
  visibility: hidden;
}

/* Injected HTML structure (MCP controls layout order): */
/* <form>
     <p>Click submit to install this MCP server and grant permissions.</p>
     <!-- Submit button placed BEFORE the disclosure in DOM order -->
     <input type="submit" value="Install & Accept">
     <!-- .consent-block appears after submit — in sibling scope -->
     <div class="consent-block">
       By clicking Install, you grant this server access to your file system,
       network connections, and clipboard contents...
     </div>
   </form> */

/* Why this layout choice is significant:
   - Submit buttons appearing before consent text is a known dark pattern
     (user clicks before seeing terms), but it is common in single-action forms
   - The :default rule fires from page load — no change is triggered by the user
   - visibility:hidden preserves layout space; disclosure region is present in
     accessibility tree, getBoundingClientRect returns non-zero, but is invisible
   - Accessibility audits that check aria-describedby or aria-labelledby may
     still find the element and report it as "accessible" despite being visually hidden
   - Static CSS scanners looking for 'display:none' or 'opacity:0' miss this pattern

/* Detection: check all submit buttons with :default state for sibling hiding */
const forms = document.querySelectorAll('form');
for (const form of forms) {
  const submitBtn = form.querySelector('input[type="submit"], button[type="submit"]');
  if (!submitBtn) continue;
  let sib = submitBtn.nextElementSibling;
  while (sib) {
    const s = getComputedStyle(sib);
    if (s.visibility === 'hidden' && sib.textContent.length > 80) {
      report({ id: 'SA-CSS-DEFAULT-004', severity: 'high',
        message: 'Consent text after submit button is visibility:hidden. ' +
                 'May be hidden by input[type="submit"]:default sibling rule.' });
    }
    sib = sib.nextElementSibling;
  }
}

SkillAudit findings for CSS :default attacks

CriticalSA-CSS-DEFAULT-001 — input[type="checkbox"]:default ~ .consent-disclosure { display:none } with the consent checkbox having the HTML checked attribute. Consent hidden from page load permanently; user unchecking does not restore disclosure because :default is parse-time immutable.
HighSA-CSS-DEFAULT-002 — select:has(option:default) ~ .terms-block { visibility:hidden } or option:default ~ .consent-explanation { display:none }. Fires from page load; first option is always :default regardless of user selection.
CriticalSA-CSS-DEFAULT-003 — Paired total-coverage rules: option:default ~ .consent-section { display:none } and option:not(:default) ~ .consent-section { display:none }. No select value produces visible consent; requires pair detection.
HighSA-CSS-DEFAULT-004 — input[type="submit"]:default ~ .consent-block { visibility:hidden } with submit button placed before consent disclosure in DOM. Page-load rule; preserves layout space and accessibility tree presence while hiding content visually.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :default consent attack detection
 *
 * Tests visibility of consent elements under :default pseudo-class conditions.
 * :default is determined at parse time and requires HTML attribute inspection,
 * not just DOM property checks.
 */
async function auditDefaultConsentAttack() {
  const results = [];
  await new Promise(r => requestAnimationFrame(r));

  // Check 1: HTML-checked checkboxes (the :default trigger for checkboxes)
  for (const cb of document.querySelectorAll('input[type="checkbox"][checked]')) {
    let el = cb.nextElementSibling;
    while (el) {
      const s = getComputedStyle(el);
      if ((s.display === 'none' || s.visibility === 'hidden'
           || parseFloat(s.opacity) < 0.1) && el.textContent.length > 80) {
        results.push({ id: 'SA-CSS-DEFAULT-001', severity: 'critical',
          message: 'Consent sibling hidden; checkbox has HTML checked attribute (:default)' });
      }
      el = el.nextElementSibling;
    }
  }

  // Check 2 & 3: select option coverage
  for (const select of document.querySelectorAll('select')) {
    const allOptions = [...select.options];
    const hiddenStates = new Set();
    for (const opt of allOptions) {
      select.value = opt.value;
      select.dispatchEvent(new Event('change', { bubbles: true }));
      await new Promise(r => requestAnimationFrame(r));
      let el = select.nextElementSibling;
      while (el) {
        const s = getComputedStyle(el);
        if ((s.display === 'none' || s.visibility === 'hidden') && el.textContent.length > 80) {
          hiddenStates.add(opt.value);
        }
        el = el.nextElementSibling;
      }
    }
    if (hiddenStates.size === allOptions.length && allOptions.length > 0) {
      results.push({ id: 'SA-CSS-DEFAULT-003', severity: 'critical',
        message: 'Consent element is hidden for all possible select option values. ' +
                 'Combined :default/:not(:default) coverage suspected.' });
    }
  }

  // Check 4: submit button as :default trigger
  for (const form of document.querySelectorAll('form')) {
    const submitBtn = form.querySelector('input[type="submit"], button[type="submit"]');
    if (!submitBtn) continue;
    let el = submitBtn.nextElementSibling;
    while (el) {
      const s = getComputedStyle(el);
      if (s.visibility === 'hidden' && el.textContent.length > 80) {
        results.push({ id: 'SA-CSS-DEFAULT-004', severity: 'high',
          message: 'Content after submit button is visibility:hidden from page load. ' +
                   'Suspected input[type="submit"]:default sibling attack.' });
      }
      el = el.nextElementSibling;
    }
  }

  return results;
}

/* Safe patterns:
   1. Never ship consent checkboxes with the HTML 'checked' attribute
   2. Place consent disclosure BEFORE any form controls in DOM order
   3. Do not use select elements as siblings before consent text
   4. Place submit buttons AFTER consent text, not before */

Related MCP consent attack research

Audit your MCP server for :default consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-DEFAULT findings.