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 */
  }
};
AttackGuard bypassWhat user seesSeverity
anchor-name on hidden element + position-anchor on disclosure — disclosure placed at anchor's off-screen coordinatesdisplay:block, visibility:visible, scrollHeight positive → standard guards pass. getBoundingClientRect().top negative → detected by bbox viewport check. DOM inspector shows disclosure in dialog DOMDialog 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 overflowsNo 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 positionSame as attack 1: disclosure off-screen after layout; dialog and button visibleHIGH
anchor() inside calc() — expression evaluates to negative top offset at layout time, not staticallyStatic scanner sees calc() expression, not the resolved negative value. Runtime getBoundingClientRect().top detects negative offset. Requires evaluating calc() with anchor geometry to detect staticallySame as attack 1: disclosure rendered above viewport; dialog and button on-screenHIGH
position-anchor chain through intermediary — off-screen displacement propagates through anchor chain, no single element shows explicit negative coordinatedisclosure 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 positionSame as attack 1: disclosure off-screen; multi-hop chain makes forensic attribution harderHIGH

Defences

SkillAudit findings for this attack surface

HIGHanchor-name on ::before pseudo-element at top:-9999px + position-anchor on disclosure — disclosure displaced to -9998px: MCP injects anchor-name on a 1×1px ::before element at top:-9999px; sets disclosure position-anchor to --disclosure-anchor; disclosure's top:anchor(bottom) resolves to -9998px; display:block, scrollHeight positive → standard guards pass; getBoundingClientRect().top=-9998 → detected by bbox viewport check
HIGH@position-try fallback to off-screen coordinates — browser selects off-screen fallback when preferred position overflows viewport: MCP injects @position-try with off-screen fallback; prefers a position that overflows the viewport top; browser auto-selects the off-screen fallback; no explicit top:-9999px in the disclosure's own CSS rule → static scanner less likely to flag; runtime bbox check detects final position
HIGHanchor() in calc() — top:calc(anchor(--dialog top) - anchor(--dialog bottom)) resolves to negative offset at layout time: MCP injects calc() expression that evaluates to negative top offset using dialog geometry; static scanner sees calc() expression, not the resolved value; expression only materializes as negative during browser layout; getBoundingClientRect().top negative → detected at runtime; requires anchor-geometry-aware static analysis to detect without runtime
HIGHposition-anchor chain through intermediary — off-screen displacement propagated through two-hop anchor chain, disclosure CSS shows no explicit negative value: MCP injects root off-screen anchor → intermediary anchor element → disclosure follows intermediary; disclosure CSS shows only anchor references; static analysis must traverse chain; runtime getBoundingClientRect() on disclosure detects final off-screen position; multi-hop chain increases attribution complexity

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.

← Blog  |  Security Checklist