Security Guide

MCP server CSS scroll-padding-inline security — inline snap port inset, 100vw port collapse, RTL direction remap, JS mousedown + scrollIntoView injection

CSS scroll-padding-inline is the inline-axis shorthand for scroll-padding-inline-start and scroll-padding-inline-end. Applied to the scroll container, it simultaneously insets the snap port from the inline-start and inline-end edges, shrinking the effective horizontal snap target area. In horizontal-tb LTR this controls left and right snap port insets. When a consent dialog's approve button is near the trailing inline edge, scrollIntoView({inline: 'nearest'}) satisfies the snap constraint by placing the button at the inset boundary — but the button is outside the actual visible scrollport area, unreachable without horizontal scrolling the user has no reason to perform.

CSS scroll-padding-inline — property overview

The scroll-padding-inline property is applied to the scroll container (not the snap target). One value sets both inline-start and inline-end insets equally; two values set inline-start then inline-end. In horizontal-tb LTR the inline axis is horizontal — the property controls left and right snap port insets. In RTL the same property still controls the logical inline axis, but the mapping reverses: a large scroll-padding-inline-start in RTL insets the right edge (physical) of the snap port rather than the left. Related: scroll-padding-inline-start, scroll-padding-block, scroll-padding shorthand.

Attack 1: large scroll-padding-inlinescrollIntoView stops button off horizontal edge

When scrollIntoView({inline: 'nearest'}) or scrollIntoView({inline: 'start'}) is called, the browser positions the scroll container so the target element crosses the inline-axis inset boundary. A large scroll-padding-inline value shrinks the visible snap port from both horizontal edges. The approve button satisfies the snap constraint — it has crossed the inset boundary — but it is positioned to the right of the right-side inset boundary, in the "dead zone" between the inset boundary and the actual scrollport right edge. The button is not visible in the viewport and is not interactable.

/* Attack: large scroll-padding-inline — scrollIntoView lands button off horizontal edge */
.scroll-container {
  overflow-x: scroll !important;
  scroll-padding-inline: 500px !important;
  /* effective snap port width = container.clientWidth - 1000px */
  /* approve button placed 600px right of container left edge */
  /* scrollIntoView satisfies at the inset boundary — button is 100px right of visible area */
}

Container-level property: scroll-padding-inline is set on the container, not the approve button. Checking the button's BCR against the viewport produces plausible values. Detection requires checking the container's scroll-padding-inline against the container's clientWidth and then verifying the button's BCR relative to the inset snap port — not the raw viewport.

Attack 2: scroll-padding-inline: 100vw — snap port collapses to zero width

A 100vw value means each inline edge is inset by the full viewport width, collapsing the effective snap port to zero width. Any scrollIntoView call that uses the inline axis satisfies the snap constraint instantly (the inset boundary is never within the visible area), but the approve button is always positioned outside the visible scrollport. This attack is viewport-size-independent — at every viewport width, the inline snap port is eliminated.

/* Attack: 100vw bilateral inline inset — snap port width = 0 at any viewport width */
.scroll-container {
  scroll-padding-inline: 100vw !important;
}
// Detection: compare scroll-padding-inline sum against viewport width
const cs = getComputedStyle(container);
const spi = parseFloat(cs.getPropertyValue('scroll-padding-inline')) || 0;
const spis = parseFloat(cs.getPropertyValue('scroll-padding-inline-start')) || 0;
const spie = parseFloat(cs.getPropertyValue('scroll-padding-inline-end')) || 0;
const vw = window.innerWidth;

// Single-value shorthand sets both edges equally; bilateral sum = 2 * spi
const bilateralSum = (spi > 0) ? spi * 2 : (spis + spie);
if (bilateralSum >= vw) {
  // inline snap port collapses to zero — scrollIntoView cannot land in visible area
}

Attack 3: RTL direction remap — inline-start and inline-end swap physical sides

In dir="rtl", the inline-start edge is the physical right edge and the inline-end edge is the physical left. A scroll-padding-inline shorthand still sets both edges equally, so the direction attribute does not affect a bilateral symmetric attack. But when using the sub-properties individually, an audit that checks getComputedStyle(container).scrollPaddingLeft will read the inline-end value in RTL (because physical left = logical inline-end in RTL) — the physical property name corresponds to the wrong logical sub-property. An attacker exploiting this mismatch can inject scroll-padding-inline-start: 600px in an RTL document and any audit checking the physical left padding will find scroll-padding-inline-end (which is the right padding in RTL, i.e., the small value).

/* Attack: RTL direction remap — physical right = inline-start in RTL */
/* scroll-padding-inline-start: 600px in RTL insets the physical right edge */
/* getComputedStyle().scrollPaddingLeft reads inline-end (physical left in RTL) = 0 */
.scroll-container[dir="rtl"] {
  scroll-padding-inline-start: 600px !important;
}
// Detection: always read logical property names on the container
const spis = parseFloat(cs.getPropertyValue('scroll-padding-inline-start')) || 0;
const spie = parseFloat(cs.getPropertyValue('scroll-padding-inline-end')) || 0;
// do NOT use scrollPaddingLeft / scrollPaddingRight — these are physical, direction-unaware

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

A mousedown listener on the approve button injects scroll-padding-inline: 100vw on the scroll container and calls scrollIntoView({inline: 'nearest', behavior: 'instant'}). The scroll position snaps so the button is at the inline inset boundary — off the horizontal visible edge — at the moment of click. The click fires on the scroll container's background and the consent is not recorded. At mouseup the injection is reversed.

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

Findings summary

High scroll-padding-inline bilateral sum ≥ 50% of container clientWidth — inline snap port is critically inset, scrollIntoView lands button outside visible horizontal area.
High scroll-padding-inline = 100vw (or bilateral sum ≥ viewport width) — inline snap port collapses to zero, approve button cannot be scrolled into view at any viewport width.
Medium RTL direction on scroll container with non-trivial scroll-padding-inline-start — physical-left/right scroll-padding checks map to wrong logical sub-properties.
High JS mousedown listener injects scroll-padding-inline and calls scrollIntoView on the container, repositioning the approve button off the horizontal visible edge during press.

SkillAudit audits scroll-padding-inline on scroll containers using logical property names and direction-aware axis mapping. Run a free audit on your MCP server.