Security Guide
MCP server CSS scroll-margin-inline-end security — off-left scroll edge, snap-type: mandatory combo, RTL direction remap, JS mousedown + scrollIntoView injection
CSS scroll-margin-inline-end is the inline-end component of the scroll-margin shorthand. It offsets a scroll-snap target from the inline-end edge of the scroll container's snap port. In horizontal-tb LTR it maps to a physical right scroll margin. When scrollIntoView({inline: 'end'}) is called on an approve button, the browser scrolls so the button's end edge aligns to the snap port's end edge — minus the scroll margin. A large scroll-margin-inline-end shifts the scroll position rightward, placing the approve button far to the left of the visible area even though the snap constraint is satisfied. This is the mirror of scroll-margin-inline-start attacks but produces an off-left-edge result rather than off-right.
CSS scroll-margin-inline-end — property overview
The scroll-margin-inline-end property specifies the scroll margin on the inline-end side of a snap target element. It is a sub-property of scroll-margin-inline (shorthand) and scroll-margin (shorthand). It is applied to the snap target element, not the scroll container. In horizontal-tb LTR, inline-end maps to physical right. In RTL, it maps to physical left. Related: scroll-margin-inline-start, scroll-margin-inline shorthand, scroll-padding-block.
Attack 1: large scroll-margin-inline-end — scrollIntoView stops with button off left edge
When scrollIntoView({inline: 'end'}) is called on a consent approve button, the browser scrolls horizontally so the button's inline-end edge aligns to the scroll port's inline-end edge, offset by scroll-margin-inline-end. A large positive margin shifts the final scroll position rightward by the margin amount. From the scroll container's perspective, the snap point is satisfied, but the approve button itself is far to the left of the visible area. The button's BCR.right < containerBCR.left — it is entirely off the left edge of the scroll port.
/* Attack: large scroll-margin-inline-end — scrollIntoView({inline:'end'}) places button off-left */
.approve-btn {
scroll-margin-inline-end: 2000px !important;
}
/* When called: approveBtn.scrollIntoView({ inline: 'end', block: 'nearest' })
Browser scrolls so: approveBtn.right_edge + 2000px = container.snap_port.right_edge
→ Rearranged: container scrolls right by 2000px beyond what would show the button
→ approveBtn.right = container.right_edge - 2000px
On a 500px-wide scroll container: approveBtn.right = 500 - 2000 = -1500px
→ Button is 1500px off the left edge of the visible scroll port.
BCR check after scrollIntoView:
approveBtn.getBoundingClientRect().right < containerBCR.left → button off-left */
function checkScrollMarginInlineEndOffLeft(scrollEl, approveBtn) {
approveBtn.scrollIntoView({ inline: 'end', block: 'nearest', behavior: 'instant' });
const containerBCR = scrollEl.getBoundingClientRect();
const btnBCR = approveBtn.getBoundingClientRect();
const cs = getComputedStyle(approveBtn);
return {
scrollMarginInlineEnd: parseFloat(cs.getPropertyValue('scroll-margin-inline-end')) || 0,
buttonRight: btnBCR.right,
containerLeft: containerBCR.left,
buttonOffLeft: btnBCR.right < containerBCR.left,
buttonInView: btnBCR.left < containerBCR.right && btnBCR.right > containerBCR.left,
};
}
End-side scroll margin attacks produce off-left results in LTR: unlike start-side attacks (which push the button off-right), a large scroll-margin-inline-end in LTR causes the browser to scroll further right than needed, leaving the button off the left edge. An auditor who only checks BCR.left > containerBCR.right (off-right condition) will miss this attack entirely. Both off-right and off-left conditions must be checked.
Attack 2: scroll-snap-type: inline mandatory + scroll-margin-inline-end — snap prevents user correction
When the scroll container enforces scroll-snap-type: inline mandatory, every horizontal scroll gesture snaps to the nearest snap point. With a large scroll-margin-inline-end on the approve button and scroll-snap-align: end, the snap point satisfies the mandatory constraint while keeping the button off-left. Any user attempt to scroll left to reach the button causes the mandatory snap to pull the container back to the "satisfied" snap position — the button remains off-left and unreachable by user gesture. The only way to reach the button is to have a different snap point to the left of it or no snap constraints at all.
/* Attack: mandatory inline snap + large scroll-margin-inline-end */
.consent-scroll-container {
scroll-snap-type: inline mandatory !important;
overflow-x: scroll !important;
}
.approve-btn {
scroll-snap-align: end !important;
scroll-margin-inline-end: 3000px !important;
}
/* Effect:
Mandatory snap → every horizontal scroll snaps.
Button snap point (inline:end mode): scrollLeft = approveBtn.offsetLeft + approveBtn.offsetWidth + 3000 - containerWidth
At this snap position, button.right is at containerBCR.left → button is off-left by 3000px.
User tries to scroll left: mandatory snap pulls back to snap point.
Button is geometrically unreachable via manual scroll. */
function checkSnapMandatoryEndOffLeft(scrollEl, approveBtn) {
const containerCS = getComputedStyle(scrollEl);
const btnCS = getComputedStyle(approveBtn);
return {
snapType: containerCS.scrollSnapType,
isMandatory: containerCS.scrollSnapType.includes('mandatory'),
scrollMarginIE: parseFloat(btnCS.getPropertyValue('scroll-margin-inline-end')) || 0,
snapAlign: btnCS.scrollSnapAlign,
};
}
Attack 3: dir="rtl" — scroll-margin-inline-end maps to physical left margin
In direction: rtl, the inline-end edge is the physical left edge. scroll-margin-inline-end therefore provides an offset from the physical left side of the snap port. When scrollIntoView({inline: 'end'}) is called in RTL, the browser aligns the element's physical left edge to the scroll port's left edge, offset by the scroll margin. A large scroll-margin-inline-end in RTL pushes the final scroll position so that the element's physical left edge is far to the right — the approve button ends up off the right edge of the visible area. An auditor checking the physical right margin (expecting padding-right behavior in RTL) finds zero and misses the attack.
/* Attack: RTL layout — scroll-margin-inline-end maps to left-side margin (physical) */
[dir="rtl"] .approve-btn,
.approve-btn[dir="rtl"] {
scroll-margin-inline-end: 2000px !important; /* → left-side margin in RTL */
}
/* In RTL, scrollIntoView({inline:'end'}) aligns the element's LEFT edge:
element.left_edge - 2000px = container.snap_port.left_edge
→ element.left_edge = container.snap_port.left_edge + 2000px
On a 500px-wide container, element.left is at 500 + 2000 = 2500px → off-right by 2000px.
getComputedStyle(el).marginRight = "0px" → right margin check misses this.
getPropertyValue('scroll-margin-inline-end') = "2000px" always catches it. */
function checkScrollMarginInlineEndRTL(approveBtn) {
const cs = getComputedStyle(approveBtn);
return {
scrollMarginInlineEnd: parseFloat(cs.getPropertyValue('scroll-margin-inline-end')) || 0,
direction: cs.direction,
rtlMapped: cs.direction === 'rtl',
};
}
Attack 4: JS mousedown injection — scroll-margin-inline-end + scrollIntoView at press time
At page load, the approve button is visible and the consent dialog is within the scroll port. A mousedown listener on the approve button simultaneously injects a large scroll-margin-inline-end and calls scrollIntoView({inline: 'end'}). The browser re-scrolls during the mousedown phase, repositioning the scroll container so the button is off the left edge before the click event fires. The pointer lands at the button's former coordinates — on whatever element is now at that position. At mouseup, the scroll margin is removed and the dialog scrolls back. The static page state is clean; only style mutation monitoring during pointer events reveals the injection.
/* Mousedown: inject scroll-margin-inline-end + trigger scrollIntoView during press */
(function () {
const btn = document.querySelector('.approve-btn, [data-action="allow"]');
if (!btn) return;
btn.addEventListener('mousedown', () => {
btn.style.setProperty('scroll-margin-inline-end', '3000px', 'important');
btn.scrollIntoView({ inline: 'end', block: 'nearest', behavior: 'instant' });
}, { passive: true });
btn.addEventListener('mouseup', () => {
btn.style.removeProperty('scroll-margin-inline-end');
}, { passive: true });
btn.addEventListener('mouseleave', () => {
btn.style.removeProperty('scroll-margin-inline-end');
}, { passive: true });
})();
Check both off-left and off-right conditions after any scrollIntoView call: scroll-margin-inline-start attacks produce off-right results in LTR; scroll-margin-inline-end attacks produce off-left results in LTR. A complete audit checks BCR.right < containerBCR.left (off-left) AND BCR.left > containerBCR.right (off-right) regardless of which scroll margin sub-property is set.
Detection summary
scrollIntoView({inline:'end'}), approve button BCR right < container BCR left — button is off the left scroll edge; scroll-margin-inline-end offset caused the container to over-scroll right.
scroll-margin-inline-end on approve button — snap constraint satisfied but button off-left, and user scroll gestures are pulled back by mandatory snap.
scroll-margin-inline-end > scroll container width — any scrollIntoView({inline:'end'}) will place the button off-left regardless of initial scroll position.
scroll-margin-inline-end maps to physical left-side margin — in RTL, off-right rather than off-left; post-scroll BCR must check against container right edge.
scroll-margin-inline-end and calls scrollIntoView — transient scroll repositioning during press not detectable at page-load audit time.
/* Complete scroll-margin-inline-end consent audit */
function auditScrollMarginInlineEnd(scrollEl, approveBtn) {
const cs = getComputedStyle(approveBtn);
const smie = parseFloat(cs.getPropertyValue('scroll-margin-inline-end')) || 0;
const containerCS = getComputedStyle(scrollEl);
// Post-scroll BCR check using inline:end alignment
approveBtn.scrollIntoView({ inline: 'end', block: 'nearest', behavior: 'instant' });
const containerBCR = scrollEl.getBoundingClientRect();
const btnBCR = approveBtn.getBoundingClientRect();
return {
scrollMarginInlineEnd: smie,
direction: cs.direction,
snapType: containerCS.scrollSnapType,
isMandatory: containerCS.scrollSnapType.includes('mandatory'),
buttonLeft: btnBCR.left,
buttonRight: btnBCR.right,
containerLeft: containerBCR.left,
containerRight: containerBCR.right,
buttonOffLeft: btnBCR.right < containerBCR.left,
buttonOffRight: btnBCR.left > containerBCR.right,
buttonInScrollPort:
btnBCR.left < containerBCR.right && btnBCR.right > containerBCR.left &&
btnBCR.top < containerBCR.bottom && btnBCR.bottom > containerBCR.top,
};
}
SkillAudit checks scroll-margin-inline-end by reading the logical property directly (catching RTL direction remaps), calling scrollIntoView({inline:'end'}) programmatically, and comparing the approve button's post-scroll BCR against both the left and right bounds of the scroll container's visible area. Mandatory snap constraints that lock the button off-screen are flagged as compounding factors. Run a free audit →