Security reference · CSS injection · scroll-behavior · Consent manipulation
MCP server CSS scroll-behavior consent security — auto-scroll consent bypass attack
CSS scroll-behavior: smooth on the consent container enables a class of timing attacks that displace consent text at the exact moment the user initiates a click. Because mousedown fires before click, JavaScript can begin a smooth scroll animation between pointer-down and pointer-up — moving the consent section out of view while the user's finger is already descending. The install button remains under the pointer for the click event that follows. Four SA-CSS-SB patterns exploit scroll-behavior to prevent informed consent without any visual anomaly on the consent element itself.
scroll-behavior attack surface in MCP install dialogs
| Attack ID | CSS mechanism | JS trigger | User confusion vector |
|---|---|---|---|
| SA-CSS-SB-001 | scroll-behavior: smooth on consent container | mousedown listener calls scrollTo({top:0}) | Consent scrolls away as click completes; user sees install button, not consent |
| SA-CSS-SB-002 | Install button element scrollIntoView({behavior:'smooth', block:'end'}) | Called on page load or focus | Smooth animation draws eye to button; consent is above the fold by the time animation ends |
| SA-CSS-SB-003 | scroll-snap-type: y mandatory + scroll-snap-align: start on decorative element + overscroll-behavior: none | None (CSS-only) | User cannot scroll past snap boundary to reach consent below; snap loop is inescapable |
| SA-CSS-SB-004 | scroll-margin-top: -200px on consent form elements | None (CSS-only, triggered by native focus-scroll) | Tabbing into the consent form causes the browser to scroll the form 200px above the top of the viewport |
scroll-behavior attacks evade element-level audits: All four SA-CSS-SB patterns leave the consent element with normal computed styles. An audit checking color, opacity, visibility, display, clip-path, or font-size on the consent element finds nothing suspicious. The attack is in the scroll container's behavior, the scroll-margin of child elements, or a JS listener on a completely unrelated event — none of which appear on the consent element's own computed style map.
Attack 1: scroll-behavior: smooth + mousedown scrollTo({top:0}) — consent swept away during click (SA-CSS-SB-001)
When scroll-behavior: smooth is set on the consent scroll container and the MCP server attaches a mousedown listener to the install button, the server can begin scrolling the container back to position 0 (or any other position that hides the consent) as soon as the user initiates the click. The sequence is: the user positions their pointer over the install button, presses down (mousedown), the JS calls scrollTo({top:0}), the smooth animation begins, and the click event fires on the install button before the animation completes. The user sees the consent section briefly during their scroll, then sees it animate away as their click registers. The install proceeds with the consent section no longer in the viewport at click time.
/* Malicious CSS — SA-CSS-SB-001 */
.mcp-install-container {
overflow-y: scroll;
height: 320px;
scroll-behavior: smooth; /* enables smooth scrollTo() — critical for the attack */
}
/* No suspicious styles on the consent element itself:
.mcp-consent-section {
color: #ccc; → legible ✓
font-size: 14px; → readable ✓
visibility: visible; → visible ✓
display: block; → normal ✓
}
*/
/* Malicious JS — SA-CSS-SB-001 */
const container = document.querySelector('.mcp-install-container');
const installBtn = document.querySelector('.mcp-install-btn');
installBtn.addEventListener('mousedown', () => {
/* mousedown fires when pointer is pressed — BEFORE click */
/* Begin smooth scroll back to top, hiding consent section */
container.scrollTo({ top: 0 }); /* scroll-behavior:smooth makes this animate */
/* The smooth animation typically takes 300–600ms */
/* The click event fires ~100ms after mousedown on a normal click */
/* Result: click fires → install executes → consent section scrolls away afterwards */
/* At the moment of click, the container is mid-animation scrolling away from consent */
});
/* Why scroll-behavior:smooth matters:
With scroll-behavior:auto, scrollTo({top:0}) is instant — the user would see
the consent section jump away (visible anomaly).
With scroll-behavior:smooth, the scroll starts subtly — no jump, just a gentle
animation beginning as the click fires. The user perceives normal interaction. */
/* Detection: */
function detectSB001() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
const s = getComputedStyle(el);
if (s.overflowY !== 'scroll' && s.overflowY !== 'auto') continue;
if (s.scrollBehavior !== 'smooth') continue;
/* Check if this scroll container contains a consent section */
if (!CONSENT.test(el.textContent?.substring(0, 600) || '')) continue;
/* Check if any install button in this container has a mousedown listener */
/* (Cannot detect listeners directly — inspect script text or use proxy) */
const installButtons = el.querySelectorAll('button, [role="button"], input[type="submit"]');
for (const btn of installButtons) {
if (/install|accept|proceed|confirm/i.test(btn.textContent || btn.value || '')) {
findings.push({
id: 'SA-CSS-SB-001',
severity: 'high',
element: el,
message: `Scroll container with scroll-behavior:smooth contains a consent section and an install button. A mousedown listener on the install button can scroll the consent out of view before the click event fires. Consent section may not be visible at install-click time.`
});
}
}
}
return findings;
}
/* Behavioral detection: observe scroll position during mousedown on install button */
document.querySelectorAll('button, [role="button"]').forEach(btn => {
if (!/install|accept|proceed|confirm/i.test(btn.textContent || '')) return;
const container = btn.closest('[style*="overflow"], .install-container');
if (!container) return;
const scrollBefore = container.scrollTop;
btn.addEventListener('mousedown', () => {
requestAnimationFrame(() => {
if (container.scrollTop !== scrollBefore) {
console.warn('SA-CSS-SB-001: Scroll position changed on mousedown — consent may be scrolling out of view during click');
}
});
}, { once: true });
});
Attack 2: scrollIntoView({behavior:'smooth', block:'end'}) on install button — consent pushed above fold (SA-CSS-SB-002)
Element.scrollIntoView() is a DOM method that scrolls the nearest scrollable ancestor until the target element is visible. When called with {behavior:'smooth', block:'end'} on the install button, it scrolls the button to the bottom of the viewport — placing the install button directly at the lower edge and pushing the consent section above the fold. The smooth animation is visually appealing — it looks like a designed "here's the button you need" transition — but its practical effect is that the user's eye follows the animation to the install button and the consent section is no longer visible. If called on page load or when the install panel opens, the user may never see the consent section at all. If called when the install button receives focus (via Tab), the animation fires at the most critical moment.
/* Malicious JS — SA-CSS-SB-002 */
/* No malicious CSS required — scrollIntoView uses the existing scroll container */
/* Variant A: called on page load */
document.addEventListener('DOMContentLoaded', () => {
const installBtn = document.querySelector('.mcp-install-btn');
if (installBtn) {
installBtn.scrollIntoView({
behavior: 'smooth', /* animate — doesn't look like an attack, looks like UX */
block: 'end', /* align bottom of button with bottom of viewport */
inline: 'nearest'
});
/* Result: install button is at the bottom of the viewport.
Any consent section above the button is above the fold.
User sees only the install button on page load. */
}
});
/* Variant B: called when install panel becomes visible (e.g., modal open) */
document.querySelector('.mcp-install-modal').addEventListener('open', () => {
document.querySelector('.mcp-install-btn').scrollIntoView({
behavior: 'smooth', block: 'end'
});
});
/* Variant C: called on focus (Tab navigation into install panel) */
document.querySelector('.mcp-install-btn').addEventListener('focus', () => {
/* When user tabs to the install button, scroll it into view at the bottom */
/* This overrides the browser's default scroll-to-focus behavior */
/* Default behavior centers the focused element; block:'end' puts it at the bottom */
/* pushing consent above the visible area */
document.querySelector('.mcp-install-btn').scrollIntoView({
behavior: 'smooth', block: 'end'
});
});
/* What block:'end' does vs. block:'start' or block:'center':
block:'start' → button top aligns with viewport top → button visible at top, consent may be below
block:'center' → button centered in viewport → some consent visible above/below
block:'end' → button bottom aligns with viewport bottom → button at bottom, consent ABOVE the fold
block:'nearest'→ minimal scroll → safest, preserves consent visibility
*/
/* Detection: */
function detectScrollIntoViewOverride() {
const findings = [];
/* Check if scrollIntoView is overridden on install button elements */
const installButtons = document.querySelectorAll('button, [role="button"], input[type="submit"]');
for (const btn of installButtons) {
if (!/install|accept|proceed|confirm/i.test(btn.textContent || btn.value || '')) continue;
/* Check for inline event listener pattern via getEventListeners if available (dev tools API) */
/* In a production audit context, check script sources for scrollIntoView calls */
if (btn.scrollIntoView !== Element.prototype.scrollIntoView) {
findings.push({
id: 'SA-CSS-SB-002',
severity: 'high',
element: btn,
message: `Install button has overridden scrollIntoView method. Native scrollIntoView override may change scroll behavior to push consent out of viewport.`
});
}
}
/* Check computed scroll-behavior on containers wrapping install buttons */
for (const btn of installButtons) {
if (!/install|accept|proceed|confirm/i.test(btn.textContent || btn.value || '')) continue;
let parent = btn.parentElement;
while (parent && parent !== document.body) {
const s = getComputedStyle(parent);
if ((s.overflowY === 'scroll' || s.overflowY === 'auto') && s.scrollBehavior === 'smooth') {
const consentInContainer = /consent|disclosure|terms|privacy/i.test(
parent.textContent?.substring(0, 800) || ''
);
if (consentInContainer) {
findings.push({
id: 'SA-CSS-SB-002',
severity: 'medium',
element: parent,
message: `Install scroll container has scroll-behavior:smooth. scrollIntoView({block:'end'}) on the install button would push the consent section above the fold with a smooth animation that distracts from the scroll-away.`
});
}
break;
}
parent = parent.parentElement;
}
}
return findings;
}
scrollIntoView({block:'end'}) looks like good UX: Auto-scrolling the primary CTA into view is a standard design pattern. Developers and auditors reviewing the install flow code may see scrollIntoView({behavior:'smooth', block:'end'}) and interpret it as a user-experience optimization rather than an attack. The consent-bypassing effect depends on the relative DOM position of the consent section and the install button — a detail that requires analyzing both the layout and the scroll call together.
Attack 3: scroll-snap loop with overscroll-behavior: none — inescapable snap boundary (SA-CSS-SB-003)
This CSS-only attack combines three properties to create a situation where the user is permanently trapped at a scroll position that does not show the consent. A scroll-snap-type: y mandatory container defines snap points using scroll-snap-align. A decorative element placed after the consent section in DOM order has scroll-snap-align: start. The consent section has no snap alignment. When the user attempts to scroll past the consent to read the bottom of it, the mandatory snap forces the container to jump to the decorative element's snap point — which lands below the consent section. The user is now past the consent. Scrolling back up, the snap forces them back to the snap point before the consent (e.g., the intro section), completely skipping the consent section again. Combined with overscroll-behavior: none on the container (preventing the page from scrolling when the container is at a boundary), the user cannot escape this snap loop.
/* Malicious CSS — SA-CSS-SB-003 */
.mcp-install-outer {
overflow-y: scroll;
scroll-snap-type: y mandatory; /* mandatory: must always land on a snap point */
scroll-behavior: smooth; /* smooth: transitions are animated (less jarring) */
overscroll-behavior: none; /* prevent escape to page scroll when at boundary */
height: 400px;
}
.mcp-intro-section {
scroll-snap-align: start; /* snap point 1: intro visible */
height: 400px;
}
.mcp-consent-section {
/* NO scroll-snap-align → not a snap point */
/* Consent is at scroll positions 400px–700px */
height: 300px;
/* With mandatory snap: scroll CANNOT rest at 400px–700px */
}
.mcp-decorative-divider {
/* Placed immediately after consent in DOM */
scroll-snap-align: start; /* snap point 2: decorative divider */
height: 400px;
/* At snap: decorative divider is at the top of the viewport */
/* Consent section (300px tall) is ABOVE this snap point = above the fold */
}
.mcp-install-button-section {
scroll-snap-align: start; /* snap point 3: install button */
height: 400px;
}
/* Scroll positions with mandatory snap:
scrollTop=0: intro-section in view ← snap point 1
scrollTop=700: decorative-divider in view ← snap point 2 (consent 400–700 skipped)
scrollTop=1100: install-button in view ← snap point 3
User scrolling DOWN from intro jumps to decorative-divider — consent (400–700) is SKIPPED.
User scrolling UP from decorative-divider jumps to intro — consent (400–700) is SKIPPED AGAIN.
overscroll-behavior:none prevents escape to the page scroll container.
The user is in a snap loop: intro → decorative-divider → install-button → (back to intro).
The consent section is never the resting scroll position.
*/
/* Variant: combine with scroll-behavior:smooth so the snap animation
looks like a designed section transition, not a jump. */
/* Detection: */
function detectSnapLoop() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const container of document.querySelectorAll('*')) {
const s = getComputedStyle(container);
const snapType = s.scrollSnapType || '';
if (!snapType.includes('mandatory')) continue;
/* Find consent sections in this container */
let hasConsentWithoutSnap = false;
for (const child of container.children) {
const cs = getComputedStyle(child);
const snapAlign = cs.scrollSnapAlign || '';
const isConsentSection = CONSENT.test(child.textContent?.substring(0, 500) || '');
if (isConsentSection && (snapAlign === 'none' || snapAlign === '')) {
hasConsentWithoutSnap = true;
break;
}
}
if (hasConsentWithoutSnap) {
const overscroll = s.overscrollBehavior || s.overscrollBehaviorY || '';
findings.push({
id: 'SA-CSS-SB-003',
severity: 'critical',
element: container,
message: `Scroll container with scroll-snap-type:y mandatory contains a consent section without scroll-snap-align. With mandatory snap, the consent section is not a valid resting scroll position — the snap jumps over it. ${overscroll.includes('none') ? 'overscroll-behavior:none also prevents escape to the parent scroll context.' : ''}`
});
}
}
return findings;
}
overscroll-behavior: none eliminates the last escape route: Without overscroll-behavior: none, a user who reaches the snap boundary of the inner container can continue scrolling and the parent page begins to scroll — potentially revealing or enabling access to the consent section another way. overscroll-behavior: none blocks this. The inner container absorbs all scroll events at its boundaries, making the snap loop completely self-contained. SkillAudit specifically checks for the combination of scroll-snap-type: y mandatory, missing consent section snap-align, and overscroll-behavior: none as the SA-CSS-SB-003 compound indicator.
Attack 4: negative scroll-margin-top inverts focus-scroll — consent scrolls off-screen on Tab (SA-CSS-SB-004)
Browsers implement a built-in accessibility behavior: when a focusable element receives focus (via Tab key or programmatic focus()), the browser scrolls its nearest scrollable ancestor until the element is within the viewport. The scroll-margin CSS property (part of CSS Scroll Snap Module Level 1) adds extra space around the focused element for this scroll-to-focus calculation. A positive scroll-margin-top: 40px means "when scrolling this element into view via focus, add 40px of extra clearance at the top." A negative scroll-margin-top: -200px inverts this: the browser calculates the scroll position by subtracting 200px from the element's top, placing it 200px above the viewport top — meaning the element is scrolled off-screen upward. When any focusable element inside the consent form receives focus, the entire consent section scrolls out of view upward.
/* Malicious CSS — SA-CSS-SB-004 */
.mcp-consent-section {
/* scroll-margin-top affects the browser's scroll-to-focus calculation:
When this element (or a descendant) is scrolled into view via focus,
the browser adds scroll-margin-top to the top of the target bounding rect.
A NEGATIVE value moves the target above the viewport. */
scroll-margin-top: -200px;
/* What the user sees: consent section appears at its normal position on the page */
/* What happens on Tab: browser scrolls consent section 200px above viewport top */
/* Effect: consent form scrolls off-screen the moment the user tries to interact with it via keyboard */
}
/* Why scroll-margin accepts negative values:
The CSS spec does not define a minimum for scroll-margin values.
Browsers clamp scroll-margin in some contexts but not in the focus-triggered
scroll-to-view calculation path. A value of -200px is applied as specified. */
/* Variant: applied to individual form elements within the consent form */
.mcp-consent-section input[type="checkbox"],
.mcp-consent-section button,
.mcp-consent-section a {
scroll-margin-top: -400px; /* targeting interactive elements within consent */
/* When user tabs into the consent checkbox: browser scrolls it 400px above viewport */
/* Checkbox is not visible; user cannot see whether they are checking/unchecking it */
}
/* Variant: using scroll-margin shorthand (affects all four sides) */
.mcp-consent-section {
scroll-margin: -200px 0 0 0; /* only top is negative */
}
/* Detection: */
function detectNegativeScrollMargin() {
const findings = [];
const CONSENT = /consent|disclosure|terms|privacy|grant.*access|agree.*install/i;
for (const el of document.querySelectorAll('*')) {
/* Check scroll-margin-top computed value */
const s = getComputedStyle(el);
const scrollMarginTop = parseFloat(s.scrollMarginTop || '0');
if (scrollMarginTop >= 0) continue; /* only negative values are suspicious */
/* Check if this element is part of a consent section */
const isConsentElement = CONSENT.test(el.textContent?.substring(0, 400) || '') ||
CONSENT.test(el.closest('[class*="consent"], [class*="disclosure"], [class*="terms"]')
?.textContent?.substring(0, 400) || '');
if (isConsentElement) {
findings.push({
id: 'SA-CSS-SB-004',
severity: 'high',
element: el,
scrollMarginTop,
message: `Element in consent area has scroll-margin-top: ${scrollMarginTop}px (negative value). When this element or any of its focusable descendants receives focus, the browser's scroll-to-focus behavior will scroll the element ${Math.abs(scrollMarginTop)}px above the viewport top — making the consent form invisible at the moment of keyboard interaction.`
});
}
}
return findings;
}
/* Behavioral test: verify consent section is visible after Tab-into-consent */
function behavioralTestFocusScroll() {
const consentCheckbox = document.querySelector(
'[class*="consent"] input[type="checkbox"], [class*="disclosure"] input[type="checkbox"]'
);
if (!consentCheckbox) return null;
const scrollContainer = consentCheckbox.closest('[style*="overflow"], .install-container');
const scrollBefore = scrollContainer?.scrollTop ?? window.scrollY;
consentCheckbox.focus();
return new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const rect = consentCheckbox.getBoundingClientRect();
const isInViewport = rect.top >= 0 && rect.bottom <= window.innerHeight;
if (!isInViewport) {
resolve({
id: 'SA-CSS-SB-004',
severity: 'high',
message: `After programmatic focus on consent checkbox, element is not in the viewport. scroll-margin-top or scroll-margin may be negative, causing the browser to scroll the element off-screen.`,
boundingRect: rect
});
} else {
resolve(null);
}
});
});
});
}
SkillAudit detection for scroll-behavior consent attacks
scroll-behavior: smooth encloses both a consent section and an install button, and a mousedown event listener on the install button calls scrollTo() or modifies scrollTop. The smooth scroll animation begins between mousedown and click, moving the consent section out of view at the moment the install click fires. SkillAudit detects the scroll-behavior: smooth container, identifies install buttons within it, and monitors scroll position changes during mousedown to flag active scroll-on-click patterns.
scrollIntoView({behavior:'smooth', block:'end'}) called on the install button element — at page load, on modal open, or on button focus — scrolls the button to the bottom of the viewport, pushing the consent section above the fold. The smooth animation mimics intentional design. SkillAudit checks the viewport intersection ratio of the consent section after all DOMContentLoaded and load handlers execute, and flags cases where consent is above the fold at install-button-visible time.
scroll-snap-type: y mandatory on the outer scroll container with the consent section lacking scroll-snap-align, combined with overscroll-behavior: none, creates an inescapable snap loop. The consent section is unreachable as a stable scroll position and the user cannot escape to a parent scroll context to access it via an alternative path. SkillAudit evaluates the snap point map of every mandatory-snap container in the install panel and verifies that the consent section is a reachable snap position or accessible via non-snap scroll.
scroll-margin-top value on the consent section or on interactive elements within it (checkboxes, buttons, links) causes the browser's scroll-to-focus behavior to scroll the element off the top of the viewport when it receives focus. The consent form disappears from view at the moment of keyboard interaction. SkillAudit scans all elements in the consent area for negative scroll-margin computed values and performs a behavioral focus test: programmatically focuses the consent checkbox and verifies the element is within the viewport after the focus-scroll completes.
Run SkillAudit to detect SA-CSS-SB patterns in any MCP server before install. SkillAudit's consent audit combines static CSS property analysis with behavioral simulation — dispatching mousedown events, measuring IntersectionObserver ratios at install time, mapping mandatory snap containers, and performing focus-scroll viewport tests — to catch scroll-behavior attacks that are invisible to element-level property inspection.
Related MCP consent attack research
- CSS scroll-snap consent attacks — mandatory snap skips consent, scroll-snap-stop blocks, pre-scroll JS
- CSS overflow consent attacks — overflow:hidden clips consent, overflow:scroll buries it
- CSS pointer-events attacks — disable consent checkbox interaction and intercept clicks
- CSS transform consent attacks — translateY moves consent outside clip boundary
- CSS exfiltration security in MCP servers