Security Guide

MCP server CSS Anchor Positioning security — dynamic anchor position tracking, anchor-size() dimension oracle, @position-try viewport edge probe, position-visibility:anchors-valid DOM presence oracle

CSS Anchor Positioning (Chrome 125+, behind flag in Firefox) solves a long-standing CSS challenge: positioning a floating element relative to another element anywhere in the DOM, without a shared positioned ancestor. A floating element declares position-anchor: --my-anchor and its position properties automatically track the named anchor element. The security assumption is that anchor positioning controls only layout — where tooltips and popovers appear. This assumption is wrong in four ways: floating elements continuously re-position relative to scrolling anchor elements, encoding scroll position in the floater's measured coordinates; anchor-size() reads host anchor dimensions through a dependent element's sizing; @position-try fallback cascades probe which viewport edges the floater hits; and position-visibility:anchors-valid reveals which named anchor elements are present in the DOM.

How CSS Anchor Positioning works

An anchor element is declared by setting anchor-name: --my-anchor on an element. A floating element (an absolutely or fixed positioned element) references it with position-anchor: --my-anchor. The floater can then use anchor() function values in its inset properties to align to the anchor's edges: top: anchor(--my-anchor bottom) places the floater's top edge at the anchor's bottom edge. anchor-size() reads the anchor's dimensions for sizing the floater. @position-try defines fallback positions the browser tries in order if the initial position overflows the viewport. position-visibility controls whether the floater is visible when the anchor is off-screen or absent from the DOM.

Attack 1: dynamic anchor position tracking follows scrolling host elements

When an anchor element scrolls (is inside a scrollable container, or is a sticky element that changes vertical position), the floating element that references it automatically repositions to stay attached. An MCP server can declare a floating element that references a host anchor element — either by guessing a named anchor the host defined, or by injecting anchor-name onto a host element — and then read the floating element's getBoundingClientRect() to extract the anchor element's current position. Since the floater tracks the anchor continuously, reading the floater's position at any moment gives the anchor's current position — encoding scroll offset as element coordinates without scrollTop, scroll event listeners, or IntersectionObserver.

// Anchor position tracking — extract host element scroll position
// via a floating element that tracks it automatically

// Step 1: inject anchor-name onto a host element (or guess an existing anchor)
// If host has: anchor-name: --page-header  (e.g., defined in their stylesheet)
// we don't need to inject anything for step 1.

// If host doesn't define anchors, inject onto a known element:
document.querySelector('.sticky-header').style.anchorName = '--mcp-track';

// Step 2: inject a floating element that tracks the anchor
const floater = document.createElement('div');
floater.style.cssText = `
  position: fixed;              /* fixed positioning uses anchor relative to viewport */
  position-anchor: --mcp-track; /* attach to the sticky header anchor */
  top: anchor(bottom);          /* floater's top = anchor's bottom edge */
  left: anchor(left);           /* floater's left = anchor's left edge */
  width: 1px;
  height: 1px;
  pointer-events: none;
  visibility: hidden;           /* invisible but still positioned */
`;
document.body.appendChild(floater);

// Step 3: read the floater's position to extract the anchor's coordinates
// (works during scroll without any scroll event listener)
function getAnchorPosition() {
  const rect = floater.getBoundingClientRect();
  return {
    // floater.top = anchor.bottom → anchor.top = floater.top - anchor.height
    // (anchor-size(height) can be used to get the anchor height too)
    anchorBottomY: rect.top,
    anchorLeftX: rect.left,
  };
}

// For a sticky header that starts at y=0 and sticks once scrolled:
// getAnchorPosition().anchorBottomY encodes the header's current position
// as a function of scroll offset — passive scroll oracle from CSS geometry

Passive scroll surveillance: If the host application defines any anchor elements on sticky, parallax, or scroll-driven elements (common in modern hero sections and sticky navigation), an MCP server can immediately begin tracking their position without injecting anything onto the host elements — just declaring its own floater that targets the host's existing named anchors. SkillAudit checks for MCP servers that inject floating elements with position-anchor pointing to names not in the MCP server's own stylesheet.

Attack 2: anchor-size() reads host anchor dimensions through dependent element sizing

anchor-size() reads the anchor element's width or height and uses it to size the floating element. For example, width: anchor-size(--target width) makes the floater exactly as wide as the anchor element. An MCP server can read the anchor's rendered dimensions by measuring the floater instead of the anchor directly. The attack works even if the MCP server doesn't have permission to call getBoundingClientRect on the anchor element directly — it just reads the floater (which it owns and controls). This provides an indirect dimension oracle: the anchor element's width and height are encoded as the floater's width and height, both fully accessible via offsetWidth / offsetHeight or getBoundingClientRect.

// anchor-size() as an indirect dimension oracle
// Reads anchor element's width/height through the floater's sizing

// MCP server creates a floating element that takes on the anchor's dimensions:
const probe = document.createElement('div');
probe.style.cssText = `
  position: fixed;
  position-anchor: --target-element;  /* attach to host's named anchor */
  width: anchor-size(--target-element width);   /* same width as anchor */
  height: anchor-size(--target-element height); /* same height as anchor */
  top: 0; left: 0;
  pointer-events: none;
  visibility: hidden;
  contain: layout; /* ensure contain doesn't break sizing */
`;
document.body.appendChild(probe);

// After one layout pass, the probe reflects the anchor's dimensions:
requestAnimationFrame(() => {
  const anchorWidth = probe.offsetWidth;   // = anchor element's rendered width
  const anchorHeight = probe.offsetHeight; // = anchor element's rendered height

  // Use for fingerprinting: element dimensions identify viewport size,
  // container layout, and whether content has loaded (empty vs populated card).
  // E.g., a .user-profile-card that is 80px tall when no avatar is loaded
  // vs 240px when a profile photo and bio are present.
});

// Extension: use anchor-size(width) in a calc() for more precision:
probe.style.fontSize = 'calc(anchor-size(--target-element width) * 1px)';
// Now getComputedStyle(probe).fontSize encodes the anchor width as a font-size string

Attack 3: @position-try fallback cycling probes viewport edge proximity

@position-try defines a list of alternative positions for a floating element when the primary position overflows the viewport. The browser tries each @position-try in order and applies the first one that keeps the floater within the viewport. An MCP server can define a sequence of @position-try rules with systematically varying offsets — each encoding a different estimated distance from a viewport edge — and read which fallback is active by reading the floater's computed position. When the primary overflows and fallback N is active, the floater's position encodes that the anchor element is within N×step pixels of a specific viewport edge. By designing the fallback cascade carefully, the MCP server can binary-search the anchor's position relative to viewport edges, fingerprinting both viewport size and the anchor's precise on-screen location.

/* @position-try fallback cascade as viewport edge binary search */
/* Primary position: place floater to the right of anchor */
.probe-floater {
  position: fixed;
  position-anchor: --target;
  position-try-fallbacks: flip-block, flip-inline, --half-right, --quarter-right;
  /* The browser tries primary → flip-block → flip-inline → --half-right → --quarter-right */
  left: anchor(right);   /* primary: to the right */
  top: anchor(top);
}

@position-try --half-right {
  /* Applied when there's less than half-viewport-width to the right */
  left: calc(anchor(right) - 50vw);
  top: anchor(top);
}

@position-try --quarter-right {
  /* Applied when there's less than quarter-viewport-width to the right */
  left: calc(anchor(right) - 25vw);
  top: anchor(top);
}

// JavaScript reads which fallback is active:
// The position-try-order property tells us which fallback was selected,
// but we can also infer it from getComputedStyle(floater).left:
const left = parseFloat(getComputedStyle(floater).left);
if (left === primaryLeft) {
  // Primary position active → anchor is not near right edge
} else if (left === halfRight) {
  // --half-right active → anchor is within 50vw of the right edge
} else if (left === quarterRight) {
  // --quarter-right active → anchor is within 25vw of the right edge
}
// Reconstruct anchor's distance from viewport edge from which fallback applied

Attack 4: position-visibility:anchors-valid detects which named anchors are in the DOM

position-visibility: anchors-valid makes a floating element visible only when its anchor element is present and within scroll range. When the anchor is not in the DOM (e.g., the component that renders it hasn't mounted yet, or has been conditionally removed), the floater is hidden. An MCP server can inject multiple floating elements, each targeting a different named anchor corresponding to host components (--admin-panel, --checkout-form, --user-avatar), and poll getComputedStyle(floater).visibility for each. A floater that is visible indicates its named anchor is currently in the DOM; one that is hidden indicates the anchor is absent — encoding which host components are currently mounted without a single DOM query.

// position-visibility:anchors-valid as a component mount oracle
// Each floating element's visibility encodes the presence of its anchor

const anchorProbes = [
  '--admin-panel',
  '--checkout-form',
  '--user-profile',
  '--notification-tray',
  '--payment-method-selector',
];

const probeElements = anchorProbes.map(anchorName => {
  const probe = document.createElement('div');
  probe.style.cssText = `
    position: fixed;
    position-anchor: ${anchorName};   /* target named anchor */
    position-visibility: anchors-valid; /* only visible when anchor is in DOM */
    width: 0; height: 0;
    top: anchor(top); left: anchor(left);
    pointer-events: none;
  `;
  probe.dataset.anchor = anchorName;
  document.body.appendChild(probe);
  return probe;
});

// Poll visibility to detect component mount/unmount events:
function detectMountedComponents() {
  return probeElements.reduce((state, probe) => {
    const visible = getComputedStyle(probe).visibility !== 'hidden';
    state[probe.dataset.anchor] = visible; // true = component is mounted
    return state;
  }, {});
}

// Example: { '--admin-panel': false, '--checkout-form': true, '--user-profile': true }
// Reveals: user is in checkout flow, not in admin panel — maps current app state
// without any querySelectorAll, without reading DOM text, without observing events

App state surveillance via CSS: The position-visibility:anchors-valid oracle reveals the user's current app state (which screens, modals, panels are mounted) without reading DOM content or intercepting events. For multi-page SPAs where routing is reflected in mounted components, this encodes the user's navigation path. SkillAudit checks for MCP servers that inject floating elements targeting non-owned anchor-name values with position-visibility:anchors-valid.

AttackAnchor positioning mechanismWhat it reads / enablesBrowser support
Dynamic scroll trackingFloater auto-repositions with scrolling anchorHost element scroll position extracted from floater's getBoundingClientRect without scrollTopChrome 125+
Dimension oracleanchor-size() sets floater dimensions to anchor's sizeHost anchor element's width/height read indirectly from MCP-owned floater dimensionsChrome 125+
Viewport edge probe@position-try fallback cascade selectionActive fallback encodes anchor's distance from viewport edges — position and viewport fingerprintingChrome 125+
DOM presence oracleposition-visibility: anchors-validFloater visibility encodes whether named anchor is in DOM — detects which host components are mountedChrome 125+

SkillAudit findings for CSS Anchor Positioning

MEDIUMDynamic scroll tracking: a floating element with position-anchor targeting a host scrolling element re-positions automatically with the anchor, encoding the anchor's scroll-driven coordinates in the floater's getBoundingClientRect — a passive scroll oracle without scrollTop or scroll event listeners.
MEDIUManchor-size() dimension oracle: injecting a floating element with width: anchor-size(--target width) encodes the host anchor element's rendered dimensions as the floater's dimensions, readable without getBoundingClientRect on the anchor directly — an indirect dimension readout of host elements.
LOW@position-try viewport edge probe: a cascade of fallback positions with systematically varying offsets, combined with reading the active fallback from the floater's computed position, performs a binary search on the anchor's distance from viewport edges — viewport size and element position fingerprinting via CSS fallback state.
HIGHComponent mount oracle via position-visibility:anchors-valid: multiple floating elements each targeting a different named anchor report which host components are currently mounted via their computed visibility — passive app state surveillance encoding the user's navigation state without DOM queries or event listeners.

Defences

Avoid globally-named anchor elements in security-sensitive contexts: The scroll tracking and dimension oracle attacks require the MCP server to know the host's anchor-name values. Using non-predictable, component-scoped anchor names (e.g., hashed or randomly-suffixed names generated at build time) instead of semantic names (--header, --sidebar) prevents name guessing. Anchor names set in component-scoped shadow DOM are not accessible from the light DOM.

CSP blocks anchor-name injection: The scroll tracking attack requires injecting anchor-name onto host elements if they don't already define named anchors. Strict style-src 'self' CSP prevents MCP servers from setting anchor-name or position-anchor via inline styles or injected style blocks.

Audit MCP server code for position-anchor references to non-owned names: SkillAudit flags MCP servers that set position-anchor to names that do not appear in the MCP server's own stylesheet. Referencing externally-defined anchor names is the basis of all four attacks in this article and has no legitimate use case for MCP servers that are not part of the host app's own component system.

Use contain: layout on anchor elements: CSS containment on anchor elements limits how their position changes propagate to dependent layout, reducing the precision of the scroll-tracking oracle. It also ensures that anchor position changes don't trigger layout recalculations outside the contained subtree, which can reduce timing leakage from rAF-based position polling.

Related: CSS anchor positioning basics security · CSS scroll-driven animations security · CSS contain security