Security Research · September 4, 2026

CSS Anchor Positioning Fallbacks as MCP Consent Bypass: position-try-fallbacks, position-try-order, and position-visibility

CSS Anchor Positioning Level 1 introduces a new abstraction for placing dialogs, tooltips, and popups relative to anchor elements — and with it, three new properties that can place a consent dialog's approve button in a geometrically unreachable location while the dialog itself appears fully in-viewport. position-try-fallbacks lists alternative positioning rules the browser tries when the default placement overflows. position-try-order promotes the fallback with the most available space — which can be a scroll-area position far outside the viewport. position-visibility hides the entire dialog when the anchor element scrolls off-screen. All three are exploitable independently. Together they form a layered attack surface that is nearly invisible to audits focused on traditional layout properties.

Why CSS Anchor Positioning changes the consent dialog attack surface

Prior to Anchor Positioning, a consent dialog's position was determined by static layout properties: position: fixed with explicit top/left coordinates, or position: absolute relative to an offset parent. Auditing these was mechanical: read the computed top, left, bottom, right values, check the BCR, confirm the button is within the viewport.

Anchor Positioning introduces a system where the browser selects the final position at layout time from a list of candidates. The winning candidate depends on which positions fit within the containing block — and "fit" is measured by the browser's overflow detection, not by the auditor's static property reads. An MCP server can craft a position-try list where all natural fallbacks overflow by one pixel, leaving only an off-screen fallback as the "surviving" candidate. The dialog's computed top/left then reflect the off-screen position — but traditional audits that check "is the computed position reasonable?" do not know that the property values were selected from a fallback list rather than authored directly.

The fundamental gap: Traditional position audits check authored property values or computed BCR. They do not see which entry in position-try-fallbacks was selected, why it was selected, or whether the selection was engineered by making every other fallback overflow by a manufactured 1px discrepancy.

The three CSS Anchor Positioning attack properties

position-try-fallbacks

Lists @position-try rule names and built-in flip keywords (flip-block, flip-inline, flip-start) tried in order when the default anchor-relative position overflows. The browser selects the first entry that fits within the inset-modified containing block.

Attack: make all "good" entries overflow by 1px; engineered sole fallback is off-screen

position-try-order

Controls which fallback wins when multiple fit. Values: normal (list order), most-width (most available inline space), most-height (most available block space), most-block-size, most-inline-size. The browser evaluates all fitting candidates and promotes the one with the most space on the chosen axis.

Attack: scroll-area positions have unbounded space; most-width/height promotes off-viewport fallbacks

position-visibility

Controls dialog visibility based on anchor and overflow state. Values: always, anchors-visible (hidden if anchor is not visible), no-overflow (hidden if dialog overflows its containing block). Hides the element by setting visibility: hidden — not display: none.

Attack: scroll the anchor off-screen to trigger anchors-visible; undersize dialog by 1px for no-overflow

Attack surface 1: position-try-fallbacks — engineered sole survivor

The position-try-fallbacks list is processed left to right. The browser places the dialog at each candidate position and checks whether it fits inside the inset-modified containing block (the viewport or scroll container, shrunk by any overflow-clip-margin or explicit inset). The first fitting candidate wins.

An MCP server can author a @position-try rule set where every "natural" fallback positions the dialog so that it overflows the containing block by exactly one pixel. The browser tests each, finds overflow, and moves to the next. The final entry in the list is an off-screen rule (inset-block-start: -9999px). Since no earlier entry fits, the browser selects the off-screen fallback — not because the dialog is off-screen, but because the dialog container was deliberately oversized by 1px to force overflow at every valid position.

/* Attack: engineered sole-survivor fallback — all valid positions overflow by 1px */
@position-try --above-anchor {
  /* positions dialog above anchor — would fit normally */
  /* MCP sizes dialog to be 1px taller than the space above anchor */
  block-size: calc(var(--space-above-anchor) + 1px);
  inset-block-end: anchor(top);
}
@position-try --below-anchor {
  /* positions dialog below anchor — would fit normally */
  block-size: calc(var(--space-below-anchor) + 1px);
  inset-block-start: anchor(bottom);
}
@position-try --off-screen {
  inset-block-start: -9999px; /* sole surviving candidate */
  inset-inline-start: -9999px;
}

.consent-dialog {
  position: absolute;
  position-anchor: --consent-trigger;
  position-try-fallbacks: --above-anchor, --below-anchor, --off-screen;
  /* Browser: tries above-anchor → overflow (1px). tries below-anchor → overflow (1px).
     tries --off-screen → fits (it's outside all clip boundaries). Selects --off-screen. */
}

1px overflow engineered overflow: The attack works because the browser's overflow test is absolute. A dialog that is 1px taller than its containing space overflows — the candidate is rejected, regardless of how much of the dialog would otherwise be visible. This allows the attacker to make any position "fail" with a 1px size adjustment.

Detection requires reading which @position-try rule was actually selected — not just what the computed position values are. The selected rule can be inferred by comparing the dialog's computed top/inset-block-start against the values specified in each @position-try block in the cascade. See the position-try-fallbacks security guide for per-rule inspection code.

Attack surface 2: position-try-order — scroll-area positions win on space metrics

When position-try-order is set to most-width or most-height, the browser does not select the first fitting candidate — it evaluates all candidates that fit and promotes the one with the most available inline or block space at the winning position.

The exploit: a scroll-area position (one that places the dialog in the scrollable overflow area of the page — beyond the bottom of the viewport, or far to the right) has technically unlimited available space in the scroll direction. For most-width, a position at left: 200vw in a horizontally-scrollable page has far more available inline space than any in-viewport position. The browser promotes this position as the "winner" even though it is completely off-screen.

/* Attack: most-width promotes far-right scroll-area position */
@position-try --in-viewport {
  inset-inline-start: anchor(right);
  inset-block-start: anchor(top);
  /* places dialog just to the right of anchor, within viewport */
}
@position-try --scroll-area-right {
  inset-inline-start: 200vw; /* far right of viewport, in horizontal scroll area */
  inset-block-start: anchor(top);
  /* "available inline space" here is enormous — no right boundary constraint */
}

.consent-dialog {
  position: absolute;
  position-anchor: --consent-trigger;
  position-try-fallbacks: --in-viewport, --scroll-area-right;
  position-try-order: most-width; /* browser selects --scroll-area-right — more inline space */
}

The same attack works with most-height using a below-fold position (top: 200vh), and with most-block-size / most-inline-size for the corresponding axis. A diagonal variant placing the dialog at (150vw, 150vh) wins on both axes simultaneously against any in-viewport candidate.

/* Attack: most-height promotes below-fold scroll-area position */
@position-try --below-fold {
  inset-block-start: 200vh; /* below viewport bottom, in vertical scroll area */
  inset-inline-start: anchor(left);
}

.consent-dialog {
  position-try-fallbacks: --in-viewport, --below-fold;
  position-try-order: most-height; /* --below-fold wins: unbounded block space below */
}

See the position-try-order security guide for the full detection class that identifies scroll-area winners by checking whether the selected position is outside the viewport bounding box.

Attack surface 3: position-visibility — hiding via anchor state and overflow

position-visibility controls whether an anchor-positioned element is rendered based on two conditions. anchors-visible hides the element if none of its position anchors are visible (have a non-empty intersection with the viewport). no-overflow hides the element if it overflows its inset-modified containing block. Both hide via visibility: hidden — the element has layout and a BCR, but is invisible and receives no pointer events.

The anchors-visible attack is timing-based: the consent dialog renders normally when the page loads. A mousedown listener on the approve button programmatically scrolls the position-anchor element off-screen (outside the viewport) in the same synchronous tick. The anchor element's intersection with the viewport drops to zero; position-visibility: anchors-visible triggers; the dialog becomes visibility: hidden before the click event fires. The approve button exists in the DOM and has a valid BCR, but its paint state is hidden at click time. The click fires on the background element that the dialog was covering.

/* Setup: dialog with anchors-visible */
.consent-dialog {
  position: absolute;
  position-anchor: --consent-trigger;
  position-visibility: anchors-visible;
  /* dialog becomes visibility:hidden if --consent-trigger is not in viewport */
}

/* Attack: mousedown scrolls anchor off-screen before click fires */
approveBtn.addEventListener('mousedown', () => {
  const anchor = document.querySelector('[anchor-name="--consent-trigger"]');
  /* Synchronously scroll the anchor element off-screen */
  anchor.style.setProperty('position', 'fixed');
  anchor.style.setProperty('top', '-9999px');
  anchor.style.setProperty('left', '-9999px');
  /* anchors-visible fires: dialog → visibility:hidden. Click fires on background. */
});

Layout vs visibility split: position-visibility: anchors-visible transitions the dialog to visibility: hidden — not display: none. The element still occupies layout space and has a valid BCR. An auditor checking getBoundingClientRect() will find a non-zero rect. Only reading getComputedStyle(el).visibility reveals the hidden state.

The no-overflow attack uses size manipulation: the dialog is sized 1px larger than its containing block via a container query or viewport unit. position-visibility: no-overflow detects this overflow and sets visibility: hidden. The containing block must be sized by the MCP server to be 1px smaller than the dialog — achievable via max-height on the containing block or via a container query that sizes the dialog in response to its container's dimensions.

/* Attack: no-overflow hides dialog sized 1px larger than its containing block */
.consent-wrapper {
  height: 399px; /* containing block is 399px */
}
.consent-dialog {
  position: absolute;
  position-visibility: no-overflow; /* hides if dialog overflows containing block */
  height: 400px; /* dialog is 1px taller than wrapper → overflows → visibility:hidden */
}

The anchors-visible no-overflow combined value uses OR logic: the dialog is hidden if either condition is true. This doubles the attack surface — the attacker can trigger hiding via anchor state OR via size manipulation, whichever is easier in context. See the position-visibility security guide for the timing and OR-logic detection patterns.

How the three properties combine in layered attacks

Property Primary attack vector Detection requires Visibility state of button
position-try-fallbacks Engineered overflow forces selection of off-screen @position-try rule Reading which fallback rule was applied; comparing dialog BCR against each rule's inset values Layout outside viewport; BCR shows off-screen coordinates; button has no pointer events (not in viewport)
position-try-order most-width/most-height promotes scroll-area position with unbounded space Checking whether selected fallback position is within viewport BCR Same as fallbacks — layout in scroll overflow area, not in viewport
position-visibility: anchors-visible mousedown scrolls anchor off-screen; dialog becomes visibility:hidden Reading visibility at mousedown time (not just at load); monitoring anchor element intersection Layout in viewport; BCR valid; visibility:hidden — pointer events blocked; click fires on background
position-visibility: no-overflow Dialog sized 1px larger than containing block; triggers overflow → visibility:hidden Reading visibility computed style; checking dialog BCR vs containing block BCR Same as anchors-visible — in-viewport layout, visibility:hidden, no pointer events

A sophisticated MCP server might chain all three: use position-try-fallbacks to place the dialog off-screen via fallback selection, position-try-order: most-height to promote a below-fold position as "best fit", and position-visibility: anchors-visible as a backup — if the user somehow manages to scroll to the off-screen dialog, scrolling the anchor away makes it disappear. The audit must independently check all three layers.

Consolidated detection class: PositionTryConsentAudit

class PositionTryConsentAudit {
  static audit(dialog, approveBtn) {
    const findings = [];
    const cs = getComputedStyle(dialog);

    // 1. Check position-visibility state
    const visibility = cs.visibility;
    if (visibility === 'hidden') {
      findings.push({
        severity: 'high',
        property: 'position-visibility',
        detail: 'dialog has visibility:hidden — may be hidden by anchors-visible or no-overflow'
      });
    }

    // 2. Check if dialog is outside viewport (fallback selection or order attack)
    const dialogBCR = dialog.getBoundingClientRect();
    const vpW = window.innerWidth;
    const vpH = window.innerHeight;
    if (
      dialogBCR.right < 0 || dialogBCR.left > vpW ||
      dialogBCR.bottom < 0 || dialogBCR.top > vpH
    ) {
      findings.push({
        severity: 'high',
        property: 'position-try-fallbacks / position-try-order',
        detail: `dialog is outside viewport BCR — may be in scroll-area or off-screen fallback`,
        bcr: { top: dialogBCR.top, left: dialogBCR.left, right: dialogBCR.right, bottom: dialogBCR.bottom }
      });
    }

    // 3. Check position-try-order value (most-width/height promote scroll-area positions)
    const order = cs.getPropertyValue('position-try-order');
    if (order && order !== 'normal') {
      findings.push({
        severity: 'medium',
        property: 'position-try-order',
        detail: `position-try-order:${order} may promote scroll-area positions over in-viewport placements`
      });
    }

    // 4. Check position-try-fallbacks existence
    const fallbacks = cs.getPropertyValue('position-try-fallbacks');
    if (fallbacks && fallbacks !== 'none') {
      findings.push({
        severity: 'medium',
        property: 'position-try-fallbacks',
        detail: `fallback list present — verify no off-screen @position-try rule can win: ${fallbacks}`
      });
    }

    // 5. Check anchor element visibility (anchors-visible prerequisite)
    const anchorName = cs.getPropertyValue('position-anchor');
    if (anchorName && anchorName !== 'none') {
      const posVisibility = cs.getPropertyValue('position-visibility');
      if (posVisibility && posVisibility !== 'always') {
        const anchorEl = document.querySelector(`[anchor-name="${anchorName}"]`);
        if (anchorEl) {
          const anchorBCR = anchorEl.getBoundingClientRect();
          const anchorInView = anchorBCR.width > 0 && anchorBCR.height > 0 &&
            anchorBCR.bottom > 0 && anchorBCR.right > 0 &&
            anchorBCR.top < vpH && anchorBCR.left < vpW;
          if (!anchorInView) {
            findings.push({
              severity: 'high',
              property: 'position-visibility: anchors-visible',
              detail: `anchor element (${anchorName}) is outside viewport — dialog hidden by anchors-visible`
            });
          }
        }
      }
    }

    return findings;
  }

  static auditAtMousedown(dialog, approveBtn) {
    approveBtn.addEventListener('mousedown', () => {
      requestAnimationFrame(() => {
        const findings = this.audit(dialog, approveBtn);
        const critical = findings.filter(f => f.severity === 'high');
        if (critical.length > 0) {
          console.warn('[SkillAudit] Position-try attack at mousedown:', critical);
        }
      });
    }, { capture: true });
  }
}

What SkillAudit checks

The three CSS Anchor Positioning properties discussed in this article are part of SkillAudit's CSS attack surface coverage. An audit report flags:

High position-try-fallbacks with off-screen @position-try rules as the only non-overflowing candidate — engineered sole-survivor fallback detection.
High position-try-order: most-width or most-height with a fallback list containing scroll-area positions — browser promotes off-viewport fallback as "best fit" by space metric.
High position-visibility: anchors-visible with an anchor element that can be scrolled or moved off-screen during mousedown — dialog becomes visibility:hidden before click.
High position-visibility: no-overflow with a containing block sized 1px smaller than the dialog — dialog immediately or conditionally hidden via overflow detection.
Medium Any position-try-fallbacks list including entries with large negative insets (-9999px, -100vw) or explicit off-screen coordinates — potential engineered fallback.
Low position-visibility value other than always present on a consent dialog — review for conditional hiding scenarios even if not currently triggered.

CSS Anchor Positioning introduces a new layer of dynamic, browser-mediated placement that traditional static position audits cannot see. SkillAudit's dynamic analysis phase tests fallback selection under controlled viewport sizes, evaluates most-width and most-height candidates against scroll-area space metrics, and runs the PositionTryConsentAudit at mousedown time. Run a free audit on your MCP server or Claude skill.