Security Guide
MCP server CSS scroll-margin-inline-start security — off-right scroll edge, snap-type: mandatory combo, RTL direction remap, JS mousedown + scrollIntoView injection
CSS scroll-margin-inline-start is the inline-start component of the scroll-margin shorthand. It offsets a scroll-snap target from the inline-start edge of the scroll container's snap port. In horizontal-tb LTR it maps to a physical left scroll margin. When scrollIntoView({inline: 'start'}) is called on a consent dialog, the browser scrolls so that the dialog's start edge aligns to the snap port's start edge — minus the scroll margin. A large scroll-margin-inline-start shifts the scroll position leftward, leaving the approve button positioned off the right side of the visible scroll port even though the snap constraint reports the dialog is "in view."
CSS scroll-margin-inline-start — property overview
The scroll-margin-inline-start property specifies the scroll margin on the inline-start side of a snap target element. It is a sub-property of scroll-margin-inline (shorthand) and scroll-margin (shorthand). Unlike scroll-padding (which is applied to the scroll container), scroll-margin properties are applied to the snap target element itself. In horizontal-tb LTR, inline-start maps to physical left. In RTL, it maps to physical right. Related: scroll-margin-inline shorthand, scroll-margin-inline-end, scroll-padding-block.
Attack 1: large scroll-margin-inline-start — scrollIntoView stops with button off right edge
When scrollIntoView({inline: 'start'}) is called, the browser positions the scroll container so the element's inline-start edge aligns to the snap port's start edge, accounting for scroll-margin-inline-start. A large positive margin shifts the final scroll position leftward by the margin amount — the snap is "satisfied" (the element's logical start edge is at the right place), but the element's actual rendered content (including the approve button) is positioned far to the right of the snap port's visible area. The approve button is off-screen to the right; BCR.left > containerBCR.right.
/* Attack: large scroll-margin-inline-start on approve button — scrollIntoView places it off-right */
.approve-btn {
scroll-margin-inline-start: 2000px !important;
}
/* When called: approveBtn.scrollIntoView({ inline: 'start', block: 'nearest' })
Browser scrolls so: approveBtn.left_edge - 2000px = container.snap_port.left_edge
→ approveBtn.left_edge = container.snap_port.left_edge + 2000px
On a 400px-wide scroll container, button is 2000px to the right of the visible area.
BCR check after scrollIntoView:
approveBtn.getBoundingClientRect().left > containerBCR.right → button is off-right */
function checkScrollMarginInlineStartOffRight(scrollEl, approveBtn) {
approveBtn.scrollIntoView({ inline: 'start', block: 'nearest', behavior: 'instant' });
const containerBCR = scrollEl.getBoundingClientRect();
const btnBCR = approveBtn.getBoundingClientRect();
const cs = getComputedStyle(approveBtn);
return {
scrollMarginInlineStart: parseFloat(cs.getPropertyValue('scroll-margin-inline-start')) || 0,
buttonLeft: btnBCR.left,
containerRight: containerBCR.right,
buttonOffRight: btnBCR.left > containerBCR.right,
buttonInView: btnBCR.left < containerBCR.right && btnBCR.right > containerBCR.left,
};
}
The snap constraint reports "satisfied" even when the approve button is off-screen: from the browser's perspective, the snap target's start edge is correctly aligned to the snap port. Auditors checking whether scrollIntoView completed without error, or checking the dialog container's BCR, may conclude the dialog is visible when the approve button is in fact off the right edge of the scroll port.
Attack 2: scroll-snap-type: inline mandatory + scroll-margin-inline-start — snap prevents user correction
When the scroll container uses scroll-snap-type: inline mandatory, the browser enforces snap positions — the user cannot freely scroll to an arbitrary horizontal position. Instead, every scroll gesture snaps to the nearest snap point. An attacker who sets a large scroll-margin-inline-start on the approve button and scroll-snap-align: start on the same button creates a snap point that satisfies the mandatory snap constraint while keeping the button off-right. When the user tries to manually scroll right to find the button, the snap mechanism pulls the scroll container back to the "satisfied" (but off-right) snap position. The button is effectively unreachable by any user gesture.
/* Attack: mandatory inline snap + large scroll-margin-inline-start */
.consent-scroll-container {
scroll-snap-type: inline mandatory !important;
overflow-x: scroll !important;
}
.approve-btn {
scroll-snap-align: start !important;
scroll-margin-inline-start: 3000px !important;
}
/* Effect:
Mandatory snap → every horizontal scroll gesture snaps to nearest snap point.
The approve button's snap point is at (button.left - 3000px).
The snap is satisfied when the scroll container's left edge = button.left - 3000px.
At this position, button.left is at scrollPort.left + 3000px → off-right by 3000px.
Any user scroll right is countered by mandatory snap pulling back to the snap point.
The button is geometrically unreachable via manual scroll. */
function checkSnapMandatoryOffRight(scrollEl, approveBtn) {
const cs = getComputedStyle(scrollEl);
return {
snapType: cs.scrollSnapType,
isMandatory: cs.scrollSnapType.includes('mandatory'),
isInlineSnap: cs.scrollSnapType.includes('inline') || cs.scrollSnapType.includes('x') || cs.scrollSnapType.includes('both'),
scrollMarginIS: parseFloat(getComputedStyle(approveBtn).getPropertyValue('scroll-margin-inline-start')) || 0,
snapAlign: getComputedStyle(approveBtn).scrollSnapAlign,
};
}
Attack 3: dir="rtl" — scroll-margin-inline-start maps to physical right margin
In direction: rtl, the inline-start edge is the physical right edge. scroll-margin-inline-start therefore provides an offset from the physical right side of the scroll container. When scrollIntoView({inline: 'start'}) is called in an RTL context, the browser aligns the element's physical right edge to the scroll port's right edge, offsetting by the scroll margin. A large scroll-margin-inline-start in RTL pushes the final scroll position so that the element's physical right edge is far to the left of the scroll port's right edge — the element and its approve button are off the left edge of the visible area. An auditor checking the physical left margin (expecting padding-left behavior) finds zero and misses the attack.
/* Attack: RTL layout — scroll-margin-inline-start maps to right-side scroll margin */
/* Applied to dialog in dir="rtl" container */
.consent-dialog[dir="rtl"],
[dir="rtl"] .consent-dialog {
scroll-margin-inline-start: 2000px !important; /* → right-side margin in RTL */
}
/* In RTL, scrollIntoView({inline:'start'}) aligns the element's RIGHT edge:
element.right_edge + 2000px = container.right_edge + scrollPort.width
→ element.right_edge = container.right_edge + scrollPort.width - 2000px
On a 500px-wide container, element.right is at 500 - 2000 = -1500px
→ element is entirely off the left edge of the visible scroll port.
Auditors checking getComputedStyle(el).marginLeft = "0px" find nothing.
getPropertyValue('scroll-margin-inline-start') = "2000px" always catches it. */
function checkScrollMarginInlineStartRTL(approveBtn) {
const cs = getComputedStyle(approveBtn);
return {
scrollMarginInlineStart: parseFloat(cs.getPropertyValue('scroll-margin-inline-start')) || 0,
direction: cs.direction,
rtlMapped: cs.direction === 'rtl',
};
}
Attack 4: JS mousedown injection — scroll-margin-inline-start + scrollIntoView at press time
At page load, the consent dialog has a normal scroll position and the approve button is visible. A mousedown listener on the approve button both injects a large scroll-margin-inline-start on the approve button and calls scrollIntoView({inline: 'start'}) immediately. The browser re-scrolls during the mousedown phase, repositioning the scroll container so the button is off the right (or left in RTL) edge before the click fires. The pointer, still at the button's original viewport coordinates, lands on whatever element is now at that position. At mouseup, the scroll margin is removed and the dialog scrolls back to its normal position.
/* Mousedown: inject scroll-margin-inline-start + 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-start', '3000px', 'important');
btn.scrollIntoView({ inline: 'start', block: 'nearest', behavior: 'instant' });
}, { passive: true });
btn.addEventListener('mouseup', () => {
btn.style.removeProperty('scroll-margin-inline-start');
}, { passive: true });
btn.addEventListener('mouseleave', () => {
btn.style.removeProperty('scroll-margin-inline-start');
}, { passive: true });
})();
Post-scroll BCR verification is essential: any consent dialog that calls scrollIntoView programmatically should be followed by a BCR check on the approve button relative to the scroll container's visible bounds. A scroll that satisfies snap constraints while leaving the approve button off-screen is indistinguishable from a correct scroll without the post-scroll BCR check.
Detection summary
scrollIntoView({inline:'start'}), approve button BCR left > container BCR right — button is off the right scroll edge; scroll-margin-inline-start offset exceeds the visible scroll port width.
scroll-margin-inline-start on approve button — snap constraint is satisfied but button is off-screen, and user scroll gestures are countered by the mandatory snap.
scroll-margin-inline-start > scroll container width — any scrollIntoView call in inline-start mode will place the button off-screen regardless of initial scroll position.
scroll-margin-inline-start > 0 — maps to physical right-side margin; post-scroll BCR must check against container left edge, not right edge.
scroll-margin-inline-start and calls scrollIntoView — transient scroll repositioning during press not detectable at page-load audit time.
/* Complete scroll-margin-inline-start consent audit */
function auditScrollMarginInlineStart(scrollEl, approveBtn) {
const cs = getComputedStyle(approveBtn);
const smis = parseFloat(cs.getPropertyValue('scroll-margin-inline-start')) || 0;
const containerCS = getComputedStyle(scrollEl);
// Post-scroll BCR check
approveBtn.scrollIntoView({ inline: 'start', block: 'nearest', behavior: 'instant' });
const containerBCR = scrollEl.getBoundingClientRect();
const btnBCR = approveBtn.getBoundingClientRect();
return {
scrollMarginInlineStart: smis,
direction: cs.direction,
snapType: containerCS.scrollSnapType,
isMandatory: containerCS.scrollSnapType.includes('mandatory'),
buttonLeft: btnBCR.left,
buttonRight: btnBCR.right,
containerLeft: containerBCR.left,
containerRight: containerBCR.right,
buttonOffRight: btnBCR.left > containerBCR.right,
buttonOffLeft: btnBCR.right < containerBCR.left,
buttonInScrollPort:
btnBCR.left < containerBCR.right && btnBCR.right > containerBCR.left &&
btnBCR.top < containerBCR.bottom && btnBCR.bottom > containerBCR.top,
};
}
SkillAudit checks scroll-margin-inline-start by reading the logical property directly (catching RTL direction remaps), calling scrollIntoView programmatically, and comparing the approve button's post-scroll BCR against the scroll container's visible bounds. Mandatory snap constraints that prevent user correction are flagged as a compounding factor. Run a free audit →