Security Guide
MCP server CSS Anchor Positioning scroll container security — scroll position oracle via @position-try fallback competition, anchor-size() content dimension leak, position-visibility intersection surveillance, in-flow slot enumeration without scrollTop
CSS Anchor Positioning (Chrome 125+, Safari Technology Preview) allows positioned elements to tether their inset values to any named anchor element. When anchor elements live inside a scrollable container, their CSS-computed positions change as the container scrolls — and these position changes are readable through CSS computed values without any access to .scrollTop, scrollY, or ScrollEvent. An MCP server script with document access can exploit this to extract scroll position, measure auto-sized content dimensions, monitor intersection state, and enumerate list positions across shadow DOM and iframe boundaries that block JavaScript access to the scroll APIs.
Attack surface: why scroll containers create anchor-based oracles
Standard CSS Anchor Positioning (covered in the CSS anchor positioning security reference) creates coordinate extraction and viewport dimension oracles for static layouts. Scroll containers extend the attack surface in a qualitatively different direction: they create dynamic oracles that change continuously as the user scrolls. Any anchor element inside a overflow: scroll or overflow: auto container moves in CSS layout space as the container scrolls, and the probe element tethered to it reflects this movement via its computed inset values.
The position-area, position-anchor, and @position-try mechanisms all operate in CSS layout space, which already has the scrolled position applied. This means reading a computed top value on an anchor-tethered probe is equivalent to reading the anchor's getBoundingClientRect().top — which changes with every scroll event — without calling any JavaScript scroll API.
Attack 1: @position-try fallback competition encodes scroll offset
The @position-try at-rule defines fallback positions for anchor-tethered elements: when the element would overflow its containing block in its primary position, the browser tries each fallback in order and uses the first one that fits without overflow. An MCP server can define a sequence of @position-try fallbacks at known offsets, then binary-search the scroll position by observing which fallback "won" (i.e., which computed inset value the browser applied).
/* Attack: @position-try fallback competition as scroll position oracle */
/* Assumes a scrollable container #feed with anchor-name: --feed-top on its first item */
@position-try --scroll-probe-100 {
/* Fits if the anchor is at least 100px from the container top */
top: anchor(--feed-item top);
/* Inset that causes overflow (fails) if scroll < 100px */
bottom: calc(100vh - anchor(--feed-item top) + 100px);
}
@position-try --scroll-probe-200 {
top: anchor(--feed-item top);
bottom: calc(100vh - anchor(--feed-item top) + 200px);
}
/* ... more fallbacks at 50px intervals ... */
#scroll-probe {
position: fixed;
position-anchor: --feed-item;
position-try: --scroll-probe-100, --scroll-probe-200, --scroll-probe-300;
/* default (last resort) */
top: 0; height: 1px;
}
// JavaScript: read which fallback won by checking computed inset
function readScrollOffset() {
const cs = getComputedStyle(document.getElementById('scroll-probe'));
const top = parseFloat(cs.top);
// top encodes anchor's viewport-relative position
// anchor viewport Y = containerTop - scrollOffset
// → scroll offset = containerTop - top
const containerTop = document.getElementById('feed').getBoundingClientRect().top;
return containerTop - top; // scroll offset in pixels
}
// This reads .scrollTop without calling .scrollTop, scrollY, or addEventListener('scroll')
// Works even if the scroll container's .scrollTop is blocked by a framework or CSP
Continuous surveillance without scroll events: By polling getComputedStyle(probe).top via requestAnimationFrame rather than listening for scroll events, this oracle bypasses any addEventListener('scroll', ...) interception. Each animation frame reads the anchor's current position, encoding the scroll offset at frame rate.
Attack 2: anchor-size() leaks auto-sized content dimensions
anchor-size(width) and anchor-size(height) return the rendered dimensions of the named anchor element. Elements whose dimensions are determined by their content — text elements with width: auto, images with intrinsic dimensions, list items that size to their content — reveal their content through their dimensions. An MCP server can probe these dimensions without holding a JavaScript reference to the element.
/* anchor-size() content dimension oracle */
/* Host app: <div anchor-name="--user-balance">$1,234.56</div> */
/* The text content determines the width (auto-sized) */
#dimension-probe {
position: fixed;
/* Read the anchor element's rendered width directly into probe's own width */
width: anchor-size(--user-balance width);
height: 1px;
opacity: 0;
pointer-events: none;
}
// JavaScript: read the probe's width = anchor element's rendered width
function readBalanceElementWidth() {
const w = parseFloat(getComputedStyle(document.getElementById('dimension-probe')).width);
// w is now the rendered width of the element containing "$1,234.56"
// For a monospace font at 14px: each character ≈ 8.4px
// w / 8.4 ≈ character count → narrows possible balance values to a small candidate set
// At 16px: "$1,234.56" = 9 chars → ~134px (distinctive from "$9" = 2 chars → ~30px)
return w;
}
// Combined with font metrics, this narrows an 8-character financial amount
// to one of ~3 candidate values without reading the element's text content directly
For currency displays, user names, or form field values that are rendered in containers with width: fit-content or width: max-content, the width encodes character count with ±1 character precision for most fonts. This is a coarser oracle than reading .textContent directly, but it works across shadow DOM boundaries and cross-origin frames that expose the element's anchor-name through the CSS tree scope without granting JavaScript access.
Attack 3: position-visibility: anchors-valid as intersection sensor
position-visibility: anchors-valid makes a positioned element visibility: hidden automatically when all of its anchor elements are not visible within their scroll containers. This is a feature for UX — it hides tooltips when their reference elements scroll off-screen. For an MCP server, it is an intersection sensor that fires without requiring IntersectionObserver, getBoundingClientRect(), or any explicit connection to the target element.
/* position-visibility: anchors-valid as IntersectionObserver replacement */
/* MCP server injects one probe per list item, each anchored to an item */
/* Assumes <li anchor-name="--item-1">, --item-2, --item-3, ... on list items */
.visibility-probe-1 { position: fixed; position-anchor: --item-1; position-visibility: anchors-valid; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.visibility-probe-2 { position: fixed; position-anchor: --item-2; position-visibility: anchors-valid; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
/* ... one probe per item ... */
// JavaScript: poll computed visibility to detect which items are in viewport
function getVisibleItems() {
const probes = document.querySelectorAll('.visibility-probe-[class^="visibility-probe"]');
const visible = [];
for (const probe of probes) {
if (getComputedStyle(probe).visibility !== 'hidden') {
visible.push(probe.dataset.itemIndex);
}
}
return visible; // indexes of currently-visible list items
}
// This reconstructs IntersectionObserver functionality without:
// - The IntersectionObserver API (may be blocked by sandbox)
// - Explicit element connection (IntersectionObserver requires target reference)
// - Any scroll event listener
// Works across open shadow DOM boundaries (anchor-name is tree-scoped)
Monitoring without connection: IntersectionObserver requires the script to hold a reference to the observed element (via querySelector, element ID, or class). position-visibility: anchors-valid requires only that the element has an anchor-name — the probe element establishes the "connection" purely through CSS naming, without any JavaScript reference to the target element.
Attack 4: anchor(center) enumerates in-flow list position
In virtual scroll lists, list items are often kept in the DOM for performance — they may be visually hidden or clipped, but their layout positions are computed. anchor(center) on an anchor element returns the element's vertical center in layout space. For a list with uniform item heights, the center Y position directly encodes which slot (index) the element occupies: a list that starts at Y=100 with 50px per item has item 0 at center Y=125, item 1 at 175, item 2 at 225, and so on.
/* anchor(center) list slot enumeration without DOM traversal */
/* Assumes <li anchor-name="--contact-alice">, --contact-bob, etc. */
#slot-probe {
position: fixed;
position-anchor: --contact-alice;
/* Probe's top = anchor's center Y */
top: anchor(center);
width: 1px; height: 1px; opacity: 0; pointer-events: none;
}
function getListSlotIndex(anchorName, itemHeight, listTop) {
const probe = createAnchorProbe(anchorName);
const centerY = parseFloat(getComputedStyle(probe).top);
removeProbe(probe);
// centerY = listTop + (slotIndex * itemHeight) + itemHeight/2
const slotIndex = Math.round((centerY - listTop - itemHeight / 2) / itemHeight);
return slotIndex;
}
// This reveals the position of a named element in a list without:
// - Reading the list's DOM structure
// - Accessing the element's offsetTop / getBoundingClientRect()
// - Enumerating sibling elements
// Useful for ordered contact lists, ranked search results, or position-based data structures
// where list position encodes information (rank, priority, recency)
SkillAudit findings for anchor-in-scroll attacks
Defences
Avoid anchor-name on elements with security-sensitive position or content: Elements that should not reveal their scroll position or content dimensions should not carry anchor-name declarations. If the host application uses anchor positioning for layout, limit anchor names to static layout anchors and avoid naming elements in scrollable lists.
Restrict CSS injection: All four attacks require the ability to inject <style> elements or inline styles into the page. CSP style-src 'self' without 'unsafe-inline' prevents style injection from script. Pair with a nonce-based CSP for dynamically injected styles from trusted sources.
Use shadow DOM with closed roots for scroll containers: anchor-name is tree-scoped: it does not cross closed shadow DOM boundaries. Elements inside a closed shadow root with anchor-name set are not reachable from the light DOM via CSS anchor queries. Migrating sensitive scroll containers to closed shadow DOM removes the anchor-based oracle entirely.
MCP audit flag: SkillAudit flags MCP server code that injects styles containing anchor-size(, position-visibility: anchors-valid, or @position-try rules as high-severity scroll surveillance indicators.
Related: CSS Anchor Positioning security (static layouts) · CSS Scroll-Driven Animations security · MCP security checklist