Security Guide

MCP server CSS scroll-padding-inline-start security — left snap port inset short-stop, scroll-snap-type: mandatory lock, RTL physical-right remap, JS mousedown + scrollIntoView

CSS scroll-padding-inline-start insets the inline-start edge of the scroll container's snap port. In horizontal-tb LTR this is the left edge — a large value pushes the snap port's start boundary rightward, shrinking the usable horizontal snap region from the left. When scrollIntoView({inline: 'start'}) fires, the browser aligns the target element's inline-start edge to this inset boundary. The element's start edge is satisfied at the inset point, but the element's actual rendered content — including the approve button — is positioned to the right of the inset boundary and outside the visible scrollport. The button is geometrically off the right edge, unreachable without horizontal scrolling the user has no reason to perform.

CSS scroll-padding-inline-start — property overview

The scroll-padding-inline-start property is a sub-property of scroll-padding-inline and scroll-padding. It is applied to the scroll container and insets only the inline-start edge of the snap port. In horizontal-tb LTR, inline-start is the left edge. In RTL, inline-start is the physical right edge. In vertical-rl, inline-start is the physical top edge. Unlike scroll-margin-inline-start (which is on the snap target), scroll-padding-inline-start is always on the container. Related: scroll-padding-inline shorthand, scroll-margin-inline-start, scroll-padding-block-start.

Attack 1: large scroll-padding-inline-startscrollIntoView stops with button right of visible edge

When scrollIntoView({inline: 'start'}) fires, the browser scrolls the container so the element's inline-start edge aligns to the snap port's inline-start boundary, which is at scroll-padding-inline-start pixels from the container's physical left edge. A large scroll-padding-inline-start shifts this boundary rightward — far inside the container's visible area. The browser stops scrolling when the element's start edge reaches this boundary. But the element's visible content (the consent text and approve button) extends rightward from the start edge, ending at the element's right side, which is now element.clientWidth pixels to the right of the inset boundary. If the inset is large, the button is positioned well past the container's physical right edge — the button is off-screen to the right while the snap constraint reports the element as "in view."

/* Attack: large scroll-padding-inline-start — button ends up right of visible edge */
.scroll-container {
  overflow-x: scroll !important;
  scroll-padding-inline-start: 600px !important;
  /* snap port inline-start boundary = 600px from container's left edge */
  /* scrollIntoView({inline:'start'}) aligns button's left edge to 600px mark */
  /* button's right edge = 600px + button.clientWidth → off right edge of viewport */
}

Counterintuitive direction: A left-side snap port inset causes the button to end up off the right visible edge. The browser scrolls leftward to align the element's start edge to the inset boundary — overshooting the right side in the process. Audits expecting a left inset to push buttons left will miss this.

Attack 2: scroll-snap-type: inline mandatory combo — snap lock prevents user correction

Combining scroll-padding-inline-start: 600px with scroll-snap-type: inline mandatory and scroll-snap-align: start on the approve button creates a snap lock. The browser snaps the scroll position so the button's start edge aligns to the inset boundary — off the right visible edge — and then prevents the user from manually scrolling to the button's actual position. Any user swipe or wheel gesture is countered by the mandatory snap pull-back, snapping the container back to the "in view" position where the button is off-screen. The button is geometrically unreachable.

/* Attack: snap lock — button locked off right edge by mandatory snap */
.scroll-container {
  scroll-padding-inline-start: 600px !important;
  scroll-snap-type: inline mandatory !important;
}
.approve-btn {
  scroll-snap-align: start !important;
}
// Detection: check for both large scroll-padding-inline-start AND scroll-snap-type: mandatory
const spis = parseFloat(cs.getPropertyValue('scroll-padding-inline-start')) || 0;
const snapType = cs.getPropertyValue('scroll-snap-type');
const snapMandatory = snapType.includes('mandatory');
if (spis > window.innerWidth * 0.4 && snapMandatory) {
  // snap lock: button is off right edge and user cannot scroll there
}

Attack 3: dir="rtl" remap — inline-start maps to physical right in RTL

In a dir="rtl" scroll container, the inline-start direction is the physical right side. scroll-padding-inline-start: 600px in RTL insets the physical right edge of the snap port, not the left. An audit checking getComputedStyle(container).scrollPaddingLeft reads the physical left property — which corresponds to scroll-padding-inline-end in RTL, not scroll-padding-inline-start. The physical-right audit detects the attack correctly in RTL; the physical-left audit misses it entirely. Using the logical property name (scroll-padding-inline-start) via getPropertyValue is always correct regardless of direction.

/* Attack: RTL remap — scroll-padding-inline-start insets physical right in RTL */
/* In RTL, inline-start = physical right */
/* scrollIntoView({inline:'start'}) in RTL aligns button's right edge to inset boundary */
/* button ends up off the LEFT visible edge (not right) in RTL */
.scroll-container[dir="rtl"] {
  scroll-padding-inline-start: 500px !important;
}
// Detection: always read logical property name
const spis = parseFloat(
  getComputedStyle(container).getPropertyValue('scroll-padding-inline-start')
) || 0;
const dir = getComputedStyle(container).direction;
// In LTR: spis insets physical left, button ends up off right edge
// In RTL: spis insets physical right, button ends up off left edge
// BCR check: buttonBCR.right <= containerBCR.left (RTL) or buttonBCR.left >= containerBCR.right (LTR)

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

A mousedown listener on the approve button injects scroll-padding-inline-start: 100vw on the container and calls scrollIntoView({inline: 'start', behavior: 'instant'}) during press. The scroll container snaps so the button's start edge aligns to the fully inset boundary — the button is off the right edge — at click time. The click fires on the container background; consent is not recorded. At mouseup the injection is reversed and the button scrolls back into view with no visible evidence of the attack.

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

Findings summary

High scroll-padding-inline-start ≥ 40% of container clientWidth — snap port inline-start inset causes scrollIntoView to stop with approve button off the opposite (right) visible edge.
High scroll-padding-inline-start > 40% of clientWidth combined with scroll-snap-type: inline mandatory on the container — mandatory snap locks the button off-screen, user scroll correction is blocked.
Medium dir=rtl on scroll container with non-trivial scroll-padding-inline-start — physical-right edge is inset (not left); scrollPaddingLeft check reads wrong logical sub-property.
High JS mousedown listener injects scroll-padding-inline-start and calls scrollIntoView({inline:'start'}), placing the approve button off the horizontal visible edge during press.

SkillAudit audits scroll-padding-inline-start on scroll containers using logical property names, checks for mandatory snap combos, and resolves direction before mapping to physical edges. Run a free audit on your MCP server.