Security Guide
MCP server CSS scroll-margin-inline security — horizontal snap short-stop, off-right-edge push, off-left pull, scroll-snap-type:inline mandatory trap, JS mousedown scroll injection
CSS scroll-margin-inline is the shorthand for scroll-margin-inline-start and scroll-margin-inline-end. It expands the snap area of an element along the horizontal (inline) scroll axis. A large scroll-margin-inline-start causes scrollIntoView() to stop its horizontal scroll hundreds of pixels before the approve button — the scroll-snap target is considered "in view" from the snap engine's perspective, but the button is still off to the right and invisible. Combined with scroll-snap-type: inline mandatory on the scroll container, this stop position cannot be manually overridden by additional scrolling.
CSS scroll-margin-inline — property overview
The scroll-margin-inline shorthand sets both scroll-margin-inline-start (inline-start edge margin) and scroll-margin-inline-end (inline-end edge margin). These extend the snap area on the horizontal axis. When a scroll container snaps to an element, the snap engine treats the element's snap area as its BCR expanded by the scroll-margin values. A large scroll-margin-inline-start tells the snap engine to place the element's snap area's start edge at the container's scroll-snap-align point — which means the element itself is shifted to the right by the margin value, potentially off screen. Related: scroll-margin shorthand, scroll-margin-block.
Attack 1: large scroll-margin-inline-start — inline-axis snap short-stop leaves button off right edge
When scrollIntoView({ inline: 'start' }) is called on the approve button inside a horizontally-scrollable container, the scroll engine aligns the button's snap area start edge with the container's left edge. A large scroll-margin-inline-start pushes the snap area's start edge far to the left of the button itself — the scroll engine stops scrolling when the snap area's left edge is at the container's left edge, but the button's actual left edge is still 600px+ to the right of the visible area. The button passes all property checks and is in the DOM, but is physically off-screen to the right.
/* Attack: approve button has large scroll-margin-inline-start */
.approve-btn {
scroll-margin-inline-start: 700px; /* snap short-stop 700px before the button */
scroll-snap-align: start;
}
.consent-scroll-container {
overflow-x: auto;
scroll-snap-type: inline mandatory;
}
/* Effect:
scrollIntoView({ inline: 'start' }) on the button:
Snap engine targets: [button_left - 700px] aligned to container_left
→ horizontal scroll stops 700px before reaching the button
→ BCR.left of the button = container_left + 700px = off right edge if container < 700px wide
Post-scroll BCR check is required:
approveBtn.getBoundingClientRect().left < window.innerWidth → button in view
approveBtn.getBoundingClientRect().left >= window.innerWidth → short-stop attack */
async function checkInlineScrollSnapShortStop(approveBtn) {
const scrollParent = approveBtn.closest('[style*="overflow"]') ||
approveBtn.closest('div');
approveBtn.scrollIntoView({ behavior: 'instant', inline: 'start' });
await new Promise(r => requestAnimationFrame(r));
const bcr = approveBtn.getBoundingClientRect();
const smis = parseFloat(
getComputedStyle(approveBtn).getPropertyValue('scroll-margin-inline-start')
) || 0;
return {
inlineShortStop: bcr.left >= window.innerWidth,
scrollMarginInlineStart: smis,
bcrLeft: bcr.left,
};
}
Inline snap short-stop passes all DOM and visibility checks. The button is not hidden, has normal display, and is in the DOM. Only a post-scroll BCR check — verifying BCR.left < window.innerWidth after scrollIntoView — catches that the button is still horizontally off-screen.
Attack 2: negative scroll-margin-inline-start — pulling the button off the left edge
A negative scroll-margin-inline-start contracts the snap area — the snap engine now aligns the snap area's start edge (which is to the right of the element's actual left edge) with the container's scroll-start. The element's actual left edge is scrolled past the container's left, into the negative scroll offset. In a horizontal scroll container where the left edge of the container is the viewport's left, the button is pushed off the left side — the BCR left is negative and the button is partially or fully off-screen to the left.
/* Attack: negative scroll-margin-inline-start snaps past the container left */
.approve-btn {
scroll-margin-inline-start: -200px; /* contracts snap area 200px inside the button's left */
scroll-snap-align: start;
}
/* Effect:
scrollIntoView({ inline: 'start' }):
Snap target = button_left - (-200px) = button_left + 200px at container_left
→ 200px of the button's width is scrolled past the container left → off left */
async function checkNegativeScrollMarginInline(approveBtn) {
approveBtn.scrollIntoView({ behavior: 'instant', inline: 'start' });
await new Promise(r => requestAnimationFrame(r));
const bcr = approveBtn.getBoundingClientRect();
const smis = parseFloat(
getComputedStyle(approveBtn).getPropertyValue('scroll-margin-inline-start')
) || 0;
return {
offLeft: bcr.right <= 0,
partiallyOffLeft: bcr.left < 0,
scrollMarginInlineStart: smis,
negative: smis < 0,
};
}
Attack 3: scroll-snap-type: inline mandatory lock-in — scroll cannot be overridden
Without scroll-snap-type: inline mandatory, a user who notices the approve button is off-screen can continue scrolling manually to bring it into view. With mandatory snapping, the container's scroll position is locked to snap points — any manual scroll attempt from the short-stop snap position re-snaps to the nearest valid snap point, which is the button's snap area (not the button itself). The user is trapped at the short-stop position and cannot reach the button by scrolling.
/* Attack: mandatory inline snap prevents user override of short-stop */
.consent-scroll-container {
overflow-x: auto;
scroll-snap-type: inline mandatory; /* mandatory: no manual scroll past snap points */
}
.approve-btn {
scroll-margin-inline-start: 600px; /* short-stop 600px before the button */
scroll-snap-align: start;
}
/* After scrollIntoView() short-stops:
User tries to scroll right to reach the button.
scroll-snap-type:mandatory re-snaps to the current snap point (the short-stop position).
The user cannot scroll past the snap point.
The approve button remains permanently off-screen.
Detection: check the scroll container for scroll-snap-type:inline mandatory
AND large scroll-margin-inline-start on the approve button. */
function checkMandatoryInlineSnap(consentEl) {
const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
const container = consentEl.closest('[style*="overflow"]') || consentEl;
if (!approveBtn) return { found: false };
const snapType = getComputedStyle(container).getPropertyValue('scroll-snap-type') || '';
const smis = parseFloat(getComputedStyle(approveBtn).getPropertyValue('scroll-margin-inline-start')) || 0;
return {
mandatoryInlineSnap: /inline\s+mandatory/.test(snapType),
scrollMarginInlineStart: smis,
lockInRisk: /mandatory/.test(snapType) && smis > 100,
};
}
Mandatory scroll-snap combined with large scroll-margin-inline creates an unescapable horizontal trap. The user cannot reach the approve button by scrolling — snap re-captures the scroll position. Only a programmatic scrollTo() call or a different scroll container can break the trap.
Attack 4: JS mousedown injection — horizontal scroll at click time
At page load, the approve button is correctly visible. A mousedown listener on the button injects a large scroll-margin-inline-start and calls scrollIntoView({ inline: 'start' }) simultaneously. The horizontal short-stop repositions the button off-screen during the press. The user's pointer was aimed at the button's original position, which is now occupied by different content. The click fires on that content instead.
/* Mousedown: inject scroll-margin-inline and scrollIntoView at click time */
(function () {
document.querySelectorAll('.approve-btn, [data-action="allow"]').forEach(btn => {
btn.addEventListener('mousedown', () => {
btn.style.setProperty('scroll-margin-inline-start', '800px', 'important');
btn.scrollIntoView({ behavior: 'instant', inline: 'start' });
}, { 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 });
});
})();
Mousedown scroll-margin injection requires a MutationObserver on the approve button's style attribute. When scroll-margin-inline-start or scroll-margin-inline-end changes during an active pointer event, re-check BCR after a requestAnimationFrame delay. A transient off-screen result during mousedown is HIGH severity.
Detection summary
window.innerWidth after scrollIntoView({ inline: 'start' }) — inline snap short-stop; button remains off-screen to the right.
scrollIntoView — negative scroll-margin-inline-start snapped the button off the left edge.
scroll-snap-type: inline mandatory AND approve button has scroll-margin-inline-start > 100px — user cannot manually scroll past the short-stop position.
scroll-margin-inline-start or scroll-margin-inline-end on approve button (>200px) — inline short-stop risk on smaller viewports or narrower scroll containers.
scroll-margin-inline and calls scrollIntoView — transient horizontal repositioning attack not visible at page-load audit time.
/* Complete scroll-margin-inline consent audit */
async function auditScrollMarginInline(consentEl) {
const approveBtn = consentEl.querySelector('[data-action="allow"], .approve-btn, button[type="submit"]');
if (!approveBtn) return { found: false };
const cs = getComputedStyle(approveBtn);
const smis = parseFloat(cs.getPropertyValue('scroll-margin-inline-start')) || 0;
const smie = parseFloat(cs.getPropertyValue('scroll-margin-inline-end')) || 0;
approveBtn.scrollIntoView({ behavior: 'instant', inline: 'start' });
await new Promise(r => requestAnimationFrame(r));
const bcr = approveBtn.getBoundingClientRect();
const vw = window.innerWidth;
const container = consentEl.closest('[style*="overflow"]') || consentEl;
const snapType = getComputedStyle(container).getPropertyValue('scroll-snap-type') || '';
return {
scrollMarginInlineStart: smis,
scrollMarginInlineEnd: smie,
bcrLeft: bcr.left,
offRight: bcr.left >= vw,
offLeft: bcr.right <= 0,
mandatoryInlineSnap: /inline\s+mandatory/.test(snapType),
lockInRisk: /mandatory/.test(snapType) && smis > 100,
};
}
SkillAudit checks scroll-margin-inline by calling scrollIntoView on the approve button with inline:'start' alignment and verifying the post-scroll BCR, checking the scroll container for mandatory inline snap, and monitoring mousedown events for style injection on the button's scroll-margin properties. Run a free audit →