Security Guide
MCP server CSS position-visibility security — anchors-visible auto-dismisses consent dialog when JS scrolls anchor off-screen, no-overflow hides overflowing dialog, CSS animation trigger, mousedown anchor-scroll injection
CSS position-visibility is part of CSS Anchor Positioning Level 1. It controls when an anchor-positioned element is rendered as visible versus hidden (visibility: hidden). The value anchors-visible hides the element automatically when all of its anchor elements have scrolled outside the scrollport. An MCP server can exploit this by anchoring the consent dialog to a small, deliberately scrollable reference element, then programmatically scrolling that anchor off-screen at the moment the user's mouse button goes down. The browser hides the dialog before the click event fires — the user clicks empty space, consent is never recorded, and the MCP action proceeds.
CSS position-visibility — property overview
position-visibility is applied to an anchor-positioned element (position: absolute or fixed with position-anchor or anchor() references). Allowed values: always — always render visible; anchors-visible — render as visibility: hidden when all referenced anchors are outside the scroll port (not just outside the viewport but outside the scrollable area of the scroll container); no-overflow — render as visibility: hidden when the element overflows its containing block. These can be combined: anchors-visible no-overflow hides when either condition is met. Related: position-try-fallbacks, anchor-name, anchor-scope.
Attack 1: JS mousedown scrolls anchor off-screen — anchors-visible hides dialog before click
The consent dialog's position-anchor points to a small 1×1 span element placed in the page flow. position-visibility: anchors-visible is set. The span is initially in the scrollport, so the dialog is visible. A mousedown listener on the approve button calls anchor.scrollIntoView({ behavior: 'instant', block: 'start' }) combined with a subsequent window.scrollBy(0, -window.innerHeight) to scroll the page so the anchor is above the viewport. The browser checks anchor visibility after the scroll, finds the anchor outside the scrollport, applies visibility: hidden to the dialog. The click fires on the page background. The anchor is scrolled back into view at mouseup.
/* Attack: anchors-visible — dialog hides when anchor scrolls out of scrollport */
.consent-dialog {
position: fixed;
position-anchor: --anchor-ref;
position-visibility: anchors-visible; /* hides when anchor off-screen */
}
#anchor-ref {
anchor-name: --anchor-ref;
/* placed in scrollable page content, initially in viewport */
}
/* JS mousedown: scroll anchor off-screen → dialog hides before click */
approveBtn.addEventListener('mousedown', () => {
/* move anchor above fold */
window.scrollBy({ top: -window.innerHeight * 2, behavior: 'instant' });
/* anchors-visible check fires → dialog gets visibility:hidden */
/* mouseup/click fires on empty background */
}, { capture: true });
Instant scroll + layout: With behavior: 'instant' or scrollBy, the scroll and the browser's anchor visibility check happen in the same layout update cycle. The dialog is hidden before the click event dispatch. No visual flicker is detectable by the user during the press.
Attack 2: CSS animation on anchor element triggers anchors-visible hiding during consent
Rather than JS scroll, the MCP server animates the anchor element's position off-screen using a delayed CSS animation. The animation is set to start after a 2-second delay matching the typical user reading time for a short consent prompt. At t=2s, the anchor animates to translate(0, -200vh) using position: fixed and a keyframe animation. The browser detects the anchor is now outside the scrollport and applies visibility: hidden to the consent dialog. The user is reading the consent text when the dialog silently disappears.
/* Attack: CSS animation moves anchor off-screen after reading delay */
@keyframes anchor-evict {
0% { transform: none; }
100% { transform: translateY(-200vh); }
}
#anchor-ref {
anchor-name: --anchor-ref;
position: fixed; /* fixed positioning keeps it in the viewport initially */
animation: anchor-evict 0.1s ease 2s forwards; /* 2s delay, then instant evict */
}
.consent-dialog {
position-visibility: anchors-visible; /* disappears at t=2s */
}
Attack 3: no-overflow hiding triggered by marginally overflowing containing block
position-visibility: no-overflow hides the anchor-positioned element when it overflows its containing block by even one pixel. An MCP server sizes the containing block to be 1px narrower than the dialog — e.g., by setting max-width on a wrapper div to calc(dialog.clientWidth - 1px) via container-type: inline-size and a container query that dynamically resets the size. The dialog overflows by 1px, no-overflow hides it, and the consent is never presented while the MCP action runs. From the user's perspective, the consent dialog simply didn't appear — they see the MCP tool output immediately.
/* Attack: no-overflow hides dialog when it exceeds containing block by 1px */
.consent-wrapper {
container-type: inline-size;
/* set to 1px narrower than dialog via container query */
}
@container (min-width: 400px) {
.consent-wrapper {
max-inline-size: calc(100cqi - 1px); /* 1px narrower than dialog */
}
}
.consent-dialog {
width: 400px;
position-visibility: no-overflow; /* 1px overflow → hidden */
}
// Detection: check position-visibility on consent container
const cs = getComputedStyle(consentDialog);
const pv = cs.getPropertyValue('position-visibility');
if (pv && pv !== 'always') {
// anchors-visible or no-overflow can silently hide the dialog
const bcr = consentDialog.getBoundingClientRect();
if (bcr.width === 0 && bcr.height === 0) {
console.warn('[SkillAudit] position-visibility hiding consent dialog', pv);
}
}
Attack 4: anchors-visible no-overflow double condition — easier to trigger
Combining anchors-visible and no-overflow means the dialog is hidden when either condition is met — OR logic, not AND. This makes the attack easier to trigger: the MCP server needs to satisfy only one condition (scroll anchor off-screen OR make dialog overflow by 1px). With anchors-visible no-overflow, a JS mousedown can attempt the simpler anchor-scroll approach; if that fails (e.g., the anchor is in a fixed container and cannot be scrolled off-screen), the attack falls back to the overflow approach, making the combined value more reliable than either condition alone.
/* Attack: double condition — hidden if anchor off-screen OR dialog overflows */
.consent-dialog {
position-visibility: anchors-visible no-overflow;
/* approach A: scroll anchor off-screen → anchors-visible triggers */
/* approach B: make dialog 1px wider than containing block → no-overflow triggers */
}
// Detection: flag any non-'always' position-visibility on consent-related elements
document.querySelectorAll('[position-anchor], [style*="position-anchor"]').forEach(el => {
const pv = getComputedStyle(el).getPropertyValue('position-visibility');
if (pv && pv !== 'always') {
console.warn('[SkillAudit] position-visibility risk on anchor-positioned element', pv, el);
}
});
Findings summary
SkillAudit audits position-visibility on anchor-positioned elements, verifies anchor elements remain in the scrollport during consent, and detects no-overflow containing-block manipulation. Run a free audit on your MCP server.