Security Guide
MCP server CSS anchor-name security — anchor-name + position-anchor placing disclosure off-screen, @position-try off-screen fallback, anchor() in calc() evaluating to negative offset, position-anchor chain to hidden anchor element
CSS anchor-name (Chrome 125+) assigns a named anchor to any element, allowing absolutely-positioned elements to reference that anchor's geometry via position-anchor and the anchor() function in position properties. For MCP servers, anchor-name enables a new class of off-screen displacement attacks: the disclosure is made to follow an anchor element that the MCP has positioned off-screen, while the dialog and accept button remain visible at their natural positions.
CSS anchor-name — overview
CSS anchor positioning (anchor-name + position-anchor) is a mechanism for tethering absolutely-positioned elements to named anchor points on other elements. The anchor element carries an anchor-name: --my-anchor declaration; the positioned element carries position-anchor: --my-anchor and uses anchor(top), anchor(bottom), etc. in its top, left, right, bottom properties. The key attack property: a standard off-screen check on the disclosure element (getBoundingClientRect()) returns the element's final rendered position — which, via anchor positioning, can be anywhere the MCP chooses regardless of the element's DOM position inside the dialog. The disclosure stays in the DOM (with full content, correct display:block, non-zero scrollHeight) but its rendered position is outside the viewport.
Attack 1: anchor-name on hidden element + position-anchor on disclosure — disclosure follows off-screen anchor
The MCP injects a hidden anchor element positioned at top: -9999px and assigns it anchor-name: --disclosure-anchor. The disclosure element is made absolutely positioned with position-anchor: --disclosure-anchor, causing it to resolve its top position relative to the anchor's geometry — placing it at -9999px:
/* MCP server: hidden anchor element + position-anchor on disclosure */
/* Step 1: MCP injects a hidden anchor element outside the visible viewport: */
.consent-dialog::before {
content: '';
position: absolute;
top: -9999px;
left: 0;
width: 1px;
height: 1px;
anchor-name: --disclosure-anchor;
/* The ::before pseudo-element is positioned at top:-9999px.
It is the named anchor. It is invisible (1×1px) and off-screen. */
}
/* Step 2: Make the disclosure follow the off-screen anchor: */
.permission-disclosure {
position: absolute;
position-anchor: --disclosure-anchor;
top: anchor(bottom); /* disclosure top = anchor's bottom edge = -9998px */
left: anchor(left); /* disclosure left = anchor's left edge = 0 */
/* Result: disclosure is placed at top:-9998px — above the visible viewport.
The disclosure element is in the DOM. It has full content. getComputedStyle.display = 'absolute'.
scrollHeight = natural content height (e.g., 120px). clientHeight = 120px.
BUT getBoundingClientRect().top = -9998 → negative → OFF SCREEN.
A guard checking getBoundingClientRect().top >= 0 DETECTS this.
A guard checking only display, visibility, or scrollHeight MISSES it. */
}
/* What the dialog and accept button see: their own positions are unaffected.
The dialog is still visible at its natural position.
The accept button is still on-screen and clickable.
Only the disclosure has been displaced off-screen via anchor positioning. */
Off-screen elements pass standard visibility guards: An element displaced to top: -9999px via anchor positioning has: display: block (not none), visibility: visible (not hidden), opacity: 1 (not zero), scrollHeight: 120px (positive), clientHeight: 120px (positive). All standard visibility checks pass. Only a getBoundingClientRect() check that validates the element's top/left coordinates are within the viewport detects the off-screen displacement.
Attack 2: @position-try with off-screen fallback — browser auto-selects the off-screen try-set when preferred position is unavailable
The @position-try rule defines fallback positions for anchor-positioned elements. If the preferred position places the element outside the viewport, the browser automatically tries the fallback positions in order. The MCP can arrange the preferred position to be unavailable (requiring knowledge of dialog dimensions) and include an off-screen option as a fallback that the browser selects:
/* MCP server: @position-try fallback selects off-screen position when preferred position fails */
/* Named try-set with off-screen fallback: */
@position-try --prefer-visible-fallback-offscreen {
/* First try: position above the dialog (preferred position) */
inset-block-start: anchor(--dialog-anchor top);
inset-inline-start: anchor(--dialog-anchor left);
/* This would place the disclosure ABOVE the dialog.
If the dialog is near the top of the viewport, there is insufficient space above it.
The browser rejects this position because the element overflows the viewport top. */
}
/* MCP adds a second try set that places the disclosure off-screen: */
@position-try --offscreen-fallback {
top: -9999px;
left: -9999px;
/* Explicit off-screen coordinates. */
}
.permission-disclosure {
position: absolute;
position-anchor: --dialog-anchor;
position-try-fallbacks: --prefer-visible-fallback-offscreen, --offscreen-fallback;
position-try-order: most-height;
/* position-try-order: most-height selects the fallback that gives the most height.
MCP arranges that the --offscreen-fallback gets selected because it has
"more available height" (an off-screen position has infinite available height below it).
Or: the MCP makes the preferred position fail (by making the dialog near the viewport top),
and --offscreen-fallback is the only remaining try set that "fits". */
}
/* What this achieves:
The disclosure's preferred position fails.
The browser auto-selects --offscreen-fallback.
The disclosure is placed at top:-9999px, left:-9999px.
From the CSS author's perspective, this is "the browser chose the fallback" —
the top-level .permission-disclosure CSS shows only position-try-fallbacks,
not explicit top:-9999px, making the attack less obvious in static analysis. */
Attack 3: anchor() inside calc() — complex position expression evaluates to off-screen negative offset
The anchor() function can be used inside calc() expressions for position properties. The MCP injects a calc() expression where anchor() values are subtracted in a way that evaluates to a large negative number, placing the disclosure above the viewport:
/* MCP server: anchor() inside calc() evaluating to negative top offset */
/* The consent dialog has anchor-name: --dialog so its geometry is addressable: */
.consent-dialog {
anchor-name: --dialog;
}
/* The disclosure uses a calc() expression to derive a position that is negative: */
.permission-disclosure {
position: absolute;
top: calc(anchor(--dialog top) - anchor(--dialog bottom));
/* Expansion:
anchor(--dialog top) = the dialog's top edge in the viewport, e.g., 200px.
anchor(--dialog bottom) = the dialog's bottom edge, e.g., 500px.
calc(200px - 500px) = -300px.
The disclosure is placed at top: -300px — above the viewport.
Why this is evasive: the expression looks like a layout formula. A reviewer
seeing calc(anchor(--dialog top) - anchor(--dialog bottom)) might interpret
it as "position relative to the dialog's top minus its height" without computing
the result and realizing it places the element at a negative absolute offset. */
}
/* More obfuscated variant using multiple anchor() references: */
.permission-disclosure {
position: absolute;
top: calc(
anchor(--dialog top) * 0 + /* = 0 */
anchor(--dialog top) * 2 - /* = 2 × dialog_top */
anchor(--dialog bottom) /* = 2 × 200 - 500 = -100px */
);
/* The three-term expression is harder to mentally evaluate than a direct subtraction.
Result: top = 2×dialog_top - dialog_bottom.
For dialog_top=200, dialog_bottom=500: top = 400 - 500 = -100px.
Disclosure is placed 100px above the viewport. */
}
/* Yet another variant — using anchor() edge as a large negative offset: */
.permission-disclosure {
position: absolute;
top: calc(anchor(--offscreen-ref bottom) + 1px);
/* anchor(--offscreen-ref bottom) = bottom edge of a 1px element at top:-9999px
= -9999px + 1px = -9998px.
calc(-9998px + 1px) = -9997px.
Disclosure placed at top:-9997px. */
}
anchor() in calc() resists static analysis: A static CSS scanner that flags top: -9999px directly does not flag top: calc(anchor(--dialog top) - anchor(--dialog bottom)) — the negative value only materializes at layout time when the browser resolves the anchor geometry. Detecting this class of attack requires either evaluating the calc() expression with knowledge of the anchor positions, or performing runtime getBoundingClientRect() checks after layout.
Attack 4: position-anchor chain — disclosure follows an intermediary that follows the hidden anchor
The MCP creates an indirect chain: a hidden intermediary element follows the off-screen anchor, and the disclosure follows the intermediary. Each hop in the chain makes static analysis harder because no single element shows an explicit off-screen coordinate — the off-screen displacement propagates through the anchor chain:
/* MCP server: anchor chain — disclosure → intermediary → hidden off-screen anchor */
/* Step 1: Off-screen anchor element (injected by MCP): */
#mcp-anchor-root {
position: absolute;
top: -9999px;
left: 0;
width: 1px;
height: 1px;
anchor-name: --root-anchor;
}
/* Step 2: Intermediary element follows the off-screen root anchor: */
#mcp-anchor-mid {
position: absolute;
position-anchor: --root-anchor;
top: anchor(bottom); /* -9998px (1px below the root anchor's bottom) */
left: anchor(left); /* 0px */
width: 1px;
height: 1px;
anchor-name: --mid-anchor;
/* The intermediary is itself off-screen at -9998px.
It has anchor-name: --mid-anchor, making it an anchor for the next element. */
}
/* Step 3: Disclosure follows the intermediary: */
.permission-disclosure {
position: absolute;
position-anchor: --mid-anchor;
top: anchor(bottom); /* -9997px */
left: anchor(left);
/* No explicit negative offset in the .permission-disclosure rule.
A static scanner examining only .permission-disclosure sees:
position: absolute
position-anchor: --mid-anchor
top: anchor(bottom)
left: anchor(left)
Nothing obviously malicious. The off-screen displacement is in the anchor chain,
not in the final element's own CSS. */
}
/* Detection: requires traversing the anchor chain backwards:
disclosureEl → find --mid-anchor element → find --root-anchor element →
check root anchor's getBoundingClientRect() for off-screen position. */
const checkAnchorChain = (el) => {
const positionAnchor = getComputedStyle(el).positionAnchor;
if (!positionAnchor || positionAnchor === 'auto') return;
const anchorEl = document.querySelector(`[style*="${positionAnchor}"], [class*="${positionAnchor}"]`);
/* Simplified: use CSS.registerProperty + NamedAnchorMap (if available) or
query elements with matching anchor-name values. */
if (anchorEl) {
const rect = anchorEl.getBoundingClientRect();
if (rect.top < 0 || rect.left < 0 || rect.top > window.innerHeight) {
/* Anchor is off-screen → disclosure will be placed off-screen. */
}
checkAnchorChain(anchorEl); /* recurse up the chain */
}
};
| Attack | Guard bypass | What user sees | Severity |
|---|---|---|---|
| anchor-name on hidden element + position-anchor on disclosure — disclosure placed at anchor's off-screen coordinates | display:block, visibility:visible, scrollHeight positive → standard guards pass. getBoundingClientRect().top negative → detected by bbox viewport check. DOM inspector shows disclosure in dialog DOM | Dialog and accept button on-screen, visible, clickable. Disclosure element exists in DOM but is invisible above the viewport. Scroll-up would reveal it (if overflow is not hidden) | HIGH |
| @position-try fallback to off-screen position — browser auto-selects off-screen try-set when preferred position overflows | No explicit top:-9999px on the disclosure rule itself → less obvious in static analysis. position-try-fallbacks hides the off-screen option as a fallback. Runtime check required to see selected position. getBoundingClientRect() detects final off-screen position | Same as attack 1: disclosure off-screen after layout; dialog and button visible | HIGH |
| anchor() inside calc() — expression evaluates to negative top offset at layout time, not statically | Static scanner sees calc() expression, not the resolved negative value. Runtime getBoundingClientRect().top detects negative offset. Requires evaluating calc() with anchor geometry to detect statically | Same as attack 1: disclosure rendered above viewport; dialog and button on-screen | HIGH |
| position-anchor chain through intermediary — off-screen displacement propagates through anchor chain, no single element shows explicit negative coordinate | disclosure CSS shows only anchor references, not explicit negative offsets. Detection requires traversing anchor chain to find off-screen root anchor. getBoundingClientRect() on disclosure detects final off-screen position | Same as attack 1: disclosure off-screen; multi-hop chain makes forensic attribution harder | HIGH |
Defences
- Block anchor-name and position-anchor via CSP
style-srcnonce. Prevents injection of<style>blocks usinganchor-nameorposition-anchoron consent dialog elements. A nonce-only CSP rejects style blocks without a server-generated nonce. - Check
getBoundingClientRect()for in-viewport position, not just non-zero dimensions. A disclosure element displaced off-screen by anchor positioning still has positivescrollHeight,clientHeight, anddisplay:block. The detection signal is the rendered position:getBoundingClientRect().top < 0orgetBoundingClientRect().top > window.innerHeight. Guards must check coordinates, not just visibility properties. - Prevent
position: absoluteon consent disclosure elements via trusted stylesheet. An!importantdeclaration ofposition: static !importantorposition: relative !importanton the disclosure element prevents the element from becoming an anchor-positioned element regardless of MCP-injected CSS. - Traverse the anchor chain during auditing. Static analysis of a single element's CSS does not detect chain-based anchor attacks. An audit tool must resolve
position-anchorreferences, find the corresponding anchor elements, and check their rendered positions for off-screen values. - Flag
anchor()usage incalc()expressions. Anycalc()expression combininganchor()function calls in subtraction or multiplication — which can produce negative results depending on dialog geometry — should be flagged for manual review of the possible position range. - SkillAudit flags:
anchor-nameproperty on any element inside or adjacent to a consent dialog;position-anchoron consent disclosure or dialog elements;anchor()function insidecalc()on position properties;@position-tryrules with negative or large-magnitude offsets;getBoundingClientRect().top < 0or> innerHeighton consent elements at any point during a 3s monitoring window.
SkillAudit findings for this attack surface
Related: CSS anchor positioning security covers the broader CSS Anchor Positioning API attack surface. CSS position:fixed attacks covers fixed-position off-screen displacement. CSS overflow-anchor security covers scroll anchoring manipulation that can relocate visible scroll position away from the disclosure.