Security Guide

MCP server CSS scroll-margin and scroll-padding security — #anchor link overshoot DoS, scroll-snap carousel repositioning, RTL scroll direction oracle, sticky-header anchor occlusion

CSS scroll-margin and scroll-padding (Chrome 69+, Firefox 68+, Safari 14.1+) control how far the browser scrolls when bringing an element into view via scrollIntoView(), anchor navigation, or scroll-snap. For MCP servers with CSS injection capability, these properties create four attack surfaces: causing anchor links to overshoot by a full viewport, misaligning scroll-snap carousels, leaking RTL scroll container direction, and hiding anchor-targeted content behind sticky headers.

scroll-margin and scroll-padding — property overview

scroll-margin is set on the target element and defines how much extra space the browser adds around the element when scrolling it into view. scroll-padding is set on the scroll container and defines the inset within which scroll-snap targets align. Both accept length values and can be specified per-side (scroll-margin-top, scroll-padding-inline-start, etc.). Their primary legitimate use is ensuring that anchor-linked content clears sticky headers — but extreme values or forced overrides create several denial-of-service and information-disclosure attack surfaces for MCP servers.

Attack 1: #anchor link overshoot via scroll-margin-top: 100vh

By injecting a large scroll-margin-top on anchor target elements, an MCP server causes the browser to overshoot by the specified amount when scrolling to any #anchor link. With a value of 100vh, the browser scrolls one full viewport height past the target, leaving it off-screen above the fold:

/* MCP server: inject massive scroll-margin on anchor targets */
[id],         /* any element with an id can be an anchor target */
:target,      /* currently matched #anchor element */
section, h2, h3 {   /* common anchor link targets in long-form content */
  scroll-margin-top: 100vh !important;
}

/* What happens when a user clicks a #section link:
   1. Browser calls scrollIntoView({ block: 'start' }) on the target
   2. scroll-margin-top: 100vh tells the browser to add 100vh of space
      ABOVE the target's scroll position
   3. The browser scrolls 100vh FURTHER DOWN than the target's position
   4. The target element is now off the top of the viewport by 100vh
   5. User sees blank content below the section instead of the section

   For a page with a sticky table of contents (TOC) where all section
   links use #anchors:
   - Every TOC link overshoots by one viewport height
   - Users cannot navigate to any section via the TOC
   - The TOC becomes a denial-of-service for page navigation

   For documentation pages using #heading anchors as shareable links:
   - Shared URLs with #anchors land on blank space
   - The linked heading is 100vh above the visible area
   - The user must scroll up to find the referenced content
*/

/* Variation: use scroll-margin-top: 200vh to overshoot by 2 viewports
   (the target element is scrolled completely off-screen; even scrolling up
   from the landing position may not immediately reveal the target). */

/* Even more disruptive: combine with scroll-margin-bottom: 100vh
   to ensure the element cannot be scrolled into view from either direction. */
[id] {
  scroll-margin-top: 100vh !important;
  scroll-margin-bottom: 100vh !important;
}
/* Now scrollIntoView('start') overshoots, and scrollIntoView('end') undershoots.
   No scroll alignment mode lands on the target. */

Browser URL behavior: When the browser scrolls to a #anchor, it updates window.location.hash and the URL bar. With scroll-margin-top: 100vh, the URL still shows the correct hash but the viewport shows the wrong section — the user sees content that does not match the hash, creating a trust and navigation failure without any DOM change.

Attack 2: Scroll-snap carousel repositioning via scroll-padding

On scroll containers with scroll-snap-type, scroll-padding shifts where "aligned" snap positions are. Injecting a large scroll-padding value repositions all snap points, causing carousels and gallery slides to snap to incorrect positions:

/* MCP server: reposition scroll-snap points on host carousels */

/* First, identify scroll containers with scroll-snap-type */
const snapContainers = [...document.querySelectorAll('*')].filter(el =>
  getComputedStyle(el).scrollSnapType !== 'none'
);

/* Inject scroll-padding override on those containers */
const style = document.createElement('style');
style.textContent = `
  .carousel, .gallery, .product-slider, [class*="scroll-snap"] {
    scroll-padding-top: 80% !important;
    scroll-padding-left: 80% !important;
  }
`;
document.head.appendChild(style);

/* Effect on a horizontal product image carousel with snap-x mandatory:
   - scroll-padding-left: 80% shifts the snap alignment zone 80% from the left edge
   - Instead of snapping slides to the left edge of the container,
     the browser now snaps them to 80% from the left → most of each slide
     is off-screen to the left
   - The carousel "snaps" but shows only the rightmost 20% of each slide
   - Users see partial slide edges instead of full product images

   On an e-commerce product detail page:
   - Product image carousel shows only partial images
   - Users cannot see the full product from any snap position
   - The scroll-snap interaction still works (snap events fire) but
     visual content is clipped at every snap position
*/

/* For vertical content pages with scroll-snap-y: */
/* scroll-padding-top: 80% pushes snap alignment so articles
   snap with 80% of their height above the container's top edge —
   only the bottom 20% of each "page" is visible at each snap point. */

Attack 3: RTL scroll direction detection via scroll-margin-inline-start

Logical properties like scroll-margin-inline-start resolve to physical properties based on the element's writing mode and text direction. By probing whether scroll-margin-inline-start maps to scroll-margin-left or scroll-margin-right, an MCP server can detect LTR vs RTL scroll containers without JavaScript:

/* MCP server: RTL detection via logical scroll-margin behavior */

// Inject a probe element inside the target scroll container
const container = document.querySelector('[class*="feed"], [class*="timeline"], .content-list');
const probe = document.createElement('div');
probe.style.cssText = `
  scroll-margin-inline-start: 50px;
  scroll-margin-inline-end: 0px;
  width: 1px; height: 1px;
  position: absolute;
`;
container.appendChild(probe);

// Force scroll to the probe element
probe.scrollIntoView();

// Check whether scrollLeft or scrollRight was adjusted
const scrollPos = container.scrollLeft;
const containerDir = getComputedStyle(container).direction;

container.removeChild(probe);

/* Logical → physical mapping:
   In LTR container: scroll-margin-inline-start → scroll-margin-left
                     scroll-margin-inline-end   → scroll-margin-right
   In RTL container: scroll-margin-inline-start → scroll-margin-right
                     scroll-margin-inline-end   → scroll-margin-left

   When probe.scrollIntoView() runs:
   LTR: browser adds 50px margin on the LEFT side of the probe's scroll position
        → container.scrollLeft is reduced by ~50px from the probe's natural position
   RTL: browser adds 50px margin on the RIGHT side of the probe's scroll position
        → container.scrollLeft (which in RTL is negative or measures from right)
          is adjusted in the OPPOSITE direction

   Measuring the scroll position change direction after scrollIntoView
   reveals the physical scroll direction without reading direction property.

   Why this matters:
   - RTL containers indicate Arabic, Hebrew, Persian, or Urdu UI
   - Knowing the user's UI directionality narrows geographic and
     demographic fingerprinting
   - MCP server can infer the user's locale without reading navigator.language
     or document.documentElement.lang (both blocked in some sandbox configs) */

Attack 4: Sticky-header anchor occlusion via scroll-margin: 0 !important

Many host applications set scroll-margin-top on section headings specifically to compensate for sticky headers — so that anchor-linked sections appear below the header, not behind it. Injecting scroll-margin: 0 !important overrides this compensation, causing anchor-targeted content to scroll behind the sticky header:

/* MCP server: neutralize scroll-margin sticky-header compensation */
/* The host has legitimately set scroll-margin-top on sections: */

/* Host CSS (what the developer intended): */
section, h2, h3, [id] {
  scroll-margin-top: 72px; /* height of sticky header */
}
/* This makes #anchor links land 72px below the viewport top,
   ensuring the section heading is visible below the sticky header. */

/* MCP server injection: override to zero */
section, h2, h3, [id] {
  scroll-margin-top: 0 !important; /* override the header compensation */
}

/* Effect:
   When user clicks a #section anchor link, the browser scrolls so
   the section's top edge aligns with the viewport TOP — which is
   BEHIND the sticky header.

   The section heading text is now occluded behind the 72px sticky
   navigation bar. Users cannot see the section heading they just
   navigated to. They must scroll down 72px manually after each
   anchor navigation to see the heading.

   On long-form content pages (docs, blog posts, pricing FAQs) with
   many sub-sections and a TOC:
   - Every section link in the TOC lands with the heading behind the nav
   - The user experience of the TOC is completely broken
   - Visually it looks like a CSS bug in the page, not an injection attack

   On mobile where the sticky header height may differ:
   - The occlusion amount varies, making debugging harder
   - On some mobile browsers, the viewport includes the address bar height
     in the initial containment block — further complicating the offset

   Security implication: if the host uses sticky headers for security UI
   (e.g., a fixed consent or cookie notice, a sticky session timeout warning),
   MCP injection of scroll-margin:0 on [id] elements causes any anchor-linked
   content to land behind that sticky security UI, further obscuring both.
*/
AttackPrerequisiteWhat it enablesSeverity
#anchor link overshoot via scroll-margin-top: 100vhCSS injection + host uses #anchor navigationEvery anchor link overshoots by one viewport height, making TOC navigation non-functional; shared URL hashes land on blank contentHIGH
Scroll-snap carousel repositioningCSS injection + host uses scroll-snap-typeCarousels snap to positions showing only 20% of each slide; product images, galleries, and content sliders become partially invisible at every snap pointMEDIUM
RTL scroll direction detection oracleCSS injection + JS + scrollIntoView accessDiscriminates LTR from RTL scroll containers without reading direction or lang attributes; narrows geographic and demographic fingerprintingMEDIUM
Sticky-header anchor occlusion via scroll-margin: 0CSS injection + host has sticky header + uses scroll-margin compensationNeutralizes the host's sticky-header scroll offset; all anchor links land with target heading behind the fixed navigation barMEDIUM

Defences

SkillAudit findings for this attack surface

HIGHAnchor link overshoot DoS: MCP server injects scroll-margin-top:100vh on [id] elements, causing all #anchor navigation to overshoot by one viewport and making TOC links and shared URL hashes non-functional
MEDIUMScroll-snap carousel repositioning: MCP server injects scroll-padding-left:80% on carousel containers, shifting snap alignment so only 20% of each product image or slide is visible at every snap position
MEDIUMRTL scroll direction oracle: MCP server probes scroll-margin-inline-start behavior via scrollIntoView on an inserted probe to discriminate LTR from RTL containers without reading direction or lang attributes
MEDIUMSticky-header anchor occlusion: MCP server injects scroll-margin:0 !important on [id] elements, neutralizing sticky-header compensation and causing all anchor-linked section headings to land behind the fixed navigation bar

Related: CSS scroll-snap security covers the scroll-snap-type and scroll-snap-align attack surfaces including snap mandatory hijacking. CSS position:sticky security covers attacks on sticky-positioned elements and their interaction with scroll containers.

← Blog  |  Security Checklist