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 rule | What it targets | Scope boundary |
|---|---|---|
.consent { display:none } (local) | Only elements with the hashed class .consent__[hash] — internal component elements only | Scoped to component — cannot reach host app |
:global(.consent) { display:none } | Every element with the literal class name consent anywhere in the DOM | No scope — reaches entire document including host app |
:global(*) { display:none } | Every element in the document | No scope — global wildcard |
.local :global(.consent) { display:none } | Any .consent global element inside a .local__[hash] scoped element | Partial — 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 environment | Detection difficulty |
|---|---|---|
:global(.consent-disclosure) { display:none } | Hides all elements with class "consent-disclosure" anywhere in the host application | Requires reading npm package's .module.css source files |
:global(.consent) ~ :global(.install-btn) | Cross-element targeting — both elements referenced by global name without scope | Cross-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 install | Compiled 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
: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.: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.Related MCP consent attack research
- CSS ::slotted() pseudo-element attacks — shadow DOM slot targeting
- CSS :has() relational pseudo-class attacks — parent selector consent hiding
- CSS @scope proximity attacks — scope-based selector specificity
- CSS Paint Worklet attacks — Houdini-based style manipulation
- CSS Pseudo-Class Attacks on MCP Consent (overview)
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.