Security Guide

MCP server CSS scroll-padding-inline-end security — right snap port inset over-scrolls button off left edge, bilateral inline sum collapse, RTL physical-left remap, JS mousedown + scrollIntoView({inline:'end'})

CSS scroll-padding-inline-end insets the inline-end edge of the scroll container's snap port. In horizontal-tb LTR this is the physical right edge — a large value pulls the snap port's end boundary leftward, deep inside the container. When scrollIntoView({inline: 'end'}) fires, the browser aligns the element's inline-end edge to this inset boundary. The browser over-scrolls leftward so the element's right edge meets the inset point: the element's left edge is now to the left of the container's physical left edge. The approve button — positioned at the inline-end of the dialog — ends up off the left edge of the visible area. A right-side inset evicts the button to the left: the opposite direction from what most auditors expect.

CSS scroll-padding-inline-end — property overview

The scroll-padding-inline-end property is a sub-property of scroll-padding-inline and scroll-padding. Applied to a scroll container, it insets only the inline-end edge of the snap port — the physical right edge in horizontal-tb LTR, the physical left edge in RTL, and the physical bottom edge in vertical-rl. Unlike scroll-margin-inline-end (which is placed on the target element), scroll-padding-inline-end is always on the container and affects every snap target inside it. Related: scroll-padding-inline shorthand, scroll-padding-inline-start, scroll-padding-block-end.

Attack 1: large scroll-padding-inline-endscrollIntoView({inline:'end'}) over-scrolls button off left edge

When scrollIntoView({inline: 'end'}) fires, the browser aligns the target element's inline-end edge to the snap port's inline-end boundary — which is container.clientWidth − scroll-padding-inline-end pixels from the container's left edge. With a large scroll-padding-inline-end, this boundary is far to the left, deep inside the container. The browser scrolls left so far that the element's right edge meets this boundary. But the element's left edge is now element.clientWidth pixels to the LEFT of the boundary — entirely off the container's left physical edge. Audits checking buttonBCR.right > containerBCR.right (button off right edge) will see nothing; the button has gone off the left edge.

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

Direction surprise: A right-side snap port inset (scroll-padding-inline-end) causes the approve button to end up off the left visible edge. The browser over-scrolls left to satisfy the end-edge alignment constraint. Audits expecting right-side anomalies only will miss this entirely.

Attack 2: bilateral scroll-padding-inline-start + scroll-padding-inline-end sum collapses snap port

The inline snap port width is container.clientWidth − scroll-padding-inline-start − scroll-padding-inline-end. Setting both sub-threshold individually — e.g., each at 45% of container width — collapses the snap port to 10% of container width. Any element larger than that 10% strip cannot be fully placed within the snap port by scrollIntoView — the browser must pick either start-aligned or end-aligned, and in either case part of the element extends outside the inset snap port into the container's dead zone. The approve button at the far end ends up outside the visible area. Neither value alone triggers a "greater than 50%" threshold alert.

/* Attack: bilateral inline collapse — each value sub-threshold, combined fatal */
.scroll-container {
  scroll-padding-inline-start: 45% !important; /* just under 50% threshold */
  scroll-padding-inline-end: 45% !important;   /* just under 50% threshold */
  /* effective snap port = 10% of container width */
  /* approve button (full dialog width) cannot be fully within snap port */
}
// Detection: bilateral inline sum check
const cs = getComputedStyle(container);
const spis = parseFloat(cs.getPropertyValue('scroll-padding-inline-start')) || 0;
const spie = parseFloat(cs.getPropertyValue('scroll-padding-inline-end')) || 0;
const clientW = container.clientWidth;
if (spis + spie >= clientW * 0.85) {
  // snap port collapsed — less than 15% of container width usable
}

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

In a dir="rtl" scroll container, the inline-end direction is the physical left side. scroll-padding-inline-end: 600px in RTL insets the physical left edge of the snap port, not the right. An audit reading getComputedStyle(container).scrollPaddingRight reads the physical right property, which corresponds to scroll-padding-inline-start in RTL — the wrong logical edge. Using getPropertyValue('scroll-padding-inline-end') returns the correct value regardless of direction. In RTL, this attack causes scrollIntoView({inline:'end'}) to place the button off the physical right edge (the opposite of the LTR case).

/* Attack: RTL remap — scroll-padding-inline-end = physical LEFT in RTL */
/* In RTL, inline-end = physical left */
/* scrollIntoView({inline:'end'}) in RTL aligns button's left edge to inset boundary */
/* button ends up off the RIGHT visible edge in RTL */
.scroll-container[dir="rtl"] {
  scroll-padding-inline-end: 500px !important;
}
// Detection: read the logical property name directly
const spie = parseFloat(
  getComputedStyle(container).getPropertyValue('scroll-padding-inline-end')
) || 0;
const dir = getComputedStyle(container).direction;
// LTR: spie insets physical right → button goes off LEFT edge
// RTL: spie insets physical left → button goes off RIGHT edge

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

A mousedown listener on the approve button injects scroll-padding-inline-end: 100vw on the scroll container and immediately calls scrollIntoView({inline: 'end', behavior: 'instant'}). The snap port end boundary collapses to the container's left edge (or beyond); scrollIntoView over-scrolls so the button's right edge aligns to this collapsed boundary, placing the button entirely off the left edge of the visible area. The click fires on the container background. At mouseup the property is removed and the container scrolls back — no visible evidence of manipulation.

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

Findings summary

High scroll-padding-inline-end large enough to shift snap port end boundary past container's physical left edge — scrollIntoView({inline:'end'}) places approve button entirely off the left visible edge.
High scroll-padding-inline-start + scroll-padding-inline-end bilateral sum ≥ 85% of container clientWidth — snap port collapsed; approve button cannot be fully in view regardless of scroll position.
Medium dir=rtl with non-trivial scroll-padding-inline-end — physical left edge is inset (not right); scrollPaddingRight check reads the wrong logical sub-property and returns zero.
High JS mousedown listener injects scroll-padding-inline-end: 100vw and calls scrollIntoView({inline:'end'}), placing the approve button off the left visible edge at click time.

SkillAudit audits scroll-padding-inline-end with bilateral inline sum checks, direction-aware logical-to-physical mapping, and a capture-phase mousedown sentinel covering the full scroll-padding-inline family. Run a free audit on your MCP server.