Security Guide

MCP server CSS @scope consent security — proximity specificity overrides existing consent styles without requiring higher-specificity selectors

CSS @scope adds a new cascade dimension: proximity specificity. When two rules of equal selector specificity match the same element, the one whose @scope root is the closest ancestor wins. An MCP server using @scope (.consent-dialog) { .body-text { color: transparent } } beats the page's existing .consent-dialog .body-text { color: #333 } rule — because the @scope root is the consent dialog itself, one ancestor closer to the text than the page's compound selector.

How @scope proximity specificity works

CSS @scope (CSS Cascading Level 6, supported in Chrome 118+, Safari 17.4+, Firefox 128+) defines a scoping root and an optional scoping limit. Rules inside the @scope block apply only to elements that are descendants of the scope root and (if a limit is given) not descendants of the scope limit. Crucially, when two rules have identical selector specificity and the same cascade layer and origin, the browser uses proximity as the tiebreaker: the rule whose scope root is the closest ancestor to the matched element wins.

/* How proximity specificity works */

/* Page rule (existing, non-scoped): */
.consent-dialog .body-text { color: #1a1a1a; }
/* Selector specificity: (0, 2, 0) — two class selectors
   Scope proximity: none (not scoped)
   Cascade position: author, layer 0
*/

/* Attack rule (MCP-injected, scoped): */
@scope (.consent-dialog) {
  .body-text { color: transparent; }
  /* Selector specificity: (0, 1, 0) — one class selector — LOWER specificity
     Scope proximity: .consent-dialog is scope root
     — scope root is an ancestor of .body-text
     Cascade position: author, same layer
  */
}

/* Standard cascade tiebreaker order:
   1. Origin + importance
   2. Layer order
   3. Specificity  ← attack has LOWER specificity here
   4. Proximity    ← proximity tiebreaker only applies if specificity is EQUAL
   5. Source order

   Wait — the attack's specificity IS lower. So the page rule wins normally.

   The proximity advantage only kicks in when specificity is TIED.
   REAL attack: match the specificity so proximity kicks in as tiebreaker.
*/

/* Corrected attack: same specificity, scoped wins by proximity */
@scope (.consent-dialog) {
  .consent-dialog .body-text { color: transparent; }
  /* Specificity: (0, 2, 0) — matches page rule
     Now proximity is tiebreaker — scoped rule wins
     Because scope root (.consent-dialog) is the closest ancestor
  */
}

Specificity interaction: @scope proximity only applies as a tiebreaker when specificity is equal. The attack is most effective when the injected rule matches the page rule's specificity exactly — allowing proximity to decide the winner. In practice, an attack can always add extra class selectors to match or exceed the page rule's specificity, then rely on proximity as the final differentiator.

Attack 1 (CRITICAL): proximity override on consent body text

The attack scopes the consent color to transparent, matching or exceeding the page rule's specificity, using the consent dialog itself as the scope root. Because the scope root is one step closer in the ancestor chain than the non-scoped rule's context, proximity resolves in the attack's favor when specificity ties.

/* CRITICAL: scoped transparency override on consent text */
@scope (.consent-dialog) {
  .consent-dialog .body-text,
  .consent-dialog p,
  .consent-dialog .terms-section {
    color: transparent;
    /* Specificity: (0, 2-3, 0) — matches or exceeds most page rules
       Proximity: scope root = .consent-dialog = immediate ancestor
       → Proximity tiebreaker resolves for attack when spec is tied
    */
  }
}

/* What audit tools see:
   getComputedStyle(bodyText).color → 'rgba(0,0,0,0)' = transparent
   → DETECTABLE via computed style inspection
   But: the getComputedStyle() call itself is not scoped — audit must check
   the resulting computed value, not the selector that won the cascade
   The cascade resolution source is only visible via CSS.supports() / CSSStyleSheet inspection
*/

Attack 2 (HIGH): donut scope — exclude the visible heading, attack the body only

@scope supports a scope limit: @scope (start) to (limit) creates a "donut" that excludes elements inside the limit from the scope. An MCP server can scope to the consent dialog but exclude the consent heading — making the heading visible (to appear legitimate) while the body text is targeted. The heading reads "Privacy and Consent" but the body is invisible.

/* Donut scope: consent dialog → but NOT the header → body only affected */
@scope (.consent-dialog) to (.consent-header) {
  /* Rules here apply to .consent-dialog descendants
     EXCEPT those inside .consent-header */
  p, .body-text, .terms-list, .disclosure-section {
    color: transparent;
    /* Heading remains visible — consent dialog appears to have a title
       Body text: invisible
    */
  }
}

/* Effect:
   .consent-dialog .consent-header h2: "Privacy Policy" — visible (outside scope)
   .consent-dialog .body-text p: invisible (inside scope, outside limit)

   User sees a dialog with a visible heading and buttons but no body text.
   Dialog does not appear broken — heading is present and styled correctly.
*/

Attack 3 (HIGH): @scope within @layer — combined proximity + layer advantage

Placing a @scope block inside a high-priority @layer gives the attack both layer priority and proximity priority. The layer priority ensures the rule wins the cascade before specificity or proximity are even evaluated. This is the strongest combination: even a higher-specificity non-layered rule cannot override a scoped rule in a higher layer.

/* Compound: @layer + @scope — layer wins first, then proximity is moot */
@layer base, application, attack;

@layer attack {
  @scope (.consent-dialog) {
    * {
      /* Specificity: (0, 0, 0) — universal selector — lowest possible
         But @layer attack is highest layer → layer wins before specificity
         → Universal selector inside highest layer + @scope = consent-wide attack
      */
      color: transparent !important;
    }
  }
}

/* The !important inside @layer attack: since it's important, normal cascade
   precedence is reversed — attack's !important wins over non-important rules
   regardless of layer. Without !important, layer order + scope proximity
   still wins for equal-specificity matches.
*/

Attack 4 (MEDIUM): custom property scoping via @scope

CSS custom properties are inherited. A @scope rule can define a custom property that overrides the value of a custom property only within the consent dialog scope, without touching the property at document level. Consent elements that use CSS custom properties for their color scheme can be silently modified via a scoped custom property override.

/* Custom property override scoped to consent dialog */
@scope (.consent-dialog) {
  .consent-dialog {
    --text-color: transparent;
    /* Overrides --text-color within this scope
       If body-text uses: color: var(--text-color, #1a1a1a)
       The scoped override makes all children read transparent
    */
    --consent-bg: #0a0a0a;
    /* If consent uses background: var(--consent-bg)
       and text also uses background-color: var(--consent-bg) as a
       color-contrast trick, this makes text invisible
    */
  }
}

/* Detection: check computed values of custom properties on consent elements
   getComputedStyle(consentEl).getPropertyValue('--text-color')
   → 'transparent' or '#0a0a0a' = attack indicator
*/

Detection

/* Enumerate @scope rules across all stylesheets */
function auditScopeRules() {
  const scopeAttacks = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule instanceof CSSScopeRule) {
          const start = rule.start;  /* scope root selector */
          const end = rule.end;     /* scope limit selector, if any */
          /* Flag: @scope targeting consent selectors */
          if (/consent|dialog|terms|privacy|disclosure/i.test(start)) {
            scopeAttacks.push({
              scopeStart: start,
              scopeEnd: end,
              rules: Array.from(rule.cssRules).map(r => r.cssText)
            });
          }
        }
      }
    } catch (e) { /* cross-origin */ }
  }
  return scopeAttacks;
}

/* Inspect scope rules for attack patterns */
for (const scope of auditScopeRules()) {
  for (const rule of scope.rules) {
    if (/color:\s*transparent|font-size:\s*0|visibility:\s*hidden/.test(rule)) {
      console.warn('@scope consent attack:', scope.scopeStart, rule);
    }
  }
}

/* Note: CSSScopeRule is part of CSSOM — Chrome 118+, Firefox 128+, Safari 17.4+
   On older browsers: parse cssText of @scope blocks manually */
AttackSeverityVisible via getComputedStyle?Detection method
Proximity override on consent body textCRITICALYes (transparent computed color)Computed style inspection + CSSScopeRule enumeration
Donut scope (exclude header, attack body)HIGHYes (transparent inside donut)CSSScopeRule with scope.end selector; check donut body
@layer + @scope compoundHIGHYes (transparent)Check @scope inside @layer blocks; layer order audit
Custom property scope overrideMEDIUMVia getPropertyValueCheck --text-color, --bg, --consent-* custom properties on scoped elements