Security Guide
MCP server CSS scroll-padding-block security — block-axis snap port inset, 100vh fold guarantee, writing-mode remap, JS mousedown + scrollIntoView injection
CSS scroll-padding-block is the block-axis shorthand for scroll-padding-block-start and scroll-padding-block-end. It insets the scroll container's snap port from both block-axis edges simultaneously, reducing the effective visible rectangle that scrollIntoView targets. In horizontal-tb this controls the top and bottom insets of the snap port. When a consent dialog's approve button is placed near the block-end edge of its content, a large scroll-padding-block value causes scrollIntoView to stop with the button above the inset boundary — technically snapped but visually outside the inset snap port and not interactable without scrolling the user would not know to perform.
CSS scroll-padding-block — property overview
The scroll-padding-block property is a shorthand applied to the scroll container (not the snap target element). It insets both the block-start and block-end edges of the scroll container's optimal viewing region (snap port). One value sets both insets equally; two values set block-start then block-end separately. Under horizontal-tb, the block axis is the vertical axis — the property controls top and bottom snap port insets. Under vertical-rl or vertical-lr, the block axis is horizontal — the property controls left and right snap port insets. Related: scroll-padding-block-start, scroll-padding-block-end, scroll-padding-inline.
Attack 1: large scroll-padding-block — scrollIntoView stops short of button
When scrollIntoView({block: 'nearest'}) or scrollIntoView({block: 'start'}) is called on an element inside a scroll container that has scroll-padding-block set, the browser uses the inset snap port as the target rectangle. A large value shrinks this port from both top and bottom, causing the browser to consider the element "in view" as soon as it crosses the inset boundary — even though the button is outside the actual visible scrollport area. The approve button may be physically above the inset boundary (in the inset "dead zone"), reachable by CSS snap logic but not visible to the user and not interactable without additional scroll the user does not know is needed.
/* Attack: large scroll-padding-block insets snap port — scrollIntoView stops before button */
.scroll-container {
scroll-padding-block: 400px !important;
/* snap port top inset = 400px; snap port bottom inset = 400px */
/* scrollIntoView considers button "in view" when it enters the inset zone */
/* but button is 400px below visible fold */
}
Key detection gap: scroll-padding-block is set on the container, not on the button element. BCR checks on the approve button report plausible coordinates; it is the container's scroll padding that makes those coordinates fall outside the effective visible port. Always check both the target element's BCR and the container's scroll-padding values.
Attack 2: scroll-padding-block: 100vh — guaranteed fold eviction
Setting scroll-padding-block to 100vh means the snap port inset equals the full viewport height on both block-axis edges. The effective snap port shrinks to zero height — any scrollIntoView call satisfies the snap constraint when the element crosses the inset boundary, but the entire visible area is within the dead zone. The approve button is always geometrically outside the usable snap port at every viewport height. This attack is viewport-size-independent: no matter how large or small the viewport, a 100vh bilateral inset eliminates the snap port entirely.
/* Attack: 100vh bilateral block inset — snap port collapses to zero at any viewport height */
.scroll-container {
scroll-padding-block: 100vh !important;
}
// Detection: check computed scroll-padding-block against viewport height
const cs = getComputedStyle(container);
const spb = parseFloat(cs.getPropertyValue('scroll-padding-block')) || 0;
const spbs = parseFloat(cs.getPropertyValue('scroll-padding-block-start')) || 0;
const spbe = parseFloat(cs.getPropertyValue('scroll-padding-block-end')) || 0;
const vh = window.innerHeight;
if (spb >= vh || (spbs + spbe) >= vh) {
// snap port block-axis collapses to zero — scrollIntoView never lands in visible area
}
Attack 3: writing-mode: vertical-rl remap — block axis becomes physical horizontal
Under writing-mode: vertical-rl applied to the scroll container, the block axis is horizontal. scroll-padding-block then controls the left and right snap port insets, not top and bottom. An audit that checks getComputedStyle(container).scrollPaddingTop finds zero, while the horizontal snap port inset is pushing the approve button off the left or right visible area. The remap is particularly hard to detect because the physical property names (scrollPaddingLeft, scrollPaddingRight) correspond to the inline axis in the default horizontal-tb mode — auditors trained on horizontal layouts instinctively associate scroll-padding-block with top/bottom.
/* Attack: writing-mode remap — block padding controls left/right snap port */
.scroll-container {
writing-mode: vertical-rl !important;
scroll-padding-block: 300px !important; /* → left + right insets under vertical-rl */
}
// Detection: always read writing-mode before mapping scroll-padding-block to physical axes
const wm = getComputedStyle(container).writingMode;
const isVertical = wm.startsWith('vertical');
// isVertical: scroll-padding-block controls physical left+right insets
// !isVertical: scroll-padding-block controls physical top+bottom insets
Attack 4: JS mousedown + scrollIntoView injection
A mousedown listener on the approve button injects scroll-padding-block: 100vh onto the container and then calls scrollIntoView({block: 'nearest'}) during the press interval. The scroll container repositions so the button is at the block-axis inset boundary — geometrically off-screen — at the moment the click fires. Because the repositioning happens between mousedown and click, the click event fires on the container background rather than the button, and the consent is not recorded. The injection is reversed at mouseup, leaving no trace after the failed click.
/* Attack: mousedown scroll-padding-block injection + scrollIntoView */
approveBtn.addEventListener('mousedown', () => {
scrollContainer.style.setProperty('scroll-padding-block', '100vh');
approveBtn.scrollIntoView({ block: 'nearest', behavior: 'instant' });
});
approveBtn.addEventListener('mouseup', () => {
scrollContainer.style.removeProperty('scroll-padding-block');
});
// Detection: static analysis — scan for addEventListener('mousedown') + scrollIntoView
// Dynamic detection: capture-phase sentinel on approveBtn
approveBtn.addEventListener('mousedown', () => {
requestAnimationFrame(() => {
const spb = parseFloat(getComputedStyle(container).getPropertyValue('scroll-padding-block')) || 0;
if (spb > window.innerHeight * 0.5) {
console.warn('[SkillAudit] mousedown scroll-padding-block injection detected', spb);
}
});
}, { capture: true });
Findings summary
SkillAudit checks scroll-padding-block, scroll-padding-block-start, and scroll-padding-block-end on scroll containers — not just the target element — and resolves writing-mode before mapping to physical axes. Run a free audit on your MCP server.