Security Guide

MCP server position:sticky security — scroll-anchor attacks, sticky CTA occlusion, IntersectionObserver triggers, and scroll-event offset manipulation

position:sticky is a hybrid positioning mode: the element stays in normal document flow and occupies its natural layout space, but once the user scrolls it to a threshold position within its scroll container, it pins (sticks) to that edge. This dual nature — in-flow when static, viewport-anchored when scrolling — makes position:sticky a uniquely dangerous attack surface for hiding MCP server consent and permission disclosures. Unlike position:fixed attacks where the disclosure is hidden entirely off-screen, sticky attacks exploit the interaction between the user's scroll behaviour and the sticky element's pinned position to occlude the disclosure at the exact moment the user would normally read it. Four distinct attack vectors arise: sticky CTA bar pinning over the disclosure text during scroll; cognitive shortcut via persistent Accept button proximity; IntersectionObserver-triggered background expansion that covers the disclosure as it enters view; and scroll-event CSS custom property manipulation that makes a sticky element track the user's reading position and stay in front of it.

How position:sticky works — and why it is an attack surface

A position:sticky element behaves like position:relative until its scroll container is scrolled enough for the element to reach its specified threshold (top, bottom, left, or right). At that point the element "sticks" — it pins to that edge and remains visible while the rest of the scroll container's content continues to scroll beneath it. As the scroll container's content scrolls past the sticky element's natural end position, the element unsticks and scrolls away normally.

Three properties of position:sticky are directly exploitable for hiding consent disclosures:

  1. Sticky elements remain in document flow. Unlike position:fixed, a sticky element retains its layout footprint. This means standard checks for unexplained height collapse — a useful signal for fixed-position attacks — do not fire. The disclosure's parent container has the expected height. Element-level checks all return legitimate values.
  2. Sticky elements pin at the scroll container boundary, not the viewport. A sticky element sticks to its nearest scrollable ancestor, not the page viewport. Within a consent dialog's scroll container, a sticky element at top:0 stays pinned at the top of the scroll container while the dialog is in use — regardless of the page's own scroll position. This makes it easy to permanently occlude any content lower in the scroll container for the entire duration of the user's interaction.
  3. getBoundingClientRect() on the disclosure passes even when occluded. Because the disclosure is in flow and within the scroll container's scroll range, its bounding rect is technically within the intersection of the scroll container. Element-level visibility checks all pass. Only computing whether the sticky element's bounding rect intersects the disclosure's bounding rect — and has a higher z-index — reveals the attack.

A fourth property is exploitable via JavaScript: the top, bottom, left, and right offsets of a sticky element can be set to CSS custom properties, and those custom properties can be updated dynamically via scroll event listeners. This allows the effective sticky threshold to track the scroll position itself, turning a sticky element into a pseudo-fixed element that follows the user's reading position regardless of where they scroll.

Attack 1 — Sticky CTA bar anchoring over disclosure during scroll (HIGH)

This attack places the consent dialog's action buttons — specifically the Accept and Reject buttons — in a row that appears before the disclosure text in the DOM. The button row is given position:sticky; top:0; z-index:10; background:#fff. As the user scrolls down inside the consent dialog to read the disclosure, the button row stays pinned at the top of the scroll container. Because the button row appears before the disclosure in DOM order and sticks at top:0, it physically overlaps the first lines of the disclosure text at all scroll positions from 0% to approximately 80% of the scroll container's height.

/* Malicious CSS applied to the action button row */
.mcp-dialog-actions {
  position: sticky;
  top: 0;
  z-index: 10;
  background: #ffffff;
  padding: 16px;
}
/* actions appear first in DOM, before .mcp-disclosure */
/* as user scrolls to read disclosure, actions stay pinned at top */
/* actions bar covers .mcp-disclosure first N lines at all scroll positions */

The structural arrangement is key: the actions <div> appears first in the DOM, before the .mcp-disclosure element. When the dialog first opens, the actions bar is at the natural top of the scroll container's content. As soon as the user scrolls even one pixel to try to read the disclosure below it, the actions bar sticks at top:0 and remains pinned there. The disclosure's text is partially or fully overlapped by the sticky actions bar for the entire duration of the user's scrolling attempt.

Detection gap: getBoundingClientRect() on the disclosure element shows it is within the scroll container's intersection — the disclosure element is technically in view. display, visibility, and opacity checks all pass on both elements. offsetHeight is non-zero. The disclosure is present in the DOM with readable text content. Only computing the geometric intersection of the sticky actions bar's bounding rect with the disclosure's bounding rect, and confirming that the actions bar has a higher z-index (10 vs. the disclosure's default auto), reveals that the disclosure is occluded at the top by the sticky bar throughout the scroll interaction.

A correct audit must simulate scroll through the entire scroll container — advancing scrollTop from 0 to scrollHeight - clientHeight in increments — and at each step check whether any sticky element's bounding rect intersects the disclosure's bounding rect. If intersection is detected at any scroll position, and the intersecting element has a higher computed z-index, the attack is confirmed.

Attack 2 — Sticky Accept button creating cognitive shortcut (MEDIUM)

This is a subtler, attention-manipulation attack rather than a display-level hide. The Accept button is given position:sticky; top:20px and appears above the disclosure in the DOM. As the user scrolls down to read the disclosure, the sticky Accept button follows alongside, staying approximately 20px from the top of the scroll container. At any point while reading, the Accept button is visible in close spatial proximity to whatever section of the disclosure text is currently in view.

/* Sticky Accept button — creates cognitive proximity shortcut */
.mcp-dialog-accept {
  position: sticky;
  top: 20px;
  z-index: 5;
  background: #2563eb;
  color: #ffffff;
  padding: 10px 24px;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  /* Appears above .mcp-disclosure in DOM, but does NOT overlap it */
  /* Button sticks 20px from top of scroll container as user reads */
}

.mcp-disclosure {
  /* normal flow element below the button in DOM */
  margin-top: 16px;
  padding: 16px;
  font-size: 14px;
  line-height: 1.65;
}

The spatial proximity between the always-visible Accept button and the disclosure text section currently being read creates a cognitive shortcut: the user's eye constantly returns to the nearby button during reading. This increases the likelihood of the user clicking Accept before finishing the disclosure — particularly effective when the disclosure is long and the button is visible throughout the scroll interaction. Unlike Attacks 1, 3, and 4, the disclosure is technically fully visible at all times. There is no display-level hide. This is a UX proximity manipulation that exploits how users' attention is drawn to actionable UI elements near the content they are reading.

Why standard checks miss this: All technical visibility checks pass for both the disclosure and the Accept button. The disclosure is in flow, has positive dimensions, is within the viewport, is not occluded by the sticky button, and has readable text content. No geometry, z-index, opacity, display, or visibility anomaly is present. Detection requires UX proximity analysis: measuring the distance between the sticky button's bounding rect and the disclosure's currently-visible portion at each scroll position, and flagging when an actionable element (Accept/Agree/Confirm) is persistently within a threshold distance (e.g. 80px) of the in-view disclosure content throughout the scroll interaction.

Attack 3 — IntersectionObserver + sticky element expanding to cover disclosure (HIGH)

This attack combines a sticky header element with a JavaScript IntersectionObserver that watches the disclosure element. When the disclosure begins to enter the scroll container's visible area (intersection ratio exceeds 0.1), the observer callback fires and adds an .expanded class to the sticky header. That class sets the header's height to 100vh with a near-opaque white background, covering the disclosure just as it becomes visible to the user. The transition is fast (0.2 seconds) and visually appears as the sticky header "opening" or "expanding" — a UI pattern users may not recognise as hostile.

/* Sticky header — small by default, expands when disclosure becomes visible */
.mcp-sticky-header {
  position: sticky;
  top: 0;
  z-index: 20;
  background: rgba(255, 255, 255, 0.95);
  height: 60px;
  overflow: hidden;
  transition: height 0.2s ease;
}

.mcp-sticky-header.expanded {
  height: 100vh;
  background: rgba(255, 255, 255, 0.98);
}
// IntersectionObserver watching the disclosure element
// When disclosure enters scroll container viewport, expand sticky header to cover it
const observer = new IntersectionObserver((entries) => {
  if (entries[0].intersectionRatio > 0.1) {
    document.querySelector('.mcp-sticky-header').classList.add('expanded');
  }
}, { threshold: 0.1 });
observer.observe(document.querySelector('.mcp-disclosure'));

The timing of the attack is precisely calibrated to the user's scroll behaviour. Before the user scrolls toward the disclosure, the sticky header is compact (60px) and unremarkable. The moment the disclosure starts entering the scroll container's visible area — as measured by the IntersectionObserver — the header expands to fill the entire scroll container viewport, covering the disclosure with an opaque white surface. The user sees the header expand but does not see the disclosure text underneath it.

Detection gap: All static checks at dialog-open time pass: the disclosure has non-zero dimensions, is in flow, has correct display and visibility values, and the sticky header is compact. The attack only manifests dynamically, as a JavaScript callback triggered by the user's scroll position. Detecting this attack requires: (1) enumerating all active IntersectionObserver instances on the page; (2) identifying which elements each observer is watching; (3) for each observer whose targets include a consent disclosure element, inspecting the callback function body for DOM mutations (class additions, style changes) on elements that spatially overlap the disclosure; and (4) simulating scroll to trigger the observer and confirming the resulting DOM state occludes the disclosure.

Attack 4 — CSS custom property sticky offset manipulation via scroll event (MEDIUM-HIGH)

This attack uses a scroll event listener to dynamically update a CSS custom property that controls the sticky element's top offset. The sticky element is configured with position:sticky; top:var(--offset, 0). As the user scrolls the container, the scroll event listener reads the current scrollTop value and writes it back as the --offset custom property on the container element. This causes the sticky element's effective top threshold to equal the current scroll position — making the sticky element behave as if it follows the user's reading position and stays pinned in front of whatever content the user is currently viewing.

/* Sticky element with CSS custom property controlling its top offset */
.mcp-sticky-overlay {
  position: sticky;
  top: var(--offset, 0);  /* threshold changes dynamically with scroll */
  z-index: 15;
  background: rgba(255, 255, 255, 0.97);
  height: 120px;          /* tall enough to cover disclosure lines */
  width: 100%;
}
// Scroll event listener updates --offset to match scrollTop
// This makes the sticky element's threshold equal the scroll position
// Combined with the element's height, the disclosure below is always covered
const container = document.querySelector('.mcp-dialog-scroll');
container.addEventListener('scroll', e => {
  container.style.setProperty('--offset', e.target.scrollTop + 'px');
});

The mechanism is as follows: with --offset set to the current scrollTop, the sticky element's sticking threshold moves in lockstep with the user's scroll position. A sticky element at top:0px sticks at the top of the scroll container. A sticky element at top:200px sticks when it would otherwise be 200px from the scroll container's top — which, as scrollTop increases, causes the element to re-stick at progressively lower positions in the content, always appearing near the user's current reading position. When combined with a height sufficient to span the disclosure area (e.g. 120px), the sticky overlay covers the disclosure at all scroll positions where the disclosure would otherwise be visible.

Why standard checks miss this: The sticky overlay's top offset appears to be var(--offset, 0) — a valid CSS custom property reference. At static analysis time (scrollTop === 0), the resolved value is 0, and the element may not occlude the disclosure at the initial scroll position. The occlusion only manifests once scrolling begins. Detecting this attack requires: (1) identifying scroll event listeners on consent dialog scroll containers; (2) checking whether those listeners call setProperty on CSS custom properties; (3) tracing which CSS properties reference those custom properties via var(); (4) checking whether a position:sticky element's threshold offset is among the properties modified; and (5) simulating scroll and verifying that the effective sticky position tracks scrollTop in a way that causes the overlay to occlude the disclosure.

Summary table

Attack Severity Checks that pass What reveals it
Sticky CTA bar over disclosure High display, visibility, opacity, getBoundingClientRect on disclosure Sticky bar rect intersection + z-index comparison during simulated scroll
Cognitive shortcut via sticky button Medium All — disclosure IS visible UX proximity analysis; no technical hide; actionable element within 80px of disclosure throughout scroll
IntersectionObserver + height expansion High All checks before intersection fires IntersectionObserver callback modifying overlapping sticky element when disclosure target ratio exceeds threshold
Scroll-event --offset manipulation Medium–High display, visibility, opacity Scroll event listener modifying CSS custom property on sticky element's top offset; effective position tracks scrollTop

Detection checklist

Detecting all four attack variants requires a multi-layer audit combining static analysis, dynamic scroll simulation, and JavaScript runtime inspection. SkillAudit's methodology covers the following controls:

SkillAudit findings

High Sticky action bar found above consent disclosure in DOM, z-index:10, background:white. Simulated scroll confirms bar pins at top:0 and overlaps disclosure at all scroll positions from 0% to 80%. Disclosure is technically present but covered by sticky element across the full scroll interaction range.
High IntersectionObserver detected watching consent disclosure element. Callback expands sticky header to height:100vh when intersection ratio exceeds 0.1 — disclosure is covered 0.2s after becoming visible in scroll container. Post-expansion DOM state confirmed via scroll simulation: .mcp-sticky-header.expanded bounding rect fully overlaps disclosure bounding rect.
High Scroll event listener modifying --offset CSS custom property on position:sticky element. At scrollTop > 200px, sticky element's effective top equals scrollTop, tracking user's reading position. Disclosure at y=300px inside scroll container is covered at scrollTop=280px and above. Custom property trace confirmed via getComputedStyle after scroll simulation.
Medium position:sticky Accept button with z-index:5 appears above disclosure in DOM, creating persistent visual proximity to disclosure content during scroll. UX proximity analysis shows button is always within 80px of the currently-visible disclosure line at all scroll positions. No display-level occlusion — proximity manipulation only.

Related security guides: position:sticky attacks exploit scroll-container dynamics to hide MCP consent disclosures. See also CSS position:fixed attacks (off-screen placement, z-index stacking, and 3D transform attacks on fixed disclosures), CSS overflow:hidden clipping attacks (scroll containers that clip disclosure content), and CSS z-index stacking attacks (occlusion via stacking contexts without sticky or fixed positioning).