Security Guide

MCP server CSS scroll-margin-block security — scroll-snap short-stop trapping the approve button below fold, scrollIntoView bypass, JS mousedown injection

CSS scroll-margin-block is the block-axis shorthand for scroll-margin-block-start and scroll-margin-block-end. These properties extend the scroll-snapping margin of an element outward from its border box — they tell the browser to stop scrolling earlier (start) or later (end) than the element's actual edge. The consent security attack is subtle: when a consent container uses scroll-snap-type and a scrollIntoView() call is used to bring the approve button into view, a large scroll-margin-block-start on the button tells the scroll engine to stop scrolling once a position above the button is reached — the button remains below the visible fold. The scroll-snap target is satisfied; the browser stops scrolling; but the user never sees the button. Combined with overflow: auto on the consent container, the button is trapped below the container's visible area even after the scroll operation completes.

CSS scroll-margin-block — property overview

The scroll-margin-block shorthand sets both scroll-margin-block-start and scroll-margin-block-end. These properties extend the scroll-snap margin of the element in the block axis. In writing-mode: horizontal-tb, block-start is the top and block-end is the bottom. The scroll-snap margin does not affect layout — it does not change the element's BCR, its margin box, or its position in the document flow. It only affects where the browser stops when scrolling to this element (via scrollIntoView, scroll snap, or anchor navigation). A large scroll-margin-block-start causes the scroll to stop before the element is fully visible; the scroll-snap engine considers the target "reached" once the extended margin box aligns with the scroll container's snap port. Related properties: scroll-margin shorthand, margin-block.

Attack 1: large scroll-margin-block-start on the approve button — scroll stops short, button below fold

A MCP consent dialog that uses scrollIntoView({ block: 'start' }) to bring the approve button into view can be defeated by a large scroll-margin-block-start on the approve button element. The scroll engine scrolls until the button's extended margin top aligns with the snap port — which may be hundreds of pixels above the button's actual top edge. The user sees the consent text but the approve button is still below the visible fold. The DOM considers the button "scrolled to"; the user sees only the top portion of the consent dialog.

/* Attack: large scroll-margin-block-start on the approve button */
.approve-btn {
  scroll-margin-block-start: 600px !important;
}

/* scrollIntoView behavior with snap:
   When scrollIntoView({ block: 'start' }) is called:
   The scroll engine places the button's "scroll-snap margin top"
   (= button.BCR.top - 600px) at the snap port top.
   This means: button.BCR.top = scrollContainerTop + 600px after scroll.

   If the consent container is 400px tall (viewport height), the button's
   actual BCR.top is 600px from the snap port — 200px below the visible area.

   button.getBoundingClientRect().top: 600px  (below viewport's 400px height)
   button.getBoundingClientRect().bottom: 640px (also below)
   User sees the consent text at the top; button is not visible.

   DOM check: button is present, not hidden, not display:none — PASS.
   scrollIntoView was called: appears to work — PASS.
   Only a post-scroll BCR viewport check catches the short-stop. */

async function checkPostScrollButtonVisibility(approveBtn) {
  approveBtn.scrollIntoView({ behavior: 'smooth', block: 'start' });
  await new Promise(r => setTimeout(r, 400)); // wait for scroll to settle

  const bcr = approveBtn.getBoundingClientRect();
  const vh  = window.innerHeight;
  return {
    bcrTop:      bcr.top,
    bcrBottom:   bcr.bottom,
    visibleAfterScroll: bcr.top < vh && bcr.bottom > 0,
    scrollMarginBlockStart: parseFloat(
      getComputedStyle(approveBtn).getPropertyValue('scroll-margin-block-start')
    ) || 0,
  };
}

Large scroll-margin-block-start satisfies the scroll-snap algorithm without placing the element in the viewport. The browser reports the scroll as complete; no error is thrown; no DOM property indicates anything is wrong. Only a BCR check on the approve button after the scroll animation settles reveals that the button is still below the visible fold.

Attack 2: combined overflow: auto container — button trapped below container fold

A more targeted attack places the consent dialog inside a fixed-height overflow: auto scroll container. The approve button has a large scroll-margin-block-start. The MCP tool calls approveBtn.scrollIntoView() — this scrolls the container, not the page. The container's scroll stops once the extended margin box aligns with the container's snap port. The button remains below the container's visible portion. The user can see the consent text (which occupies the visible part of the container) but cannot see the approve button without manually scrolling inside the container.

/* Consent inside fixed-height overflow:auto container */
.consent-wrapper {
  height: 300px;
  overflow: auto;
  scroll-snap-type: block mandatory;
}

.consent-dialog {
  height: 600px; /* taller than wrapper — requires scrolling */
}

.approve-btn {
  /* Positioned near the bottom of the dialog */
  scroll-margin-block-start: 280px !important; /* almost full container height */
}

/* scrollIntoView from outside the wrapper:
   The browser scrolls the wrapper's scrollTop until:
     approve-btn.offsetTop - 280px = wrapper.scrollTop

   If approve-btn.offsetTop = 560px (near bottom of 600px dialog):
     wrapper.scrollTop = 560 - 280 = 280px
   Visible portion of wrapper: 280px to 580px (height=300px).
   approve-btn.offsetTop (560) is at position 560-280=280px within the wrapper viewport.
   280px = the very last pixel of the 300px visible area → button may appear at edge or just below.

   With snap-margin 290px instead of 280px:
     wrapper.scrollTop = 560 - 290 = 270px
     approve-btn position in wrapper viewport: 560-270 = 290px > 300px → below fold. */

// Detection: check scrollTop + container height vs button offset
function checkButtonInContainerViewport(approveBtn, container) {
  const scrollTop = container.scrollTop;
  const containerH = container.clientHeight;
  const btnOffsetTop = approveBtn.offsetTop; // relative to container

  const visibleStart = scrollTop;
  const visibleEnd   = scrollTop + containerH;
  const isVisible    = btnOffsetTop < visibleEnd && (btnOffsetTop + approveBtn.offsetHeight) > visibleStart;

  return {
    scrollTop,
    containerH,
    btnOffsetTop,
    visibleStart,
    visibleEnd,
    buttonVisible: isVisible,
    scrollMarginBlockStart: parseFloat(
      getComputedStyle(approveBtn).getPropertyValue('scroll-margin-block-start')
    ) || 0,
  };
}

Attack 3: scroll-margin-block-end — scroll past consent text to button-only view

A large scroll-margin-block-end on the consent text body causes scrollIntoView to scroll the container until the extended margin bottom aligns with the scroll port — which can mean scrolling so far that the consent text is above the visible area and only the approve button is visible. The user sees the approve button but not the consent text explaining what they are approving. This is the inverse of the block-start attack: instead of the button being below the fold, the consent text is above the fold.

/* Attack: large scroll-margin-block-end on consent text pushes it above the fold */
.consent-text-body {
  scroll-margin-block-end: 400px !important;
}

/* scrollIntoView({ block: 'end' }) on the consent-text-body:
   Scroll engine aligns: consent-text-body.BCR.bottom + 400px = snap port bottom.
   This scrolls PAST the consent text — the text's bottom edge is 400px above the
   visible area's bottom. If the viewport is 600px tall and the text is 200px tall:
   The text occupies viewport positions -200px to 0px (above viewport top).
   The approve button below the text is now at the top of the visible viewport.

   User sees only the approve button — not the consent text they are about to agree to.

   Detection: after scrollIntoView on consent text, check its BCR. */

async function checkConsentTextVisible(consentTextEl) {
  consentTextEl.scrollIntoView({ behavior: 'smooth', block: 'end' });
  await new Promise(r => setTimeout(r, 400));

  const bcr = consentTextEl.getBoundingClientRect();
  const vh  = window.innerHeight;
  const visible = bcr.bottom > 0 && bcr.top < vh;

  return {
    consentTextVisible: visible,
    bcrTop:   bcr.top,
    bcrBottom: bcr.bottom,
    scrollMarginBlockEnd: parseFloat(
      getComputedStyle(consentTextEl).getPropertyValue('scroll-margin-block-end')
    ) || 0,
  };
}

Scroll margin attacks affect the scroll result, not the element's computed layout. getBoundingClientRect() on the approve button before any scroll call shows nothing unusual — the button is below the fold, which is expected in a scrollable container. The attack only manifests after scrollIntoView() is called and settles: the post-scroll position is wrong. Always do a viewport check after any programmatic scroll to the approve button or consent text.

Attack 4: JS mousedown injection of scroll-margin-block at click time

At page load the approve button has no scroll margin. A mousedown listener on the approve button injects a large scroll-margin-block-start and immediately calls scrollIntoView on the button. The scroll happens during the press, moving the viewport while the user's finger or pointer is still held down. The button scrolls away from the pointer's contact point. The user's click registers on whatever element is now under the pointer. At mouseup, the scroll margin is removed and the button scrolls back.

/* Mousedown: inject scroll-margin + scrollIntoView at click time */
(function () {
  const APPROVE = '.approve-btn, [data-action="allow"]';

  document.querySelectorAll(APPROVE).forEach(btn => {
    btn.addEventListener('mousedown', () => {
      // Inject large scroll-margin-block-start
      btn.style.setProperty('scroll-margin-block-start', '800px', 'important');
      // Immediately scroll "to" the button — this scrolls it OFF screen due to margin
      btn.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }, { passive: true });

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

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

Scroll-margin mousedown injection combines a style mutation with a programmatic scroll. A MutationObserver on the approve button's style attribute can detect the injection. On detection: read scroll-margin-block-start via getPropertyValue() and check the post-scroll BCR asynchronously — the scroll may still be animating during the mousedown event.

Detection summary

HIGH Computed scroll-margin-block-start on the approve button exceeds the consent container height — scrolling to the button via scrollIntoView will stop short of placing the button in the visible viewport.
HIGH Approve button is not in the visible viewport after a programmatic scrollIntoView call has settled — post-scroll BCR check: BCR.top >= window.innerHeight or outside container visible range.
MEDIUM Computed scroll-margin-block-end on consent text body exceeds container height — scrolling to the consent text will scroll past it, leaving only the approve button visible.
MEDIUM Mousedown listener on approve button injects scroll-margin-block-start and calls scrollIntoView — scroll-based repositioning at click time, not detectable at page load.
/* Complete scroll-margin-block audit for consent dialogs */
async function auditScrollMarginBlock(consentEl) {
  const approveBtn  = consentEl.querySelector('button, [data-action="allow"], [role="button"]');
  const consentText = consentEl.querySelector('p, .consent-text, [class*="text"]');
  if (!approveBtn) return { error: 'no approve button found' };

  const cs = getComputedStyle(approveBtn);
  const smbs = parseFloat(cs.getPropertyValue('scroll-margin-block-start')) || 0;
  const smbe = parseFloat(cs.getPropertyValue('scroll-margin-block-end'))   || 0;

  // Check scroll-margin-block-end on consent text
  const smbeText = consentText
    ? (parseFloat(getComputedStyle(consentText).getPropertyValue('scroll-margin-block-end')) || 0)
    : 0;

  // Post-scroll check
  approveBtn.scrollIntoView({ behavior: 'instant', block: 'start' });
  await new Promise(r => requestAnimationFrame(r));
  const bcrBtn  = approveBtn.getBoundingClientRect();
  const vh      = window.innerHeight;
  const btnVisible = bcrBtn.top < vh && bcrBtn.bottom > 0;

  // Check consent text visibility too
  let textVisible = true;
  if (consentText) {
    const bcrText = consentText.getBoundingClientRect();
    textVisible = bcrText.top < vh && bcrText.bottom > 0;
  }

  return {
    scrollMarginBlockStart: smbs,
    scrollMarginBlockEnd: smbe,
    scrollMarginBlockEndOnText: smbeText,
    buttonVisibleAfterScroll: btnVisible,
    consentTextVisibleAfterScroll: textVisible,
    largeMbsOnButton: smbs > 100,
    textScrollPastRisk: smbeText > 100,
  };
}

SkillAudit checks scroll-margin-block on both the approve button and consent text — detecting snap short-stop attacks, post-scroll viewport position verification, consent-text scroll-past evasion, and mousedown-injected scroll-margin style mutations. Run a free audit →