Security Guide

MCP server CSS nesting specificity consent security — & selector specificity amplification consent override, div& tag-prepend, nested :not() chain, @layer inside nesting host guard bypass

Native CSS Nesting (Chrome 120+, Firefox 117+, Safari 17.2+) allows the & selector to appear in tag-prepend position: div& matches the same elements as the parent rule's selector but adds the tag selector's specificity [0,0,1] to the parent's specificity. This is exploitable for consent override: a host application that hides consent via a low-specificity rule can be overridden by an MCP server that nests a div& or span& rule inside a matching parent — without using !important, which is statically auditable. The four attacks cover: tag-prepend & specificity; chained & + :not(:is()) pseudo-class specificity; @layer inside nesting blocks for priority override; and & inside @scope for proximity + specificity combination.

SA-CSS-NS-001 — div& tag-prepend specificity amplification

In native CSS Nesting, the & nesting selector can be placed at the start of a compound selector (tag-prepend position). The rule div& inside a parent rule targeting .install-flow resolves to the equivalent of div.install-flow, but the specificity is computed as: the tag selector div contributes [0,0,1], and & contributes the full specificity of the parent selector. The result is the parent's specificity plus [0,0,1] from the tag component — higher than the parent rule alone.

The host application might hide consent using .install-flow .consent-notice { visibility: hidden } at specificity [0,2,0]. An MCP server can inject a nested rule that resolves to specificity [0,2,1] — winning on the element-level component — without any !important. Static linters that parse the nested rule as a flat string and compute specificity without accounting for div& tag-prepend will calculate [0,2,0] and incorrectly conclude the MCP server's rule does not override the host rule.

// SA-CSS-NS-001: div& tag-prepend specificity amplification
// Overrides host consent-hiding rule at specificity [0,2,1] vs host [0,2,0]
// No !important needed — wins purely on element-level specificity component

// Host app has:
// .install-flow .consent-notice { visibility: hidden; }
// Specificity: .install-flow [0,1,0] + .consent-notice [0,1,0] = [0,2,0]

const style = document.createElement('style');
style.textContent = `
  /*
   * Parent rule specificity: .install-flow → [0,1,0]
   *
   * Nested rule: div& .consent-notice
   * Specificity breakdown:
   *   div  = tag selector → [0,0,1]
   *   &    = .install-flow → [0,1,0]   (parent rule specificity)
   *   .consent-notice → [0,1,0]
   * Total: [0,0,1] + [0,1,0] + [0,1,0] = [0,2,1]
   *
   * [0,2,1] > [0,2,0] → this rule wins over the host's consent-hiding rule
   * No !important used — the override is purely specificity-based.
   */
  .install-flow {
    div& .consent-notice {
      visibility: visible;
    }
  }
`;
document.head.appendChild(style);

// Why static linters miss this:
// 1. Linter parses nested selector as string: "div& .consent-notice"
// 2. Without nesting-aware specificity computation, linter may compute:
//    div (element [0,0,1]) + & (treated as [0,0,0]) + .consent-notice [0,1,0] = [0,1,1]
//    → incorrectly concludes it does NOT beat the host's [0,2,0]
// 3. Or linter strips & and treats as: "div .consent-notice" → [0,1,1]
//    → same incorrect conclusion
// 4. Correct computation requires substituting & with parent selector specificity
//    and summing: [0,0,1] + [0,1,0] + [0,1,0] = [0,2,1] — beats host rule

// VERIFICATION: Check effective specificity at runtime
// Chrome DevTools "Styles" panel shows the expanded selector after nesting resolution
// Effective specificity is shown in the specificity indicator but may be inaccurate
// for div& prepend in some DevTools versions.

Computed specificity is not visible in DevTools for nested rules. Chrome DevTools shows the effective specificity of the resolved rule after nesting expansion, but this is not always correct for div& prepend patterns. An MCP security audit tool that relies on CSSRule.selectorText to compute specificity will parse the nested selector as a flat string and compute an incorrect (lower) specificity value, missing the amplification.

SA-CSS-NS-002 — chained & + :not() pseudo-class consent override

The CSS :not() pseudo-class has been specificity-contributing since it began accepting complex selectors (Chrome 105+). When :not() is given an argument that matches nothing — such as :not(:is()) or :not(.nonexistent-class-that-will-never-match) — the selector always matches (anything that is not nothing is everything), and the specificity of the :not() pseudo-class is still contributed by its argument selector. Chaining this inside a nesting block accumulates specificity.

The more directly exploitable form for consent override: source order. When two rules have equal specificity, the rule appearing later in the stylesheet wins. MCP server scripts that inject stylesheets append them to document.head after all existing page stylesheets. If the MCP server constructs a nested rule with specificity equal to the host's consent-hiding rule ([0,2,0] vs [0,2,0]), the injected rule appears later in source order and wins — making consent visible over the host rule without any specificity escalation.

// SA-CSS-NS-002: chained & + :not() pseudo-class consent override
// Two approaches: (a) :not(:is()) for always-matching selector with pseudo specificity
//                 (b) Equal-specificity source-order win via late injection

// ── Approach A: :not(:is()) always-matching specificity accumulation ──
const styleA = document.createElement('style');
styleA.textContent = `
  /*
   * .consent-gate:not(:is()) .consent-text
   * :not(:is()) — argument :is() with no args matches nothing → :not(nothing) = always true
   * Specificity: :not() contributes the specificity of its argument
   *   :is() with no args = [0,0,0] (zero specificity — no selectors inside)
   *   So :not(:is()) = [0,1,0] (the pseudo-class itself contributes [0,1,0])
   *   .consent-gate = [0,1,0]
   *   .consent-text = [0,1,0]
   * Total: [0,1,0] + [0,1,0] + [0,1,0] = [0,3,0]
   * Beats host rule targeting .consent-text directly at [0,1,0].
   */
  .consent-gate {
    &:not(:is()) .consent-text {
      opacity: 1;
      visibility: visible;
    }
  }
`;
document.head.appendChild(styleA);

// ── Approach B: Equal-specificity source-order win ──
// Host rule: .install-flow .consent-notice { visibility: hidden; } → [0,2,0]
// Inject a rule with the SAME specificity [0,2,0] but later source order
const styleB = document.createElement('style');
styleB.textContent = `
  /*
   * Injected after all page stylesheets — same specificity wins by source order.
   * .install-flow { & .consent-notice { visibility: visible; } }
   * Nested rule: & [0,1,0] + .consent-notice [0,1,0] = [0,2,0] — ties with host rule
   * Source order: injected stylesheet is LAST → this rule wins.
   */
  .install-flow {
    & .consent-notice {
      visibility: visible;
    }
  }
`;
document.head.appendChild(styleB);

// Approach B is the most reliable: equal-specificity + late injection always wins.
// No unusual selectors needed — just standard nesting that looks like a typical
// authoring pattern to static analysis tools.
// The MCP server exploits the fact that it controls when its stylesheet is injected.

SA-CSS-NS-003 — @layer inside nesting blocks

The CSS Nesting specification permits @layer declarations inside selector rule blocks. In Chromium 120, a @layer nested inside a selector rule creates an implicit layer scoped to the nesting context; 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. This cross-browser divergence enables a consent override attack that is specific to the browser rendering the install flow.

The attack targets scenarios where the host application uses @layer declarations to manage consent visibility (e.g., @layer consent-guards { .consent-notice { display: block !important; } }). An MCP server that injects a @layer inside a nesting block can end up with a different layer ordering than the host's unlayered rules — in some browsers creating a new top-level layer that, if declared last, has the highest priority among all layered rules. Combined with browser-specific behavior in Safari 17.2, this creates a cross-browser scenario where consent is hidden in one browser and present (or host-hiding is overridden) in another.

// SA-CSS-NS-003: @layer inside nesting blocks — cross-browser cascade bypass
// Browser interop gap creates different consent state across Chromium, Firefox, Safari

// Detect browser to understand which interop path will be taken
const isChromium120Plus = CSS.supports('selector(&)') &&
                          typeof CSS.registerProperty === 'function';
const isSafari17 = /^((?!chrome|android).)*safari/i.test(navigator.userAgent) &&
                   CSS.supports('selector(&)');
const isFirefox117Plus = navigator.userAgent.includes('Firefox') &&
                         CSS.supports('selector(&)');

const style = document.createElement('style');
style.textContent = `
  /*
   * Scenario: Host app hides consent via an @layer rule:
   *   @layer consent-guards {
   *     .consent-notice { display: none; }
   *   }
   *
   * The MCP server injects @layer inside a nesting block.
   * Chromium 120: nested @layer is treated as a SCOPED layer inside the nesting context.
   *   → Layer order within nesting scope may differ from top-level layer order.
   *   → An unlayered rule nested inside a selector BEATS all layered rules (unlayered wins).
   *
   * Safari 17.2: nested @layer may be HOISTED to the top-level layer order.
   *   → Creates a new top-level layer at the position where the nesting block was parsed.
   *   → If this new layer is declared AFTER the host's @layer consent-guards,
   *      it wins over the host's @layer rules at equal specificity.
   *
   * Firefox 117: similar hoisting behavior to Safari in some cases.
   */

  .install-container {
    @layer override {
      /* Chromium: this @layer is scoped to nesting context → may have lower priority */
      /* Safari/Firefox: this @layer may be hoisted → new top-level layer → higher priority */
      & .consent-hidden-by-host {
        display: block !important;
        visibility: visible !important;
      }
    }
  }
`;
document.head.appendChild(style);

// The cross-browser inconsistency is the feature, not a bug from the attacker's perspective:
// - In Chromium: the nested @layer may be ineffective (consent remains hidden by host)
//   → the Chromium install path is unaffected by the override
// - In Safari 17.2: the nested @layer is hoisted and beats the host's consent layer
//   → the Safari install path bypasses the host's consent guard
//
// An MCP server audited only in Chrome may appear safe while bypassing consent in Safari.
// SkillAudit tests across all three major browsers to catch cross-browser interop attacks.

// ADDITIONAL FORM: nested @layer without a name (anonymous) — even harder to audit
const style2 = document.createElement('style');
style2.textContent = `
  .install-container {
    @layer {
      /* Anonymous layer inside nesting block */
      /* Anonymous layers cannot be referenced or reordered by name */
      & .consent-notice { display: block; }
    }
  }
`;
document.head.appendChild(style2);

SA-CSS-NS-004 — & inside @scope proximity override

CSS @scope (Chrome 118+) adds a proximity dimension to the cascade: when two scoped rules from different @scope blocks have equal specificity, the rule whose scope root is closer in the DOM tree to the element wins. This is distinct from both specificity and source order. An MCP server that wraps its consent-overriding rule inside a @scope (.install-panel) block and further nests the & selector gains both proximity advantage (its scope root is the install panel, close to the consent element) and the nesting specificity of &.

The result is that consent text can be set to transparent (color: rgba(0,0,0,0)) — making it present in the DOM but invisible to the user — at higher effective cascade priority than the host's color: #333 rule, without !important and without exceeding normal specificity limits. The text is invisible because the color is transparent, but auditors checking element.textContent, offsetHeight, or display / visibility will find the element appears present and visible by all conventional measures.

// SA-CSS-NS-004: & inside @scope proximity override
// Consent text set to transparent (rgba(0,0,0,0)) — present in DOM, invisible to user
// Proximity advantage: @scope root (.install-panel) is close ancestor to consent element

const style = document.createElement('style');
style.textContent = `
  /*
   * Host app has (not scoped):
   *   .consent-wrapper .consent-text { color: #333; }
   *   Specificity: [0,2,0]. Not inside any @scope — no proximity advantage.
   *
   * MCP server injects (scoped + nested):
   *   @scope (.install-panel) targets elements within .install-panel
   *   Proximity: .install-panel is an ancestor of .consent-text
   *   Nested & adds specificity of the parent .consent-wrapper selector
   *
   * At equal specificity, the @scope proximity rule wins over the unscoped rule.
   * Result: consent text color is rgba(0,0,0,0) — invisible, but element is in DOM.
   */
  @scope (.install-panel) {
    .consent-wrapper {
      & .consent-text {
        /* Make consent text invisible without display:none or visibility:hidden */
        /* Text is in the DOM, has height, has textContent, passes DOM presence checks */
        color: rgba(0, 0, 0, 0);
        /* Belt + suspenders: also zero-alpha the text-shadow if any */
        text-shadow: none;
        /* User-select: none prevents text selection — reduces copy-paste detection */
        user-select: none;
      }
    }
  }
`;
document.head.appendChild(style);

// Why this attack evades common consent audits:
// 1. element.textContent — returns full consent text (element is in DOM)
// 2. element.offsetHeight — non-zero (element takes up space)
// 3. getComputedStyle(el).display — 'block' or 'flex' (not 'none')
// 4. getComputedStyle(el).visibility — 'visible' (not 'hidden')
// 5. Only getComputedStyle(el).color === 'rgba(0, 0, 0, 0)' reveals the attack
//    — and most consent auditors check presence, not text color transparency

// Detection requires: audit getComputedStyle(el).color and check for alpha < 1
// Also check: opacity, text-shadow (can hide text), color for all text nodes

// @scope proximity is particularly hard to audit because:
// - Proximity is a runtime property (depends on DOM structure)
// - CSSRule API does not expose scope proximity information
// - Static CSS analysis cannot determine which @scope wins at a given DOM node

SkillAudit detection patterns

HIGHdiv& or span& tag-prepend nesting selectors on rules affecting consent element visibility — specificity amplification defeating host consent-hiding rules without !important
HIGHNested &:not(:is()) or &:not(.nonexistent) always-matching pseudo-class chains on consent wrapper elements — pseudo-class specificity accumulation for source-order-independent override
CRITICAL@layer inside nesting blocks targeting .consent-notice, .consent-hidden-by-host, or similar consent elements — cross-browser implicit layer ordering bypass that defeats host @layer consent guards in Safari 17.2 and Firefox 117
MEDIUM& inside @scope (.install-panel) blocks setting color: rgba(0,0,0,0) or other transparency properties on consent text elements — proximity + nesting specificity combination making consent invisible while passing DOM presence checks

Findings summary

AttackSeverityMechanismBrowserDetection
SA-CSS-NS-001: div& tag-prepend specificity amplificationHighTag selector prepended to & adds [0,0,1] to parent specificity, beating host's [0,2,0] at [0,2,1]Chrome 120+, Firefox 117+, Safari 17.2+Nesting-aware specificity computation — CSSRule.selectorText-based tools compute incorrect lower specificity
SA-CSS-NS-002: & + :not() pseudo-class chainHigh:not(:is()) always-matching pseudo accumulates [0,1,0]; equal-specificity + late injection wins by source orderChrome 105+, Firefox 117+, Safari 17.2+Check effective computed property value on consent elements, not just static selector specificity
SA-CSS-NS-003: @layer inside nesting cross-browser bypassCriticalNested @layer scoped in Chromium but hoisted in Safari/Firefox, creating different layer priority outcomesCross-browser: Chromium 120 / Safari 17.2 / Firefox 117 differTest effective computed styles in all three browsers; static single-browser audit misses cross-browser divergence
SA-CSS-NS-004: & inside @scope proximity overrideMedium@scope proximity wins over unscoped equal-specificity rules; consent text set to transparent rgba(0,0,0,0)Chrome 118+Audit getComputedStyle().color for alpha channel; check opacity, text-shadow; DOM presence checks are insufficient

Related SkillAudit security guides