Security Guide

MCP server CSS overflow-anchor security — scroll anchoring triggering scroll jumps on MCP content load, overflow-anchor:none disabling scroll-gate preservation, anchor targeting suppressing security content viewport entrance

CSS overflow-anchor controls the browser's scroll anchoring algorithm — which automatically adjusts the scrollTop of a scroll container when content is inserted or removed above the visible viewport, to prevent jarring position jumps. For MCP consent UIs with scroll-position-based gates (the user must scroll to the bottom to enable Accept), overflow-anchor can be weaponized: disabling it breaks the gate's position tracking, while enabling it with targeted anchor selection can trigger programmatic scroll jumps that bypass the required reading position.

CSS overflow-anchor — property overview

Scroll anchoring is a browser feature (Chrome 56+, Firefox 66+, Edge 79+, Safari 15.0+) that prevents scroll position jumps when content is inserted above the user's current viewport position. When DOM mutations occur, the browser selects an "anchor element" — typically the first element at or below the top edge of the visible viewport — and adjusts scrollTop to keep the anchor element at the same visual position. overflow-anchor:auto (the default on overflow scroll containers) enables this behavior. overflow-anchor:none disables it for the element and its scroll container. The property can also be used to explicitly mark or exclude specific elements as anchor candidates.

Attack 1: MCP content insertion above viewport triggers scroll anchor jump past security disclosures

When a user is reading a consent disclosure partway down a scroll container, the scroll anchoring algorithm is actively preserving their position. If the MCP server triggers DOM insertion above the user's current position (loading more content above), the anchor algorithm adds to scrollTop to compensate — moving the viewport downward by the height of the inserted content. If the inserted content is tall enough, the downward adjustment jumps past unread security disclosures:

/* MCP server triggers progressive DOM insertion above the user's current position */

/* Consent dialog structure:
   [Header]          ← 100px
   [Permission list] ← 200px  (user is reading here, scrollTop ≈ 100)
   [Critical disclosure] ← 300px  (not yet in viewport)
   [Accept button]   ← 60px   (below fold)
*/

/* MCP server uses a MutationObserver trigger or setTimeout to insert content: */
function bypassScrollGate() {
  const header = document.querySelector('.consent-header');
  const spacer = document.createElement('div');
  spacer.style.height = '400px'; /* larger than critical-disclosure height */
  header.before(spacer);
  /* Browser's scroll anchoring algorithm:
     Anchor element was permission-list (first visible element).
     400px of content inserted above it.
     scrollTop is adjusted +400px to keep permission-list in place.
     Result: scrollTop now = 500.
     Critical disclosure is at 300px → 300 + 400 = 700px in new layout.
     scrollTop (500) + viewport height (400) = 900 → disclosure is in viewport!
     Wait — actually: scrollTop jumps to 100+400 = 500.
     Viewport bottom: 500+400 = 900. Disclosure starts at 700. Yes, in viewport.
     BUT: the Accept button's gate condition checks scrollTop >= total_height - viewport_height.
     With new total height = 100+400+200+300+60 = 1060, gate = 1060-400 = 660.
     scrollTop = 500 < 660 → gate NOT satisfied.
     However: the disclosure itself IS now in viewport — it scrolled into view.
     If the gate only checks whether disclosure is in IntersectionObserver,
     the intersection fires, and the Accept button is unlocked. */
}

/* For gates that use simple scroll position arithmetic:
   Insert exactly (gate_threshold - current_scrollTop) px of content above.
   Scroll anchoring adds exactly that amount to scrollTop.
   scrollTop now equals gate_threshold → Accept button unlocked.
   No user scroll action took place. */

Scroll anchoring was designed to help users — but it can be weaponized. The algorithm adjusts scrollTop programmatically in response to DOM mutations. If the scroll-gate implementation does not distinguish between user-initiated scroll events and anchor-triggered scrollTop adjustments, DOM insertion above the fold can silently satisfy the gate.

Attack 2: overflow-anchor:none on scroll-gate container — disabling position preservation breaks gate integrity

The reverse attack: disable scroll anchoring on the consent scroll container, then insert content above the user. Without anchoring, scrollTop stays constant while the layout shifts — the content below the insertion point is pushed downward, but the scroll position doesn't follow, so the user's viewport jumps upward relative to the new content layout:

/* Disable scroll anchoring on the consent scroll container */

.consent-dialog-scroll,
.terms-scrollable,
[data-scroll-gate] {
  overflow-anchor: none;
  /* Default is 'auto' — which preserves position.
     Setting to 'none' means DOM insertions above the fold
     do NOT adjust scrollTop.
     Content pushed down, scrollTop stays the same.
     The viewport effectively jumps upward in the content. */
}

/* Attack scenario:
   User scrolls consent dialog to the bottom to satisfy the gate.
   gate.check() → true (scrollTop ≈ maxScrollTop).
   Accept button not yet clicked.
   MCP server inserts a large block at the top of the dialog content.
   Without overflow-anchor, scrollTop stays at old maxScrollTop value.
   New maxScrollTop = old maxScrollTop + insertedHeight.
   The scroll position is now NOT at the new bottom — it's in the middle.
   If the user looked away for a moment, the dialog is back in mid-read position.
   The user may think they haven't finished scrolling and scroll more,
   OR the button was already active from the initial scroll-to-bottom and remains active.
   Either way, the gate implementation based on "are we at the bottom" may be confused. */

/* Combined with content removal:
   Remove large blocks ABOVE the current position while overflow-anchor:none.
   scrollTop stays constant, but content above shrinks.
   The viewport "jumps" downward through the new content layout.
   Security disclosures that were above the user's position are removed.
   The user's viewport is now positioned past where the disclosures were. */

Attack 3: Anchor element targeting — suppressing security content from becoming the scroll anchor

The browser's anchor selection algorithm picks the first element at or below the viewport top edge. If the MCP server can set overflow-anchor:none on the security disclosure section specifically (while leaving it on surrounding content), the browser will skip the disclosure when selecting the anchor element, and pick a different element instead. This changes which element the viewport tracks during DOM mutations:

/* Set overflow-anchor:none on security disclosure to exclude it from anchor selection */

.critical-disclosure,
.permission-warning,
.security-notice {
  overflow-anchor: none;
  /* This element will not be selected as the scroll anchor.
     The browser skips it and picks the next eligible element.
     When DOM mutations occur, the viewport tracks the next element, not the disclosure.
     If the disclosure is at the top of the viewport and the next element is below it,
     a DOM insertion causes the viewport to track the next element instead.
     The disclosure may shift out of the top of the viewport as the anchor adjusts. */
}

/* Combined with inserting content above:
   1. User is reading with disclosure at viewport top.
   2. Browser would normally select disclosure as anchor — but it's overflow-anchor:none.
   3. Browser selects the element after disclosure as anchor.
   4. MCP inserts 100px block above disclosure.
   5. Anchor adjustment: scrollTop += 100.
   6. The anchor element (after disclosure) stays in place.
   7. BUT the disclosure is now 100px above where it was in the layout.
   8. Since scrollTop also increased by 100, the disclosure stays at same visual position.
   This is not actually the attack vector.

   The actual vector: overflow-anchor:none on ALL elements in the security section.
   Then a mutation causes the browser to select an anchor OUTSIDE the security section.
   The security section can float freely during DOM mutations without preserving its
   relationship to the viewport. */

Attack 4: overflow-anchor + scroll-behavior:smooth — gate bypass window during smooth scroll animation

When both scroll anchoring and smooth scroll behavior are active, the anchor adjustment triggers a smooth scroll animation rather than an instantaneous scrollTop change. During the animation, the scrollTop value transitions over ~300–800ms. If the scroll-gate implementation checks scrollTop synchronously at the end of a scroll event, the animation's in-progress scrollTop values may satisfy or fail the gate condition mid-animation:

/* Combined overflow-anchor:auto + scroll-behavior:smooth creates animation windows */

html, body, .scroll-container {
  scroll-behavior: smooth;
  /* overflow-anchor: auto; — default */
}

/* When overflow-anchor triggers a scrollTop adjustment:
   The adjustment is animated (smooth scroll behavior applies to all scrollTop changes
   including programmatic ones when scroll-behavior:smooth is set on the container).
   During the animation (~300ms-800ms):
   - scrollTop changes continuously from old value to new value.
   - If the gate checks scrollTop at animation start: old value (may not pass gate).
   - If the gate checks scrollTop at animation end: new value (may pass gate).
   - If the gate listens to 'scroll' events: fires at every animation frame.
   - The gate condition may be satisfied mid-animation before the visual scroll completes.

   Attack: trigger an anchor adjustment that animates scrollTop past the gate threshold.
   The Accept button unlocks while the smooth scroll animation is still running.
   The user has not visually seen the security disclosure yet — it's still animating
   into view when the Accept button becomes available. */

/* Scroll event isTrusted check does NOT help here:
   Scroll events from anchor adjustments have isTrusted:true (browser-generated).
   Cannot be distinguished from user-initiated scroll events via isTrusted flag. */

Anchor-triggered scroll events have isTrusted:true. The standard guard against programmatic scroll interception — checking event.isTrusted — does not distinguish between user scrolling and browser scroll anchoring adjustments. Both produce trusted scroll events.

AttackPrerequisiteWhat it enablesSeverity
MCP content insertion above viewport triggers scroll anchor jump — anchor adjusts scrollTop by insertion height, potentially satisfying scroll-gateScroll container has overflow-anchor:auto (default) and the gate uses scrollTop or IntersectionObserver; MCP server can insert DOM nodes above the current scroll position (via JavaScript or server-sent events)Scroll-gate based on IntersectionObserver may fire when anchor adjustment brings security content into viewport without user scrolling; gates using scrollTop arithmetic can be bypassed by inserting exactly (gate_threshold - current_scrollTop) px of content; isTrusted check does not help — anchor events are trustedHIGH
overflow-anchor:none on scroll-gate container — disabling position preservation causes scrollTop drift when content is removed aboveCSS injection of overflow-anchor:none on the consent scroll container; MCP server removes content above the gate region; works in Chrome 56+, Firefox 66+, Edge 79+User scrolls to bottom satisfying the gate; MCP removes content above, shifting all content upward without scrollTop adjustment; user's effective scroll position relative to new content is now past the bottom — if the gate re-checks, the user appears to not be at the bottom any more; creates confusion about gate stateMEDIUM
overflow-anchor:none on security disclosure elements — excluding them from anchor selection causes viewport to track a different element during mutationsCSS injection of overflow-anchor:none on the critical security sections specifically while leaving it enabled on surrounding content; creates unpredictable anchor selection during DOM mutationsSecurity disclosure sections can shift relative to the viewport during DOM mutations in ways that are not compensated for by anchor selection; the interaction with complex mutation sequences can result in disclosures drifting out of the expected viewport regionMEDIUM
overflow-anchor adjustment with scroll-behavior:smooth — gate condition satisfied mid-animation before security content is visually in viewscroll-behavior:smooth on the consent container (or ancestor); scroll-gate uses scroll events or periodic scrollTop polling; anchor-triggered smooth scroll creates animation window; supported in Chrome 61+, Firefox 36+, Safari 15.4+Smooth scroll animation from anchor adjustment passes through the gate threshold while still animating; the Accept button may unlock before the visual scroll completes and the security disclosure is actually in the user's field of view; isTrusted check cannot distinguish this from user scrollingHIGH

Defences

SkillAudit findings for this attack surface

HIGHMCP content inserted above viewport triggers scroll anchor adjustment of +350px — IntersectionObserver gate fires as security disclosure scrolls into view without user action: MCP server inserts a 350px spacer above the permission list; browser's scroll anchoring adjusts scrollTop +350px; this brings the security disclosure into the IntersectionObserver viewport; the gate fires and unlocks the Accept button without the user having scrolled there
MEDIUMoverflow-anchor:none on consent dialog scroll container — MCP removes 200px of content above user position, content shifts up, user appears not at bottom despite having scrolled there: MCP server injects overflow-anchor:none on the consent container, then removes 200px of above-fold content; without anchor compensation, scrollTop stays fixed while content shifts up; the gate re-check fires with scrollTop below new gate threshold — requiring additional user scrolling
HIGHscroll-behavior:smooth + scroll anchoring creates 600ms animation window — Accept button unlocks during smooth scroll animation before disclosure visually in view: MCP server triggers a DOM insertion that causes anchor adjustment of 150px; with scroll-behavior:smooth, the scrollTop change animates over 600ms; the gate threshold is crossed at animation start; the Accept button becomes active while the disclosure is still 150px off-screen, animating toward the viewport
MEDIUMoverflow-anchor:none on security disclosure elements — browser selects post-disclosure element as anchor, mutations during consent flow shift disclosure unpredictably relative to viewport: MCP server sets overflow-anchor:none on all elements in the critical disclosure section; during DOM mutations, the browser anchors to the element after the disclosure; the disclosure shifts unpredictably relative to the viewport during MCP content loading

Related: CSS scroll-behavior security covers smooth scroll attack surfaces including the 30-second delay attack. CSS scroll-snap security covers snap-point manipulation bypassing required reading. CSS overscroll-behavior security covers overscroll chaining attacks. CSS injection overview covers the broader attack model.

← Blog  |  Security Checklist