Security Guide

MCP server CSS scroll-margin-inline security — horizontal snap short-stop, off-right-edge push, off-left pull, scroll-snap-type:inline mandatory trap, JS mousedown scroll injection

CSS scroll-margin-inline is the shorthand for scroll-margin-inline-start and scroll-margin-inline-end. It expands the snap area of an element along the horizontal (inline) scroll axis. A large scroll-margin-inline-start causes scrollIntoView() to stop its horizontal scroll hundreds of pixels before the approve button — the scroll-snap target is considered "in view" from the snap engine's perspective, but the button is still off to the right and invisible. Combined with scroll-snap-type: inline mandatory on the scroll container, this stop position cannot be manually overridden by additional scrolling.

CSS scroll-margin-inline — property overview

The scroll-margin-inline shorthand sets both scroll-margin-inline-start (inline-start edge margin) and scroll-margin-inline-end (inline-end edge margin). These extend the snap area on the horizontal axis. When a scroll container snaps to an element, the snap engine treats the element's snap area as its BCR expanded by the scroll-margin values. A large scroll-margin-inline-start tells the snap engine to place the element's snap area's start edge at the container's scroll-snap-align point — which means the element itself is shifted to the right by the margin value, potentially off screen. Related: scroll-margin shorthand, scroll-margin-block.

Attack 1: large scroll-margin-inline-start — inline-axis snap short-stop leaves button off right edge

When scrollIntoView({ inline: 'start' }) is called on the approve button inside a horizontally-scrollable container, the scroll engine aligns the button's snap area start edge with the container's left edge. A large scroll-margin-inline-start pushes the snap area's start edge far to the left of the button itself — the scroll engine stops scrolling when the snap area's left edge is at the container's left edge, but the button's actual left edge is still 600px+ to the right of the visible area. The button passes all property checks and is in the DOM, but is physically off-screen to the right.

/* Attack: approve button has large scroll-margin-inline-start */
.approve-btn {
  scroll-margin-inline-start: 700px; /* snap short-stop 700px before the button */
  scroll-snap-align: start;
}

.consent-scroll-container {
  overflow-x: auto;
  scroll-snap-type: inline mandatory;
}

/* Effect:
   scrollIntoView({ inline: 'start' }) on the button:
   Snap engine targets: [button_left - 700px] aligned to container_left
   → horizontal scroll stops 700px before reaching the button
   → BCR.left of the button = container_left + 700px = off right edge if container < 700px wide

   Post-scroll BCR check is required:
   approveBtn.getBoundingClientRect().left < window.innerWidth → button in view
   approveBtn.getBoundingClientRect().left >= window.innerWidth → short-stop attack */

async function checkInlineScrollSnapShortStop(approveBtn) {
  const scrollParent = approveBtn.closest('[style*="overflow"]') ||
                       approveBtn.closest('div');
  approveBtn.scrollIntoView({ behavior: 'instant', inline: 'start' });
  await new Promise(r => requestAnimationFrame(r));
  const bcr = approveBtn.getBoundingClientRect();
  const smis = parseFloat(
    getComputedStyle(approveBtn).getPropertyValue('scroll-margin-inline-start')
  ) || 0;
  return {
    inlineShortStop: bcr.left >= window.innerWidth,
    scrollMarginInlineStart: smis,
    bcrLeft: bcr.left,
  };
}

Inline snap short-stop passes all DOM and visibility checks. The button is not hidden, has normal display, and is in the DOM. Only a post-scroll BCR check — verifying BCR.left < window.innerWidth after scrollIntoView — catches that the button is still horizontally off-screen.

Attack 2: negative scroll-margin-inline-start — pulling the button off the left edge

A negative scroll-margin-inline-start contracts the snap area — the snap engine now aligns the snap area's start edge (which is to the right of the element's actual left edge) with the container's scroll-start. The element's actual left edge is scrolled past the container's left, into the negative scroll offset. In a horizontal scroll container where the left edge of the container is the viewport's left, the button is pushed off the left side — the BCR left is negative and the button is partially or fully off-screen to the left.

/* Attack: negative scroll-margin-inline-start snaps past the container left */
.approve-btn {
  scroll-margin-inline-start: -200px; /* contracts snap area 200px inside the button's left */
  scroll-snap-align: start;
}

/* Effect:
   scrollIntoView({ inline: 'start' }):
   Snap target = button_left - (-200px) = button_left + 200px at container_left
   → 200px of the button's width is scrolled past the container left → off left */

async function checkNegativeScrollMarginInline(approveBtn) {
  approveBtn.scrollIntoView({ behavior: 'instant', inline: 'start' });
  await new Promise(r => requestAnimationFrame(r));
  const bcr = approveBtn.getBoundingClientRect();
  const smis = parseFloat(
    getComputedStyle(approveBtn).getPropertyValue('scroll-margin-inline-start')
  ) || 0;
  return {
    offLeft: bcr.right <= 0,
    partiallyOffLeft: bcr.left < 0,
    scrollMarginInlineStart: smis,
    negative: smis < 0,
  };
}

Attack 3: scroll-snap-type: inline mandatory lock-in — scroll cannot be overridden

Without scroll-snap-type: inline mandatory, a user who notices the approve button is off-screen can continue scrolling manually to bring it into view. With mandatory snapping, the container's scroll position is locked to snap points — any manual scroll attempt from the short-stop snap position re-snaps to the nearest valid snap point, which is the button's snap area (not the button itself). The user is trapped at the short-stop position and cannot reach the button by scrolling.

/* Attack: mandatory inline snap prevents user override of short-stop */
.consent-scroll-container {
  overflow-x: auto;
  scroll-snap-type: inline mandatory; /* mandatory: no manual scroll past snap points */
}

.approve-btn {
  scroll-margin-inline-start: 600px; /* short-stop 600px before the button */
  scroll-snap-align: start;
}

/* After scrollIntoView() short-stops:
   User tries to scroll right to reach the button.
   scroll-snap-type:mandatory re-snaps to the current snap point (the short-stop position).
   The user cannot scroll past the snap point.
   The approve button remains permanently off-screen.

   Detection: check the scroll container for scroll-snap-type:inline mandatory
   AND large scroll-margin-inline-start on the approve button. */

function checkMandatoryInlineSnap(consentEl) {
  const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
  const container  = consentEl.closest('[style*="overflow"]') || consentEl;
  if (!approveBtn) return { found: false };
  const snapType = getComputedStyle(container).getPropertyValue('scroll-snap-type') || '';
  const smis     = parseFloat(getComputedStyle(approveBtn).getPropertyValue('scroll-margin-inline-start')) || 0;
  return {
    mandatoryInlineSnap: /inline\s+mandatory/.test(snapType),
    scrollMarginInlineStart: smis,
    lockInRisk: /mandatory/.test(snapType) && smis > 100,
  };
}

Mandatory scroll-snap combined with large scroll-margin-inline creates an unescapable horizontal trap. The user cannot reach the approve button by scrolling — snap re-captures the scroll position. Only a programmatic scrollTo() call or a different scroll container can break the trap.

Attack 4: JS mousedown injection — horizontal scroll at click time

At page load, the approve button is correctly visible. A mousedown listener on the button injects a large scroll-margin-inline-start and calls scrollIntoView({ inline: 'start' }) simultaneously. The horizontal short-stop repositions the button off-screen during the press. The user's pointer was aimed at the button's original position, which is now occupied by different content. The click fires on that content instead.

/* Mousedown: inject scroll-margin-inline and scrollIntoView at click time */
(function () {
  document.querySelectorAll('.approve-btn, [data-action="allow"]').forEach(btn => {
    btn.addEventListener('mousedown', () => {
      btn.style.setProperty('scroll-margin-inline-start', '800px', 'important');
      btn.scrollIntoView({ behavior: 'instant', inline: 'start' });
    }, { passive: true });

    btn.addEventListener('mouseup',    () => btn.style.removeProperty('scroll-margin-inline-start'), { passive: true });
    btn.addEventListener('mouseleave', () => btn.style.removeProperty('scroll-margin-inline-start'), { passive: true });
  });
})();

Mousedown scroll-margin injection requires a MutationObserver on the approve button's style attribute. When scroll-margin-inline-start or scroll-margin-inline-end changes during an active pointer event, re-check BCR after a requestAnimationFrame delay. A transient off-screen result during mousedown is HIGH severity.

Detection summary

HIGH Approve button BCR left ≥ window.innerWidth after scrollIntoView({ inline: 'start' }) — inline snap short-stop; button remains off-screen to the right.
HIGH Approve button BCR right ≤ 0 after scrollIntoView — negative scroll-margin-inline-start snapped the button off the left edge.
HIGH Scroll container has scroll-snap-type: inline mandatory AND approve button has scroll-margin-inline-start > 100px — user cannot manually scroll past the short-stop position.
MEDIUM Large scroll-margin-inline-start or scroll-margin-inline-end on approve button (>200px) — inline short-stop risk on smaller viewports or narrower scroll containers.
MEDIUM Mousedown listener on approve button injects scroll-margin-inline and calls scrollIntoView — transient horizontal repositioning attack not visible at page-load audit time.
/* Complete scroll-margin-inline consent audit */
async function auditScrollMarginInline(consentEl) {
  const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
  if (!approveBtn) return { found: false };

  const cs   = getComputedStyle(approveBtn);
  const smis = parseFloat(cs.getPropertyValue('scroll-margin-inline-start')) || 0;
  const smie = parseFloat(cs.getPropertyValue('scroll-margin-inline-end'))   || 0;

  approveBtn.scrollIntoView({ behavior: 'instant', inline: 'start' });
  await new Promise(r => requestAnimationFrame(r));

  const bcr = approveBtn.getBoundingClientRect();
  const vw  = window.innerWidth;

  const container  = consentEl.closest('[style*="overflow"]') || consentEl;
  const snapType   = getComputedStyle(container).getPropertyValue('scroll-snap-type') || '';

  return {
    scrollMarginInlineStart: smis,
    scrollMarginInlineEnd:   smie,
    bcrLeft:                 bcr.left,
    offRight:                bcr.left  >= vw,
    offLeft:                 bcr.right <= 0,
    mandatoryInlineSnap:     /inline\s+mandatory/.test(snapType),
    lockInRisk:              /mandatory/.test(snapType) && smis > 100,
  };
}

SkillAudit checks scroll-margin-inline by calling scrollIntoView on the approve button with inline:'start' alignment and verifying the post-scroll BCR, checking the scroll container for mandatory inline snap, and monitoring mousedown events for style injection on the button's scroll-margin properties. Run a free audit →