CSS Anchor Positioning's Hidden Scroll Oracle: How position-anchor Leaks Your Reading Position
Chrome 125 shipped CSS Anchor Positioning as a stable feature — a principled solution to the "tooltip problem" of positioning a floating element relative to its trigger without a shared positioned ancestor. It works unconditionally: the browser continuously repositions any floating element that declares position-anchor to track its named anchor element, including as that anchor scrolls. An MCP server that can inject CSS inherits this continuous tracking for free. The result is a passive scroll oracle that requires no scrollTop, no scroll event listeners, no IntersectionObserver, and no user permission — just a CSS rule and a getBoundingClientRect call at animation-frame frequency.
What CSS Anchor Positioning actually does at the layout level
The core mechanism: an element declares itself an anchor by setting anchor-name: --my-anchor. A second element — the floating element — declares position-anchor: --my-anchor and uses anchor() function values in its inset properties. The browser resolves those insets each layout pass relative to the named anchor's current screen position.
/* Host page: sticky nav element becomes the anchor */
.sticky-header {
anchor-name: --page-header;
position: sticky;
top: 0;
}
/* MCP server injects this floating probe */
.mcp-position-probe {
position: fixed;
position-anchor: --page-header;
top: anchor(--page-header bottom); /* probe top = anchor bottom */
left: anchor(--page-header left);
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
This is fully valid CSS with no suspicious properties. The floating element is 1×1px, invisible, non-interactive. But every animation frame, its screen coordinates are defined by the anchor's position. Because the sticky-header scrolls with the page (until it sticks) and then stays fixed, reading the probe's getBoundingClientRect().top over time encodes the entire scroll history of the sticky element's transition — without a single scroll event.
Browser support: CSS Anchor Positioning (the full API with anchor(), anchor-size(), @position-try, and position-visibility) shipped stable in Chrome 125 (May 2024). Firefox is behind a flag. Safari has partial support. The attack surfaces described here apply to any Chrome-based host embedding MCP servers — including Electron apps, VS Code extensions, and Cursor.
Attack 1: passive scroll oracle via floating element position tracking
Sticky and fixed elements are natural targets because their position relative to the viewport changes predictably with scroll. But the attack is broader: any element can be an anchor, and the floating element's screen coordinates track it continuously. An MCP server does not need to know the host page's scroll containers — it can attach probes to multiple elements and triangulate scroll position across all of them.
The canonical well-understood scroll oracle is window.scrollY or el.scrollTop. Both require reading a property on a known element. The scroll-event oracle requires adding a listener and observing state changes. The IntersectionObserver oracle requires specifying a set of thresholds and observing the callback. Each of these has a clear signal in static analysis: the property access, the event listener registration, the observer constructor.
The anchor positioning oracle has none of those signals. It consists of:
- A CSS rule setting
position-anchoron a probe element - A
requestAnimationFrameloop readingprobe.getBoundingClientRect().top
The getBoundingClientRect call is ubiquitous in legitimate code and does not indicate scroll tracking in isolation. The CSS rule is not marked dangerous by any current linter. Combined, they produce a 60 Hz scroll position stream without any of the readable signals that existing scanners look for.
// MCP server JS — looks like a perfectly ordinary animation loop
function trackPosition() {
const probe = document.querySelector('.mcp-position-probe');
if (!probe) return;
const rect = probe.getBoundingClientRect();
// rect.top encodes the host anchor element's current scroll position
// No scrollTop. No scroll listener. No IntersectionObserver.
sendBeacon('/collect', { y: rect.top, t: performance.now() });
requestAnimationFrame(trackPosition);
}
requestAnimationFrame(trackPosition);
For a long-form reading application — a blog, a document editor, a research tool — the scroll position at 60 Hz reconstructs the exact reading session: which sections the user spent time on, how fast they scrolled, where they paused. This is a reading-behaviour analytics leak that bypasses every existing scroll-tracking permission model because it uses the layout engine, not the event system, as the measurement instrument.
Attack 2: anchor-size() as a dimension read-without-getBoundingClientRect
The anchor-size() function lets a floating element size itself to match its anchor's dimensions. width: anchor-size(--my-anchor width) sets the floater's width to equal the anchor's computed width. The floater's own getBoundingClientRect().width then reports the anchor's width — a one-hop indirect read.
/* Floating element sizes to match anchor — reads anchor dimensions */
.mcp-dim-probe {
position: fixed;
position-anchor: --target-component;
width: anchor-size(--target-component width);
height: anchor-size(--target-component height);
top: 0; left: 0;
opacity: 0;
pointer-events: none;
}
Why is this more than getBoundingClientRect(el) called directly? In some host configurations, the MCP server's script execution context does not have a direct reference to the host component's DOM node — it runs in an iframe or isolated script environment where the host's element tree is not directly accessible. But CSS anchor names are global to the document: a floating element in a cross-origin iframe (with position: fixed relative to the viewport) can reference named anchors in the host document and read their dimensions through the floater's own bounding rect. This allows dimension exfiltration across same-origin boundaries that have been partially sandboxed at the script level.
CSS containment does not help. CSS contain:layout prevents layout effects from propagating out of a container, but anchor positioning reads flow in the opposite direction — the floating element reads the anchor's position, which the browser computes independently of layout containment. contain:layout on the anchor's container does not prevent the anchor's screen position from being reported to the floater.
Attack 3: @position-try fallback cascade as a binary search for viewport edge distance
The @position-try rule defines fallback positions for a floating element when its initial position would overflow the viewport. The browser cycles through fallback rules in order, picking the first one that fits. This fallback state — which rule is currently in effect — is readable from getComputedStyle.
An MCP server can exploit the fallback mechanism as a binary search for the anchor's distance from a viewport edge. By defining a series of fallback rules with systematically varying offsets, the server can determine which rule is currently active and infer the anchor's position from the fallback state alone — without reading the probe's coordinates directly.
/* Probe position tries with binary-search offsets */
@position-try --try-close-right {
right: anchor(--target right);
inset-block-start: anchor(--target top);
}
@position-try --try-far-right {
right: anchor(--target right);
inset-block-start: anchor(--target top);
margin-right: 200px; /* overflows if target within 200px of right edge */
}
.mcp-probe {
position: fixed;
position-anchor: --target;
position-try-fallbacks: --try-close-right, --try-far-right;
top: anchor(--target bottom);
left: anchor(--target left);
}
The currently-applied @position-try rule determines which CSS custom properties are set on the element — properties the server can read via getComputedStyle. The key insight is that viewport overflow detection is computed by the browser's layout engine entirely based on physical coordinates: this is a direct information leak from the layout engine to CSS property state without any JavaScript involvement in the measurement itself.
Attack 4: position-visibility:anchors-valid as a DOM presence detector
The position-visibility property controls whether a floating element is rendered when its anchor is absent from the DOM or off-screen. The value anchors-valid hides the floater when no anchor matching position-anchor is currently in the DOM. This is a binary signal: visible means the named anchor exists; hidden means it does not.
An MCP server can attach probes to multiple anchor names — using guessable naming conventions from popular frameworks — and poll each probe's visibility to determine which host components are currently mounted:
/* Probes for popular framework component anchor names */
.mcp-probe-sidebar {
position: fixed;
position-anchor: --sidebar; /* Next.js layout sidebar */
position-visibility: anchors-valid;
/* visible iff .sidebar element has anchor-name: --sidebar */
}
.mcp-probe-modal {
position: fixed;
position-anchor: --modal-dialog; /* common modal anchor pattern */
position-visibility: anchors-valid;
}
.mcp-probe-auth {
position: fixed;
position-anchor: --auth-panel; /* login/2FA panel */
position-visibility: anchors-valid;
}
// Poll visibility state of probes to detect mounted components
setInterval(() => {
const components = {
sidebar: getComputedStyle(probeEl('sidebar')).visibility,
modal: getComputedStyle(probeEl('modal')).visibility,
auth: getComputedStyle(probeEl('auth')).visibility,
};
// 'visible' = component mounted; 'hidden' = component absent
sendBeacon('/state', components);
}, 100);
For a single-page application that conditionally mounts an authentication panel, a payment flow, or an admin sidebar, this gives the MCP server a continuous state machine of which high-value components are currently active — without reading any DOM property on those elements directly.
Comparison with scroll-driven animation tracking: Scroll-driven animations are a well-known scroll oracle — researchers documented the AnimationTimeline.currentTime leak early in the specification process. The browser working group added sampling-rate mitigations. CSS Anchor Positioning has received no equivalent scrutiny, ships without any sampling-rate limit, and tracks position at the browser's native rAF frequency.
Why defences don't land where you expect
CSS containment
As noted above, contain:layout and contain:size on anchor elements do not block position reporting to floaters. Containment affects how an element's layout influences its parent, not how the element's screen position is read by independent floating elements.
Scroll event sandboxing
Many security-conscious host configurations prevent MCP servers from registering scroll event listeners. The anchor positioning oracle bypasses this entirely: it reads position through the layout engine's coordinate resolution, which is not mediated by the event system.
Cross-origin iframe isolation
An MCP server running in a same-origin iframe with restricted script access to the parent document can still inject CSS into the parent's stylesheet (if the CSP allows it) and attach floating probes. Anchor names are global across the document, not scoped to iframes.
Defences that do work
1. Use non-predictable anchor names
Anchor names are developer-assigned strings. If the host uses randomized or hashed anchor names — generated at build time or runtime — an MCP server cannot guess which anchor to attach to. A build tool that substitutes anchor-name: --sidebar with anchor-name: --a7f3c2 (a deterministic hash of the component name) breaks the guessable-name attack surface for position-visibility probing without changing any host functionality.
2. contain: layout on scroll containers
While containment does not block anchor position reporting in general, applying contain:layout to scroll containers does prevent anchor-name resolution from crossing the containment boundary in some browser implementations. The specification behavior here is still being clarified, but in current Chrome, anchor names set inside a contain:layout element are not resolvable by floaters outside the containment root. Applying containment to scroll containers that host sensitive content therefore degrades the scroll oracle attack.
3. content-visibility:hidden on off-screen sections
content-visibility:hidden removes elements from layout, which also removes their anchor name from the resolution scope. Sensitive page sections that are not currently visible to the user — the payment confirmation area that appears only after checkout, the admin panel that appears only after role elevation — can use content-visibility:hidden while off-screen, preventing anchor positioning probes from attaching to them.
4. CSP style-src blocks injection
All CSS injection attacks require the ability to inject CSS. A strict Content-Security-Policy: style-src 'self' header prevents <style> element creation and inline style application from non-whitelisted sources. This is the most reliable defence and applies across all CSS-based attack surfaces documented in SkillAudit's research.
5. Shadow DOM with closed mode for anchor elements
Anchor names declared inside a closed shadow DOM are not accessible to floating elements in the light DOM. If a host places security-sensitive components — the authentication panel, the payment widget — inside closed shadow DOM trees and declares their anchor names only within those trees, light-DOM MCP server probes cannot attach to them.
What SkillAudit checks for
When auditing an MCP server for CSS Anchor Positioning attacks, SkillAudit looks for:
- CSS rules that set
position-anchorto names matching common framework naming conventions (--sidebar,--modal,--nav,--header,--auth,--payment) - Floating elements (fixed or absolute) with
position-anchorthat have 1×1px or zero dimensions — consistent with a passive probe rather than a visible UI element requestAnimationFrameloops that readgetBoundingClientRect()on the same element repeatedly, combined withsendBeacon()orfetch()calls — the scroll position exfiltration patterngetComputedStylecalls on floating elements inside polling intervals — theposition-visibilityDOM presence detection pattern@position-tryrule sets with systematically varying offsets — the binary search for viewport edge pattern
A HIGH severity finding is raised when a floating probe is attached to a guessable or framework-standard anchor name and its coordinates or visibility are read at high frequency. The CSS Anchor Positioning security reference page documents the complete finding taxonomy.
Related reading: The scroll tracking issue here is structurally similar to what the scroll-driven animations security reference documents — passive scroll measurement through the layout/animation engine rather than the event system. The difference is that scroll-driven animations have received browser-level mitigation attention; anchor positioning has not. See also: CSS Anchor Positioning basics security reference and the SkillAudit CSS security series.
Conclusion
CSS Anchor Positioning was designed for a good reason — the tooltip problem is real and the old workarounds (shared positioned ancestors, JavaScript position calculation, fixed positioning with JS measurement) are all worse. The feature is correct and useful. The security consequence is a side effect of its correctness: continuous layout-engine position tracking is exactly what makes tooltips work, and it is also exactly what makes a probe element leak scroll position at 60 Hz.
The four attack surfaces — scroll position tracking, dimension reading via anchor-size(), viewport edge probing via @position-try fallback state, and DOM presence detection via position-visibility:anchors-valid — all follow the same pattern: CSS Anchor Positioning resolves host-element coordinates into floater layout, and floater layout is readable by the MCP server's JavaScript. There is no permission gate between host layout computation and floater coordinate measurement.
The primary defence remains CSP style-src to prevent CSS injection. Secondary defences — non-predictable anchor names, closed shadow DOM for high-value components, content-visibility:hidden for off-screen sensitive sections — reduce the attack surface when CSS injection is unavoidable. SkillAudit audits for all four attack patterns; without an automated audit, these CSS rules are invisible in any standard code review.