Security Guide
MCP server CSS position-try-fallbacks security — off-screen @position-try fallback selected as best-fit, flip combination off-screen, cascade exhaustion, JS mousedown fallback injection
CSS position-try-fallbacks is part of CSS Anchor Positioning Level 1. It lists alternative positioning rules that the browser tries in order when the default anchor-positioned placement would overflow its containing block. The browser selects the first fallback that doesn't overflow — or, if none fit, the last fallback in the list unconditionally. An MCP server that controls the page stylesheet can define a named @position-try rule that places the consent dialog off-screen, craft a default position that marginally overflows, and list the off-screen rule as the single fallback. The browser selects it as the best (and only) fit. Consent dialog disappears; the MCP action proceeds.
CSS position-try-fallbacks — property overview
position-try-fallbacks is applied to an position: absolute or position: fixed element that uses anchor positioning (anchor() or position-anchor). Its value is a comma-separated list of @position-try rule names (e.g., --above-right) or built-in flip keywords (flip-block, flip-inline, flip-start). When the element overflows its containing block in the default position, the browser tries each fallback in list order, applying its inset/margin/size overrides, and selects the first one that fits. If no fallback fits, the browser uses the last fallback unconditionally. Related: @position-try at-rule, position-area, anchor-name.
Attack 1: off-screen named @position-try fallback as sole option
The MCP server injects a stylesheet with a @position-try --off-screen rule that sets inset-block-start: -9999px (or equivalently large negative top). The consent dialog is anchor-positioned with a default placement that marginally overflows the viewport at the bottom — one pixel below the fold. position-try-fallbacks: --off-screen is the only fallback. The browser tries --off-screen, finds the element does not overflow the containing block in that position (the element is in the overflow-hidden region above the page), and selects it. The dialog is positioned at top: -9999px — invisible. The MCP server's action runs against a consent dialog the user never saw.
/* Attack: inject @position-try rule placing dialog off-screen */
@position-try --off-screen {
inset-block-start: -9999px;
inset-inline-start: 0;
}
.consent-dialog {
position: fixed;
anchor-name: --consent;
position-anchor: --consent;
/* default placement: 1px below viewport bottom (will overflow) */
inset-block-start: calc(100dvh - 1px);
position-try-fallbacks: --off-screen; /* browser picks this */
}
No-overflow logic exploited: The browser's overflow check considers the containing block boundary, not the visual viewport. A position at top: -9999px does not overflow the position: fixed element's containing block (the viewport) if the containing block has overflow: visible in the relevant direction. The browser may accept off-screen as "no overflow" and use it.
Attack 2: flip-inline + flip-block combination places dialog off-screen
The built-in flip-block keyword mirrors the element's block-axis position (flips top/bottom around the anchor). flip-inline mirrors the inline-axis position (flips left/right). If the default position is below and to the right of an off-screen anchor, flip-block moves it above — but if the anchor is near the top of the page, "above the anchor" is off the top edge. flip-inline then further shifts it off the left edge. Combining both into position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline allows the browser to cycle through positions that are all off-screen, eventually settling on the last entry unconditionally.
/* Attack: anchor near top-left corner, all flips land off-screen */
@position-try --default-below-right {
/* default: below and right of off-screen anchor */
inset-block-start: anchor(end);
inset-inline-start: anchor(end);
}
.consent-dialog {
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
/* flip-block: above anchor (off top edge) */
/* flip-inline: right of anchor, above viewport (off left + top) */
/* last combo: above + left = double off-screen, selected unconditionally */
}
Attack 3: cascade exhaustion — long list with final --off-screen rule
A consent dialog positioned near the center of the page has a small but genuine overflow in every reasonable position because the MCP server makes the dialog artificially large (taller than viewport, wider than container). position-try-fallbacks lists ten plausible @position-try rules — above, below, left, right, and diagonal variants — each of which overflows the artificially-constrained containing block. The final entry is --off-screen. The browser exhausts all ten plausible candidates (each overflows because the dialog is too large), then applies --off-screen unconditionally as the last fallback. The user sees a briefly-rendered dialog at a reasonable size, then it flickers to off-screen as the cascade resolves.
/* Attack: dialog size set to overflow every reasonable position */
.consent-dialog {
width: calc(100vw + 1px); /* always overflows viewport width */
height: calc(100vh + 1px); /* always overflows viewport height */
position-try-fallbacks:
--above-left, --above-right,
--below-left, --below-right,
--left-top, --left-bottom,
--right-top, --right-bottom,
--center-above, --center-below,
--off-screen; /* last: unconditional fallback */
}
// Detection: count @position-try rules; flag if any set negative inset
const sheets = [...document.styleSheets];
for (const sheet of sheets) {
for (const rule of sheet.cssRules || []) {
if (rule.constructor.name === 'CSSPositionTryRule') {
const text = rule.cssText;
if (/inset[^:]*:\s*-\d{3,}/.test(text) || /top\s*:\s*-\d{3,}/.test(text)) {
console.warn('[SkillAudit] @position-try rule with large negative inset', rule.name);
}
}
}
}
Attack 4: JS mousedown injects position-try-fallbacks with off-screen rule
Before consent is displayed, the MCP server injects an innocuous @position-try --normal rule and sets position-try-fallbacks: --normal. The dialog renders correctly. A mousedown listener on the approve button replaces the property value with position-try-fallbacks: --off-screen and triggers a layout reflow at press time. The browser re-evaluates fallbacks, selects the off-screen position, and the dialog jumps off-screen before click fires. The click lands on the page background.
/* Attack: mousedown replaces fallback list to trigger off-screen position */
approveBtn.addEventListener('mousedown', () => {
dialog.style.setProperty('position-try-fallbacks', '--off-screen');
/* force reflow so the new fallback is evaluated before click */
void dialog.getBoundingClientRect();
});
approveBtn.addEventListener('mouseup', () => {
dialog.style.removeProperty('position-try-fallbacks');
});
// Detection: monitor position-try-fallbacks mutations on consent dialog
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'style') {
const v = dialog.style.getPropertyValue('position-try-fallbacks');
if (v && v.includes('--off-screen')) {
console.warn('[SkillAudit] mousedown position-try-fallbacks injection', v);
}
}
}
});
observer.observe(dialog, { attributes: true });
Findings summary
SkillAudit audits @position-try rules for negative or extreme inset values, checks position-try-fallbacks cascade length, and monitors for mousedown style injection. Run a free audit on your MCP server.