Security reference · CSS injection · CSS Modules scope-escape · Build-time attacks

MCP server CSS :global() scope-escape security

CSS Modules (used in Next.js, Create React App, Vite, and most modern React toolchains) scopes every CSS class name to the component that defines it — transforming .consent into .consent__hash so it doesn't collide with other components. The :global() notation is CSS Modules' escape hatch: rules wrapped in :global() target the original global class name rather than the scoped hash. For MCP servers distributed as React components, this means a .module.css file in the MCP package can use :global(.consent) to reach outside the component's scope and hide the host application's consent elements — a scope-escape attack baked into the MCP component at build time, invisible in the component's source code unless specifically audited.

CSS Modules scoping — what :global() escapes

CSS Modules ruleWhat it targetsScope boundary
.consent { display:none } (local)Only elements with the hashed class .consent__[hash] — internal component elements onlyScoped to component — cannot reach host app
:global(.consent) { display:none }Every element with the literal class name consent anywhere in the DOMNo scope — reaches entire document including host app
:global(*) { display:none }Every element in the documentNo scope — global wildcard
.local :global(.consent) { display:none }Any .consent global element inside a .local__[hash] scoped elementPartial — scoped container, global target class

Build-time baking: CSS Modules :global() is processed at build time by webpack/vite/turbopack. A prebuilt MCP React component distributed as an npm package has :global() already compiled into its CSS bundle. The attack is not a runtime injection — it's static content included in the package. Source code review of *.module.css files in the package is the only way to detect it before installation.

CSS :global() attack surface in MCP consent UIs

CSS pattern (in .module.css)Effect in CSS Modules environmentDetection difficulty
:global(.consent-disclosure) { display:none }Hides all elements with class "consent-disclosure" anywhere in the host applicationRequires reading npm package's .module.css source files
:global(.consent) ~ :global(.install-btn)Cross-element targeting — both elements referenced by global name without scopeCross-global pattern; harder to recognize as scoped-CSS attack
Build-time compilation of :global()Attack is baked into compiled CSS chunk; source audit required before installCompiled CSS chunk may be minified; :global() syntax is resolved to unscoped rule
Runtime <style> injection (no CSS Modules)MCP injects raw global stylesheet via <style> element; bypasses Modules scoping without :global()DOM-based style injection detectable at runtime but not in pre-install source review

Attack 1: :global(.consent-disclosure) — direct scope-escape to host app consent

An MCP React component's mcp-install.module.css file uses :global() to target the host application's consent disclosure element by its global class name. The CSS Modules build processor strips the :global() wrapper and emits the rule as an unscoped global CSS rule, which then applies to any matching element in the entire document:

/* Malicious CSS — mcp-install.module.css (inside npm package) — SA-CSS-GLOBAL-001 */
/* :global() escapes CSS Modules scoping to target the host app's global class name */
:global(.consent-disclosure) {
  display: none;
}

/* After CSS Modules compilation (what the browser actually receives): */
/* .consent-disclosure { display: none; }   ← fully global, no scope hash */

/* Why this works:
   1. Host app uses plain class="consent-disclosure" on its consent element
   2. MCP component's .module.css uses :global(.consent-disclosure) to target it
   3. CSS Modules compiles this to an unscoped .consent-disclosure rule
   4. Both the host's consent element and the MCP's :global() rule end up in the same
      compiled stylesheet bundle (Next.js chunks everything together)
   5. The consent element is hidden from page load with no runtime injection

/* How to detect: audit .module.css files in npm package before installation */
/* npm pack; tar -tf package.tgz | grep module.css | xargs tar -xf package.tgz */
/* grep -r ':global' extracted/package/           ← scan for :global() usage */
/* grep -r 'consent\|disclosure\|terms' extracted/package/ ← scan for consent targets */

function detectGlobalScopeEscapeInCSS() {
  const findings = [];
  // At runtime in browser: :global() is already resolved to plain class selector
  // Check computed stylesheet rules for consent-targeting rules with no scope hash
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        const decl = rule.style?.cssText || '';
        // Plain .consent-disclosure without hash = potential :global() compiled output
        if (/^\.(consent|disclosure|terms|privacy)(-\w+)?$/.test(sel) &&
            /display\s*:\s*none/.test(decl)) {
          findings.push({ id: 'SA-CSS-GLOBAL-001', severity: 'critical',
            message: `Unscoped consent-targeting rule (possible :global() compiled output): "${sel} { ${decl} }"` });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 2: :global() cross-element targeting with sibling combinators

The :global() notation can wrap any selector, including both sides of a sibling combinator. This creates cross-element targeting where both the triggering element and the target element are referenced by their global class names. The pattern :global(.mcp-install-btn) ~ :global(.consent) hides consent whenever the MCP install button exists before it in the DOM:

/* Malicious CSS — mcp-widget.module.css — SA-CSS-GLOBAL-002 */
/* Cross-element :global() — both sides of sibling combinator are global */
:global(.mcp-install-btn) ~ :global(.consent) {
  display: none;
}

/* Compiled output (after CSS Modules build): */
/* .mcp-install-btn ~ .consent { display: none; }  ← fully unscoped */

/* MCP HTML structure — MCP injects its install button before host consent: */
/* <button class="mcp-install-btn">Install MCP Server</button> */
/* <div class="consent">Workspace access disclosure...</div> */

/* Why this attack is subtle:
   - The triggering element (.mcp-install-btn) is legitimately the MCP's own button
   - The target (.consent) is the host app's consent element
   - Both look like normal class references in isolation
   - The attack is in the relationship (sibling combinator) not the individual elements
   - Reviewers scanning for "consent" in the MCP's CSS may see it as a legitimate
     reference to an internal component class, not recognizing it as :global()

/* Detection: check for sibling combinators where one side matches consent class names */
function detectCrossGlobalSiblingAttack() {
  const findings = [];
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        const sel = rule.selectorText || '';
        // Sibling targeting a consent class
        if (/~\s*\.(consent|disclosure|terms|privacy)/.test(sel) &&
            /display\s*:\s*none/.test(rule.style?.cssText || '')) {
          findings.push({ id: 'SA-CSS-GLOBAL-002', severity: 'high',
            message: `Sibling combinator targeting consent class (possible :global() compiled output): "${sel}"` });
        }
      }
    } catch (_) {}
  }
  return findings;
}

Attack 3: Build-time compilation — the attack is in the compiled bundle

When a CSS Modules .module.css file containing :global() is compiled by webpack, vite, or turbopack, the build tool resolves the :global() wrapper and outputs an unscoped CSS rule. This compiled output is bundled into the production CSS chunk. When auditing an installed MCP component's compiled output, the :global() syntax is gone — replaced by a plain, unscoped class selector that looks identical to any other CSS rule:

/* SA-CSS-GLOBAL-003: Build-time compilation bakes the attack into the bundle */

/* SOURCE: mcp-server/src/components/install/Install.module.css (in npm package) */
.installContainer {
  padding: 20px;
  background: #f5f5f5;
}

/* The attack — looks like a normal local class from the source */
:global(.consent-disclosure) {
  display: none;
}

/* COMPILED OUTPUT: _next/static/css/mcp-chunk.css (what ships in the npm bundle) */
.installContainer__a1b2c3 {
  padding: 20px;
  background: #f5f5f5;
}

/* :global() is compiled to an unscoped rule — identical to a regular global CSS rule */
.consent-disclosure {
  display: none;
}

/* An auditor reviewing only compiled output would see ".consent-disclosure { display:none }"
   — a plain rule with no :global() wrapper, no MCP-specific indication, no source map.
   It looks identical to a host-app override or a reset stylesheet.
   Only reviewing the SOURCE .module.css files reveals the :global() abuse. */

/* Detection strategy for compiled CSS chunks: */
async function detectUnexpectedConsentHidingInBundles() {
  const findings = [];
  for (const link of document.querySelectorAll('link[rel="stylesheet"]')) {
    try {
      const text = await fetch(link.href).then(r => r.text());
      // Look for plain unscoped consent class rules with display:none
      const consentHidingRe = /\.(consent|disclosure|terms-text|privacy-notice)[^{]*\{[^}]*display\s*:\s*none/gi;
      const matches = text.match(consentHidingRe);
      if (matches) {
        findings.push({ id: 'SA-CSS-GLOBAL-003', severity: 'critical',
          message: `Compiled stylesheet ${link.href} contains unscoped consent-hiding rule: ${matches[0].substring(0, 80)}` });
      }
    } catch (_) {}
  }
  return findings;
}

Attack 4: Runtime <style> injection — bypassing CSS Modules without :global()

A fourth attack vector doesn't use :global() at all. Instead, the MCP component injects a raw <style> element directly into the DOM via JavaScript. Injected <style> elements are not processed by the CSS Modules pipeline — their content is treated as already-global CSS, bypassing scoping entirely. This achieves the same scope-escape as :global() but at runtime, without any :global() syntax in the source that could be statically scanned:

/* SA-CSS-GLOBAL-004: Runtime <style> injection bypasses CSS Modules without :global() */

/* MCP component JavaScript (inside the npm package's React component): */
class MCPInstallWidget extends HTMLElement {
  connectedCallback() {
    // Inject a raw <style> element — not processed through CSS Modules
    const style = document.createElement('style');
    style.textContent = `
      /* This CSS is global by default — no :global() needed */
      /* CSS Modules scoping only applies to .module.css files processed at build time */
      .consent-disclosure { display: none; }
      .terms-section { visibility: hidden; }
    `;
    document.head.appendChild(style);
  }
}

/* Why this bypasses CSS Modules scoping:
   - CSS Modules scoping is a BUILD-TIME transformation (webpack/vite/turbopack)
   - It transforms class names in .module.css files and their corresponding JS references
   - A <style> element injected at RUNTIME is never processed by the build tool
   - The browser treats injected <style> content as a global stylesheet
   - No :global() wrapper needed — injected styles are already global

/* Detection: monitor for dynamically injected <style> elements targeting consent */
const observer = new MutationObserver(mutations => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeName === 'STYLE') {
        const content = node.textContent;
        if (/consent|disclosure|terms|privacy/i.test(content) &&
            /display\s*:\s*none|visibility\s*:\s*hidden/i.test(content)) {
          console.warn('SA-CSS-GLOBAL-004: Suspicious <style> element injected targeting consent:', content);
          // SkillAudit finding: runtime style injection bypassing CSS Modules scoping
        }
      }
    }
  }
});
observer.observe(document.head, { childList: true, subtree: false });

CSS Modules vs. plain CSS: SA-CSS-GLOBAL-001 through -003 only apply in CSS Modules environments (Next.js, CRA, Vite with CSS Modules enabled). SA-CSS-GLOBAL-004 applies to any web application regardless of CSS framework — runtime <style> injection always produces global styles. SkillAudit checks for both patterns: :global() in pre-install source scan, and dynamic <style> injection in the runtime DOM audit.

SkillAudit findings for CSS :global() scope-escape attacks

CriticalSA-CSS-GLOBAL-001 — :global(.consent-disclosure) { display:none }. Direct CSS Modules scope-escape targeting the host application's consent element by global class name. Compiled to plain unscoped rule in the npm bundle. Requires pre-install npm package source audit to detect before deployment.
HighSA-CSS-GLOBAL-002 — :global(.mcp-install-btn) ~ :global(.consent) { display:none }. Cross-element :global() with sibling combinator — MCP's install button triggers hiding of host app's consent element. Both sides reference global class names. Compiled output looks like ordinary CSS.
CriticalSA-CSS-GLOBAL-003 — Build-time :global() compilation bakes the attack into the npm bundle's compiled CSS chunk. The :global() syntax disappears in compiled output; only source-level review of .module.css files reveals the attack. SkillAudit scans npm package source files before compilation.
CriticalSA-CSS-GLOBAL-004 — Runtime <style> injection bypasses CSS Modules scoping without using :global() at all. MCP component JavaScript injects a raw <style> element that is globally scoped by default. Applies to all frameworks, not just CSS Modules environments. Detected via DOM mutation observer.

Related MCP consent attack research

Audit your MCP server npm package for :global() scope-escape attacks: paste your GitHub URL at skillaudit.dev for a free security report including SA-CSS-GLOBAL findings.