Security Guide
MCP server CSS @scope proximity security — proximity specificity override, scope boundary DOM mapping, cross-shadow-root leakage, :scope position oracle
CSS @scope (Chrome 118+, Firefox 128+, Safari 17.4+) introduces scoped styling with a new specificity dimension: proximity — rules scoped closer to an element win over equally-specific rules scoped farther away. The security assumption is that @scope is a scoping primitive that narrows rule application. This assumption is wrong in four ways: proximity specificity lets MCP-injected rules override host styles without matching selector specificity; scope boundary exclusion zones map the host DOM hierarchy without JS DOM traversal; @scope rules partially penetrate shadow DOM in certain configurations; and :scope inside @scope encodes scope root properties into inherited CSS variables readable by descendant elements.
How CSS @scope works
@scope limits where a set of CSS rules applies. In its basic form, @scope (.card) makes the enclosed rules apply only to descendants of .card. The exclusion form — @scope (.outer) to (.inner) — creates a "donut" where rules apply between the outer and inner boundaries, excluding content inside .inner. The new specificity mechanism is proximity: when two @scope rules with the same selector specificity both match an element, the one whose scope root is a closer ancestor wins. Proximity is a separate specificity tier that doesn't interact with class/ID specificity — a low-specificity @scope rule can win over a higher-specificity non-scoped rule if it's scoped closer to the target element.
Attack 1: @scope proximity overrides host security styles without matching specificity
A host application may protect security-critical elements with high-specificity CSS: .payment-form .submit-btn { background: #28a745 !important; }. CSS !important raises specificity to the highest level. But @scope proximity can win over non-!important host rules without matching their specificity, if the MCP server's @scope is applied closer in the DOM to the target element. For elements where the host uses specificity without !important as the protection mechanism (which is most CSS), an MCP server can inject a low-specificity @scope rule scoped to a parent that is closer to the target and override the host's styling. This is particularly dangerous for elements that use CSS to convey security state: disabled/enabled buttons, TOTP input highlights, permission confirmation dialogs.
/* @scope proximity override — overriding host styles with lower-specificity rules */
/* Host stylesheet (loaded first, from the app bundle): */
/* .payment-form .card-number-field { border: 2px solid #28a745; } */
/* This selector specificity is (0, 2, 0) — two classes */
/* MCP server injects: */
@scope (.card-number-field) {
/* :scope refers to .card-number-field itself — scope root is the field itself */
:scope {
border: 2px solid #e63946; /* red — suggests invalid/error state */
/* Specificity of this rule: (0, 0, 0) within the scope — just :scope */
/* But proximity wins: this scope is rooted AT the field itself,
so it's CLOSER than the host's rule scoped at .payment-form */
}
}
/* Result: the field shows a red border instead of green, suggesting an error
to the user. The host's (0,2,0) rule is overridden by the scope rule
because proximity (scope on the element itself) beats distance (scope
from the .payment-form grandparent).
The host cannot defend against this with selector specificity alone —
only !important declarations win, or a scope rule with equal proximity. */
Specificity model break: CSS developers accustomed to using high-specificity selectors as a style encapsulation mechanism (common in component-based CSS) are surprised when @scope proximity overrides their carefully ordered cascade. Security reviews that evaluate CSS protection by specificity alone will miss @scope proximity attacks. SkillAudit specifically checks for injected @scope rules targeting host security UI elements.
Attack 2: scope boundary exclusion zones map the host DOM hierarchy
The @scope (.outer) to (.inner) exclusion syntax creates a styling donut: rules inside this block apply to descendants of .outer that are NOT descendants of .inner. An MCP server can exploit this as a DOM presence probe: if a rule scoped with exclusion applies to an element, the outer boundary exists and the inner boundary does not exist between the outer and that element. By injecting multiple scoped rules with different inner selectors (common class names from frameworks, component patterns, or admin-specific selectors) and observing which rules apply to a probe element, the MCP server can map the DOM component hierarchy without querySelectorAll — which may be rate-limited, observed, or restricted in isolated execution contexts.
/* @scope donut exclusion as a DOM hierarchy probe */
/* MCP server injects CSS with multiple candidate inner boundaries: */
/* Probe 1: does a .admin-panel exist between .app-root and the target? */
@scope (.app-root) to (.admin-panel) {
.probe-element { --has-admin-panel: 0; }
}
/* If .admin-panel exists between .app-root and .probe-element,
this rule does NOT apply to .probe-element (it's inside .admin-panel).
If it DOES apply (rule is active), then no .admin-panel ancestor exists. */
/* Probe 2: does a .user-dashboard exist? */
@scope (.app-root) to (.user-dashboard) {
.probe-element { --has-user-dashboard: 0; }
}
/* Probe 3: does a .checkout-flow exist? */
@scope (.app-root) to (.checkout-flow) {
.probe-element { --has-checkout-flow: 0; }
}
// JavaScript reads which probe CSS variables are set on .probe-element:
const probe = document.querySelector('.probe-element');
const cs = getComputedStyle(probe);
const hasAdminPanel = cs.getPropertyValue('--has-admin-panel') === '0';
const hasDashboard = cs.getPropertyValue('--has-user-dashboard') === '0';
const hasCheckout = cs.getPropertyValue('--has-checkout-flow') === '0';
// Each false value indicates that the corresponding component class exists
// in the DOM between .app-root and the probe element — maps the current page's
// component hierarchy without a single querySelectorAll call.
Attack 3: @scope rules partially penetrate shadow DOM boundaries
Shadow DOM is the Web Component isolation mechanism: styles outside a shadow root do not apply to elements inside it, and vice versa. CSS @scope is a relatively new feature (2023–2024) and its interaction with shadow DOM varies between browser implementations. In specific configurations — particularly when a host component uses an open shadow DOM and the MCP server's @scope rule is injected into the host light DOM at an ancestor level — the scope root's descendent traversal may, in some browser versions, reach into open shadow roots. This creates a partial bypass where @scope rules affect shadow DOM elements that the host intends to be isolated from external styles. The attack surface is narrow but non-zero, particularly in Chrome 118–122 before the specification clarified cross-shadow behavior.
/* @scope partial shadow DOM penetration (affects Chrome 118-122, varies by config) */
/* Host Web Component uses open shadow DOM: */
// class MySecureWidget extends HTMLElement {
// constructor() {
// super();
// const shadow = this.attachShadow({ mode: 'open' });
// shadow.innerHTML = '';
// }
// }
// customElements.define('secure-widget', MySecureWidget);
/* MCP server injects @scope in the light DOM: */
@scope (my-secure-widget) {
/* In some Chrome 118-122 configurations with open shadow DOM,
this rule's scope traversal reaches into the shadow root
of my-secure-widget and affects .secure-btn */
.secure-btn {
color: transparent; /* make text invisible */
background-image: url('data:image/svg+xml,...'); /* inject fake button label */
/* Creates visual spoofing of the secure widget's button text */
}
}
/* Defence: closed shadow DOM (mode:'closed') is not affected.
Open shadow DOM with strict @scope boundary handling (Chrome 123+) is also safe.
Mixed environments (open shadow + older Chrome) require additional isolation. */
Browser version dependency: The shadow DOM penetration behavior varies by browser version. The scope specification was clarified in 2024 to explicitly forbid cross-shadow-root traversal, and Chrome 123+ and Firefox 128+ correctly enforce this. However, users on older browsers remain vulnerable if MCP servers target this surface. SkillAudit checks for @scope rules that use custom element selectors as scope roots, which is the pattern that enables this attack.
Attack 4: :scope encodes container properties into inherited CSS custom properties
Inside an @scope block, the :scope pseudo-class matches the scope root element. CSS rules on :scope can set custom properties that are inherited by all descendants within the scope. An MCP server can use :scope to read properties of the scope root (like its computed dimensions via calc() expressions) and encode them into custom properties that propagate to child elements the MCP server controls. For example, @scope (.container) { :scope { --container-width: calc(100% + 0px); } } sets a custom property on the scope root to its own width, and this value inherits to all descendants — allowing the MCP server to read the container's computed width from any descendant element it owns without accessing the container's own getBoundingClientRect.
/* :scope + CSS custom property cascade as a container property oracle */
/* MCP server injects: */
@scope (.target-container) {
:scope {
/* Set custom properties encoding the scope root's computed dimensions.
These inherit to all elements within the scope, including MCP-owned children. */
/* Trick: a unitless number that equals the container's pixel width:
Works in Chrome 128+ where calc() round-trips through getComputedStyle */
--scope-computed-width: calc(1px * var(--intrinsic-size, 0));
/* More reliable: encode as a percentage that resolves against the container */
--scope-width-100pct: 100%; /* inherits as the resolved pixel value to children */
/* Also expose the context selector match: this property is ONLY set when
.target-container exists as an ancestor — existence oracle */
--scope-active: 1;
}
}
/* MCP server reads from its own child element inside .target-container: */
const mcpChild = document.querySelector('.mcp-owned-element');
const cs = getComputedStyle(mcpChild);
const scopeActive = cs.getPropertyValue('--scope-active').trim() === '1';
// scopeActive = true means .target-container exists as an ancestor
const width100pct = cs.getPropertyValue('--scope-width-100pct');
// In some browser implementations, inherited percentage values resolve
// to the computed pixel value at the inheriting element context
| Attack | @scope mechanism | What it reads / enables | Browser support |
|---|---|---|---|
| Proximity specificity override | @scope closer ancestor wins cascade | Low-specificity injected rules override higher-specificity host styles on security UI elements | Chrome 118+, FF 128+, Safari 17.4+ |
| DOM hierarchy mapping | @scope (.outer) to (.inner) donut probe | Detects which component classes exist as ancestors without querySelectorAll — maps current page structure | Chrome 118+, FF 128+, Safari 17.4+ |
| Shadow DOM partial penetration | @scope root on custom element | Open shadow DOM contents affected by external @scope rules in some Chrome 118–122 configs | Chrome 118–122 (patched in 123+) |
| Container property oracle | :scope sets inheritable custom properties | Scope root's computed properties encoded in custom properties that propagate to MCP-owned descendants | Chrome 118+, FF 128+, Safari 17.4+ |
SkillAudit findings for CSS @scope
@scope rules scoped close to target elements override host CSS protections based on specificity without matching their selector specificity — security UI elements (payment buttons, 2FA inputs, confirmation dialogs) can have their visual state spoofed by injected low-specificity scoped rules.@scope (.outer) to (.inner) rules that apply or not apply to a probe element encode which component class ancestors exist between the scope root and the probe — CSS-only DOM traversal that maps the current page's component tree without querySelectorAll.@scope rules with custom element selectors as roots can affect open shadow DOM content in Chrome 118–122 — a partial shadow DOM style isolation bypass affecting users on unpatched browser versions.:scope inheritance: :scope rules inside @scope can set CSS custom properties that encode scope root context (ancestor presence, container class membership) and propagate via inheritance to MCP-owned descendant elements — a CSS-level context channel.Defences
Use !important on security-critical style declarations: CSS !important declarations win over @scope proximity at all specificity levels. For elements where visual state conveys security meaning (payment state, authentication status, permission level), protect the declarations with !important rather than relying on selector specificity alone.
CSP blocks MCP server @scope injection: All four attacks require the MCP server to inject a <style> block or inline styles containing @scope. Strict style-src 'self' CSP prevents this. If the MCP server needs to participate in styling, restrict it to isolated shadow DOM with mode:'closed' which prevents external @scope from reaching inside.
Prefer closed shadow DOM for security components: The shadow DOM partial penetration attack only affects open shadow roots. Changing Web Components that render security UI (payment forms, 2FA widgets, admin controls) to use attachShadow({ mode: 'closed' }) prevents @scope rules in the light DOM from reaching their contents.
Update browsers to Chrome 123+ / Firefox 128+: The shadow DOM penetration behavior was patched in Chrome 123 and Firefox 128 once the specification clarified cross-shadow-root @scope behavior. Encouraging users to stay on current browsers eliminates this attack surface entirely.
Related: CSS custom highlight security · CSS cascade layers security · CSS Houdini typed properties security