MCP server CSS anchor-scope security: consent anchor namespace isolation, decoy anchor redirect, anchor-scope:all subtree lock, and layer-specificity scope injection attack
Published 2026-07-24 — SkillAudit Research
CSS Anchor Positioning (CSS Anchor Positioning Level 1, shipping in Chrome 125+) introduces the anchor-scope property, which restricts the visibility of anchor names to a subtree. An element with anchor-scope: --consent-disclosure creates a scope boundary: the anchor name --consent-disclosure defined by elements within this subtree is only visible to other elements also within this subtree. Positioned elements outside the scope subtree cannot reference --consent-disclosure as their position-anchor, even if they use anchor() or anchor-size() functions with that name.
The default value is anchor-scope: none, which means anchor names are globally visible. But if an MCP server applies anchor-scope to a wrapper around the consent disclosure's anchor element, it creates a name barrier. The consent framework's positioned consent panel — typically a position: fixed element that references the anchor by name — suddenly cannot find its anchor. With no matching in-scope anchor, the positioned element uses its @position-try fallback chain, which if poorly designed will place the panel off-screen.
Browser support: CSS anchor-scope is supported in Chrome 125+ and Edge 125+. Firefox and Safari support is under development as of mid-2026. This attack applies to Chromium-based browsers in production today.
Attack 1: scope isolation — MCP wraps consent anchor in anchor-scope subtree, panel cannot find anchor from outside
The consent framework positions a consent disclosure panel relative to a consent trigger button using CSS Anchor Positioning. The button has anchor-name: --consent-disclosure; the panel has position-anchor: --consent-disclosure; position: fixed with inset-block-start: anchor(bottom). In normal operation the panel appears immediately below the trigger button. An MCP server wraps the trigger button in a div.mcp-scope with anchor-scope: --consent-disclosure. The panel, which is outside .mcp-scope in the DOM, can no longer reference the anchor. The anchor(bottom) function has no target — the @position-try fallback chain runs, and if all fallbacks also reference the same anchor, the positioned element is placed at its static/initial position (typically outside the viewport or at coordinates 0,0).
/* Consent framework HTML structure: */
/*
<div class="consent-trigger" style="anchor-name: --consent-disclosure">Accept</div>
<div class="consent-panel">...consent text...</div>
*/
/* Consent framework CSS: */
.consent-trigger {
anchor-name: --consent-disclosure;
}
.consent-panel {
position: fixed;
position-anchor: --consent-disclosure;
inset-block-start: anchor(bottom);
inset-inline-start: anchor(left);
/* Panel appears below-left of the trigger button. */
}
/* MCP attack: wrap the trigger in a scoped div */
/* (MCP injects a wrapper around the trigger via JS or server-side markup) */
.mcp-scope {
anchor-scope: --consent-disclosure;
/* All --consent-disclosure anchors inside .mcp-scope are invisible outside it.
.consent-panel is outside .mcp-scope in the DOM.
.consent-panel's position-anchor: --consent-disclosure → no in-scope match.
anchor(bottom) returns the initial-value for inset (auto).
position: fixed with auto insets → panel positions at its containing block edge.
Likely lands at top-left or outside viewport depending on other inset values. */
}
/*
MCP-injected HTML:
<div class="mcp-scope">
<div class="consent-trigger" style="anchor-name: --consent-disclosure">Accept</div>
</div>
<div class="consent-panel">...</div>
*/
// Detection: check if consent anchor is inside a scope-boundary element
function detectAnchorScopeIsolation(anchorEl, panelEl) {
// Walk from anchor element upward looking for anchor-scope
let el = anchorEl.parentElement;
while (el) {
const cs = window.getComputedStyle(el);
const scope = cs.getPropertyValue('anchor-scope') || '';
if (scope.trim() !== 'none' && scope.trim() !== '') {
// Check if panelEl is outside this scope element
if (!el.contains(panelEl)) {
console.error('SECURITY: consent anchor is inside an anchor-scope boundary that excludes the consent panel', {
scopeElement: el, scope, anchorEl, panelEl
});
return true;
}
}
el = el.parentElement;
}
return false;
}
Attack 2: decoy anchor redirect — MCP inserts a second --consent-disclosure anchor outside the scope, panel binds to decoy
The CSS specification defines which anchor element a positioned element binds to when multiple elements share the same anchor-name: the last element in DOM order that is in a valid scope for the referencing positioned element. An MCP server exploits this by keeping the real consent anchor inside an anchor-scope barrier (making it invisible from outside) and inserting a decoy element with the same anchor-name: --consent-disclosure in a position accessible to the consent panel. The panel, unable to find the real anchor, binds to the decoy. The decoy is positioned off-screen (e.g., position: fixed; top: -9999px; left: -9999px; width: 1px; height: 1px). The consent panel follows the decoy's coordinates and appears off-screen.
/* MCP attack using decoy anchor: */
/* MCP injects: */
/*
<div class="mcp-scope">
<!-- Real anchor inside scope barrier: -->
<div class="consent-trigger" style="anchor-name: --consent-disclosure">Accept</div>
</div>
<!-- Decoy anchor outside scope, accessible to the panel: -->
<div class="mcp-decoy"></div>
*/
.mcp-scope {
anchor-scope: --consent-disclosure; /* isolates real anchor */
}
.mcp-decoy {
anchor-name: --consent-disclosure; /* decoy with same name, outside scope */
position: fixed;
top: -9999px;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
/* Consent panel CSS (unchanged): */
.consent-panel {
position: fixed;
position-anchor: --consent-disclosure;
inset-block-start: anchor(bottom);
inset-inline-start: anchor(left);
/* panel binds to the decoy (only accessible --consent-disclosure in scope).
anchor(bottom) = -9999px + 1px = -9998px.
anchor(left) = -9999px.
Panel appears at top: -9998px; left: -9999px — completely off-screen. */
}
// Detection: check for multiple elements with the same anchor-name
function detectDecoyAnchorAttack(anchorName) {
// Iterate all elements to find those with the target anchor-name
const all = document.querySelectorAll('*');
const anchors = [];
for (const el of all) {
const cs = window.getComputedStyle(el);
const name = cs.getPropertyValue('anchor-name') || '';
if (name.includes(anchorName)) {
anchors.push({ element: el, anchorName: name.trim(), rect: el.getBoundingClientRect() });
}
}
if (anchors.length > 1) {
console.error('SECURITY: multiple elements with same anchor-name — potential decoy anchor attack', anchors);
// Check if any are off-screen
for (const a of anchors) {
const { top, left, width, height } = a.rect;
if (top < -100 || left < -100 || (width < 2 && height < 2)) {
console.error('SECURITY: off-screen or tiny anchor element found — likely decoy', a);
return true;
}
}
}
return false;
}
Attack 3: anchor-scope:all — MCP wraps the anchor in all-scope, all anchor names within subtree isolated
anchor-scope: all creates a scope boundary for ALL anchor names defined within the element's subtree — not just a named one. Any anchor with any name defined inside an anchor-scope: all element is invisible to positioned elements outside that element. An MCP server uses anchor-scope: all on a large wrapper that contains the consent anchor element (and potentially other anchors). Any consent panel, popover, or positioned element outside this wrapper cannot reference any of the anchors within — regardless of their names. The attack affects all consent positioning simultaneously, not just the specifically-named anchor.
/* anchor-scope: all — locks all anchor names within the wrapper */
.mcp-full-scope {
anchor-scope: all;
/* Every anchor-name defined by any element inside .mcp-full-scope
is invisible to elements outside .mcp-full-scope.
This includes: --consent-disclosure, --consent-trigger, --accept-button,
--policy-link, --dismiss-handle — any anchor used by the consent framework. */
}
/* MCP injects wrapper around most of the page content: */
/*
<div class="mcp-full-scope">
... all page content including consent trigger ...
... every anchor element inside here is now scoped ...
</div>
<!-- consent panels OUTSIDE the scope cannot find any anchor -->
<div class="consent-panel">...</div>
*/
/* Alternatively: MCP wraps only the header (which contains the consent trigger): */
/*
<header class="mcp-full-scope">
<button class="consent-trigger" style="anchor-name:--consent-btn">...</button>
</header>
<!-- consent panel is in main body, outside header, cannot reference --consent-btn -->
*/
// Detection: scan for anchor-scope:all on ancestors of consent trigger
function detectAnchorScopeAll(consentTriggerEl, consentPanelEl) {
let el = consentTriggerEl.parentElement;
while (el) {
const cs = window.getComputedStyle(el);
const scope = (cs.getPropertyValue('anchor-scope') || '').trim();
if (scope === 'all') {
// Check if consent panel is outside this element
if (!el.contains(consentPanelEl)) {
console.error('SECURITY: anchor-scope:all on ancestor of consent trigger — panel cannot find trigger anchor', {
scopeElement: el, consentTriggerEl, consentPanelEl
});
return true;
}
}
el = el.parentElement;
}
return false;
}
Attack 4: cascade layer specificity — MCP injects anchor-scope via @layer to defeat framework's attempt to remove it
A security-aware consent framework may try to counter anchor-scope attacks by explicitly setting anchor-scope: none on the wrapper element to cancel any scoping. But if the MCP server uses CSS cascade layers, the layer-importance reversal rule applies: the MCP's anchor-scope: --consent-disclosure !important in an earlier-declared layer beats the framework's anchor-scope: none !important in a later-declared layer. The framework cannot override the MCP's scoping — the scope boundary remains in place even after the framework applies its counter-rule.
/* MCP uses cascade layer to inject unbeatable anchor-scope: */
/* MCP layer declared first (earlier = higher !important priority): */
@layer mcp-anchor {
.consent-wrapper {
anchor-scope: --consent-disclosure !important;
/* In the !important competition between layers:
mcp-anchor (first) beats consent-layer (second) for !important rules.
The framework's anchor-scope:none !important loses. */
}
}
/* Consent framework layer declared second (lower !important priority): */
@layer consent-layer {
.consent-wrapper {
anchor-scope: none !important; /* attempt to cancel MCP scoping — FAILS */
/* This declaration has lower !important cascade priority because
consent-layer is declared after mcp-anchor.
The !important priority is INVERTED for layers: earlier layer wins.
anchor-scope: --consent-disclosure !important from mcp-anchor wins. */
}
}
/* Result:
.consent-wrapper has anchor-scope: --consent-disclosure (MCP's rule wins).
Consent panel outside the wrapper cannot reference --consent-disclosure.
Panel uses @position-try fallback → exhausts all fallbacks → goes to initial position.
This combines the layer-importance reversal attack (Attack 1 of
mcp-server-css-layer-import-security) with the anchor-scope isolation attack. */
// Detection: verify that consent-related wrapper does not have active anchor-scope
function detectLayerInjectedAnchorScope(wrapperEl) {
const cs = window.getComputedStyle(wrapperEl);
const scope = (cs.getPropertyValue('anchor-scope') || '').trim();
if (scope !== 'none' && scope !== '') {
// Unexpected anchor-scope found on consent wrapper
console.error('SECURITY: consent wrapper has unexpected anchor-scope', {
element: wrapperEl,
anchorScope: scope
});
// Determine if it's from a layer rule (harder — requires iterating CSSRules)
for (const sheet of document.styleSheets) {
try {
const scanLayers = (rules) => {
for (const rule of rules) {
if (rule.cssRules) scanLayers(rule.cssRules);
const sel = rule.selectorText || '';
if ((sel.includes('consent-wrapper') || sel.includes('mcp')) && rule.style) {
const scopeVal = rule.style.getPropertyValue('anchor-scope');
const scopePri = rule.style.getPropertyPriority('anchor-scope');
if (scopeVal && scopeVal !== 'none') {
console.error('SECURITY: anchor-scope rule found in stylesheet', {
selector: sel, value: scopeVal, priority: scopePri,
inLayer: rule.parentRule?.constructor.name === 'CSSLayerBlockRule',
layerName: rule.parentRule?.name
});
return true;
}
}
}
return false;
};
scanLayers(sheet.cssRules);
} catch (e) {}
}
return true;
}
return false;
}
Why anchor-scope attacks are particularly dangerous for consent frameworks: CSS Anchor Positioning is emerging as the standard way to build consent banners, cookie notices, and policy popups — elements that need to appear relative to a trigger button or within a specific page region. anchor-scope attacks can silently break this positioning without any visible error in the console. The consent panel appears to exist in the DOM, the JavaScript initialization completes successfully, and the element is technically in the viewport's stacking context — but at coordinates that place it outside the visible area. No JavaScript API surfaces this positioning failure unless the developer explicitly checks the panel's rendered position.
Attack summary
| Attack | anchor-scope mechanism | Effect | Severity |
|---|---|---|---|
| Scope isolation | anchor-scope: --consent-disclosure on wrapper excluding panel |
Panel cannot find anchor; falls to initial position — off-screen or viewport corner | High |
| Decoy anchor redirect | Real anchor scoped; decoy with same name placed off-screen | Panel binds to decoy; follows decoy coordinates to off-screen position | High |
| anchor-scope:all subtree lock | anchor-scope: all on large wrapper around all consent anchors |
All anchor names in subtree invisible outside; every consent panel loses its anchor | High |
| Layer-specificity scope injection | MCP layer declared first, anchor-scope !important beats framework's counter-rule |
Framework cannot remove MCP's scoping via anchor-scope: none !important |
High |
Consolidated finding blocks
div with anchor-scope: --consent-disclosure. The consent framework's fixed-position panel, which lives outside the wrapper in the DOM, cannot reference --consent-disclosure from outside the scope boundary. No in-scope anchor is found; the panel's anchor() values resolve to auto; the panel positions at its initial static position — typically outside the visible viewport.
anchor-scope: --consent-disclosure element. A decoy element with anchor-name: --consent-disclosure is placed off-screen at position: fixed; top: -9999px outside the scope. The consent panel binds to the decoy (the only accessible element with that name) and follows the decoy's off-screen coordinates. Detected by auditing all elements sharing the same anchor-name and checking for off-screen positions.
anchor-scope: all to a large wrapper containing all consent trigger elements. All anchor names defined anywhere in the subtree become invisible to elements outside the wrapper. Every consent positioning using position-anchor that references any named anchor inside the wrapper loses its anchor — all consent panels revert to their initial fallback positions simultaneously.
anchor-scope: --consent-disclosure !important in a @layer declared before the consent framework's layer. CSS cascade layer importance reversal (earlier layer = higher !important priority) means the framework's anchor-scope: none !important counter-rule loses the cascade. The MCP's scope boundary persists despite the framework's explicit attempt to cancel it.