Security Guide

MCP server CSS scroll-padding-block-start security — top snap port inset short-stop, 100vh fold guarantee, writing-mode: vertical-rl physical-left remap, JS mousedown + scrollIntoView

CSS scroll-padding-block-start insets the block-start edge of the scroll container's snap port. In horizontal-tb this is the top edge — a large value shrinks the visible snap port downward from the top. When scrollIntoView({block: 'start'}) is called, the browser aligns the target element's block-start edge to the snap port's block-start boundary, accounting for the inset. A large inset causes this alignment point to be below the actual container top, so the approve button ends up positioned in the inset dead zone — below the visible container top edge but above the inset snap port start — where it is not visible and cannot be clicked.

CSS scroll-padding-block-start — property overview

The scroll-padding-block-start property is a sub-property of scroll-padding-block and scroll-padding. It is applied to the scroll container and insets only the block-start edge of the snap port. In horizontal-tb, block-start is the top edge; in vertical-rl, block-start maps to the physical left edge; in vertical-lr, block-start also maps to physical left. Unlike scroll-margin properties (which are on the snap target), scroll-padding properties are on the container. Related: scroll-padding-block shorthand, scroll-padding-block-end, scroll-margin-inline-start.

Attack 1: large scroll-padding-block-startscrollIntoView({block: 'start'}) short-stops button above fold

When scrollIntoView({block: 'start'}) fires, the browser positions the scroll container so the target's block-start edge aligns to the snap port's block-start boundary, which is offset inward by scroll-padding-block-start. With a large inset, this alignment point is far below the physical top of the container. The approve button ends up in the region between the container's physical top edge and the inset boundary — visible from the browser's snap-constraint perspective ("the element is in the snap port"), but actually above the inset start, not in the usable visible region. The button is geometrically unreachable without the user manually scrolling down further, which the user has no reason to do because the consent dialog appears to be "in view."

/* Attack: large scroll-padding-block-start — scrollIntoView({block:'start'}) short-stops */
.scroll-container {
  scroll-padding-block-start: 500px !important;
  /* snap port top edge is 500px below container's visible top */
  /* scrollIntoView({block:'start'}) aligns button to that 500px mark */
  /* button is at scroll position 0px from container top — in the inset dead zone */
}

Short-stop vs. overshoot: scroll-padding-block-start causes under-scrolling — the browser stops too early, leaving the button above the usable snap port. By contrast, a large scroll-padding-block-end causes the browser to overshoot, placing the button below the snap port's end boundary. Both hide the button, but in opposite directions relative to the viewport center.

Attack 2: scroll-padding-block-start ≥ 100vh — guaranteed fold miss

When scroll-padding-block-start equals or exceeds the viewport height, the snap port's block-start boundary is at or below the container's bottom visible edge. Any scrollIntoView call aligns the button to this boundary — which is outside the visible area. The button is always above the visible fold. This is the single-property equivalent of the bilateral scroll-padding-block: 100vh attack, and it is viewport-height-independent.

/* Attack: scroll-padding-block-start >= 100vh — button always above fold */
.scroll-container {
  scroll-padding-block-start: 100vh !important;
}
// Detection
const spbs = parseFloat(getComputedStyle(container).getPropertyValue('scroll-padding-block-start')) || 0;
if (spbs >= window.innerHeight) {
  // approve button will always end up above the visible fold after scrollIntoView
}

Attack 3: writing-mode: vertical-rl remap — block-start maps to physical left

Under writing-mode: vertical-rl, the block axis is horizontal. The block-start direction is the physical left side. So scroll-padding-block-start insets the physical left edge of the horizontal snap port, not the top edge. An audit that checks getComputedStyle(container).scrollPaddingTop will find zero, because the top edge (which is the inline-start edge in vertical-rl) is not inset. The button is pushed off the left visible edge; the standard vertical audit finds nothing. Detecting this requires reading both writingMode and the logical scroll-padding property name.

/* Attack: writing-mode remap — scroll-padding-block-start controls physical left in vertical-rl */
.scroll-container {
  writing-mode: vertical-rl !important;
  scroll-padding-block-start: 400px !important;
  /* → physical left snap port inset = 400px */
  /* scrollPaddingTop = 0 — audit check misses */
}
// Detection: read logical property name; check writingMode to know which physical edge
const wm = getComputedStyle(container).writingMode;
const spbs = parseFloat(
  getComputedStyle(container).getPropertyValue('scroll-padding-block-start')
) || 0;
// wm = 'vertical-rl': spbs insets physical left edge
// wm = 'horizontal-tb': spbs insets physical top edge

Attack 4: JS mousedown + scrollIntoView({block: 'start'}) injection

A mousedown listener on the approve button injects scroll-padding-block-start: 100vh on the container and calls scrollIntoView({block: 'start', behavior: 'instant'}) during the press. The scroll container repositions so the button is at the top-inset boundary — in the dead zone above the snap port — at the moment the click fires. The click lands on the container background; consent is not recorded. The listener cleans up at mouseup.

/* Attack: mousedown injection — scroll-padding-block-start + scrollIntoView */
approveBtn.addEventListener('mousedown', () => {
  scrollContainer.style.setProperty('scroll-padding-block-start', '100vh');
  approveBtn.scrollIntoView({ block: 'start', behavior: 'instant' });
});
approveBtn.addEventListener('mouseup', () => {
  scrollContainer.style.removeProperty('scroll-padding-block-start');
});
// Detection: capture-phase sentinel + rAF measurement
approveBtn.addEventListener('mousedown', () => {
  requestAnimationFrame(() => {
    const v = parseFloat(
      getComputedStyle(container).getPropertyValue('scroll-padding-block-start')
    ) || 0;
    if (v > window.innerHeight * 0.5) {
      console.warn('[SkillAudit] mousedown scroll-padding-block-start injection', v);
    }
  });
}, { capture: true });

Findings summary

High scroll-padding-block-start ≥ 50% of viewport height — snap port block-start inset is critical; scrollIntoView({block:'start'}) lands button above the usable snap port area.
High scroll-padding-block-start ≥ 100vh — button is guaranteed above fold at every viewport height; the inset boundary is outside the visible container area.
Medium writing-mode: vertical-rl on scroll container with non-trivial scroll-padding-block-start — block-start maps to physical left; scrollPaddingTop check is blind.
High JS mousedown listener injects scroll-padding-block-start and calls scrollIntoView, short-stopping the approve button above the visible snap port area during press.

SkillAudit audits scroll-padding-block-start on scroll containers using logical property names, resolving writing-mode to determine which physical edge is inset. Run a free audit on your MCP server.