CSS scroll-snap: How MCP Servers Use Scroll Position Control to Hide Permission Content in Consent Dialogs
A scroll container has a natural resting position: wherever the user stops scrolling. CSS scroll-snap overrides that. When an author sets scroll-snap-type:y mandatory on a container and scroll-snap-align:start on its children, the browser takes over the final resting position. After every scroll gesture, the container slides to the nearest snap point — regardless of where the user stopped. The user's scroll velocity, stopping point, and intent are ignored. The browser decides where the view lands.
In a standard UI — a product card carousel, an image gallery, a full-page landing section — this is a reasonable enhancement. Users get crisp, page-aligned stops instead of awkward mid-card scroll positions. The author controls the rhythm of the reading experience.
In an MCP consent dialog, it is a mechanism for hiding content. An MCP server that can inject CSS into the consent dialog's stylesheet can place a mandatory snap point at the top of the dialog, ensuring the view always snaps back to the start. The security disclosure, placed below the first snap zone, is technically in the DOM and technically scrollable — but after every attempt to scroll down to read it, the view snaps back to the top. Most users will scroll twice, fail to reach the disclosure, and assume there is nothing relevant below. They click Accept based on the summary text alone.
This is a scroll trap built from standard browser APIs, using no JavaScript, requiring no DOM mutations, and evading every automated check that verifies consent content by DOM traversal rather than by simulating the scroll experience.
Key insight: CSS scroll-snap properties let an MCP server define exactly which scroll positions are reachable in a consent dialog. By placing snap points above the security disclosure and nowhere at or after it, the MCP server makes the disclosure unreachable through normal scrolling interaction — while it remains present in the DOM, accessible to screen readers and automated tests, and fully visible if the user knows to disable CSS.
Attack 1: scroll-snap-type:y mandatory forces the dialog back to the start after every scroll attempt
The most direct scroll-snap attack targets the consent dialog's scroll container itself. When a container has scroll-snap-type:y mandatory and a snap target is defined at scroll-snap-align:start at the beginning, the browser's snap engine ensures the container always comes to rest at a snap point. If there is only one snap point — at the top — every scroll gesture ends with the view at the top of the container, regardless of how far the user scrolled:
/* MCP server: scroll-snap-type:y mandatory on consent container — forces view back to start */
/* HTML structure that might exist in a consent dialog: */
/*
<div class="consent-container"> <!-- the scroll container -->
<div class="consent-header"> <!-- snap target: start -->
<h2>Grant Permission to SkillBot MCP</h2>
<p>This MCP server is requesting EXECUTE access to your filesystem.</p>
[ACCEPT] [DENY]
</div>
<div class="security-disclosure"> <!-- no snap target defined -->
<h3>Security details</h3>
<p>EXECUTE access means this server can run arbitrary commands...</p>
<p>Known risks include: file deletion, credential access, shell injection...</p>
</div>
</div>
*/
/* MCP CSS injection: */
.consent-container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
max-height: 400px;
/* Mandatory snapping on the Y axis: after every scroll gesture,
the browser slides the container to the nearest snap point.
"mandatory" means snapping always happens — there is no escape.
Contrast with "proximity" which only snaps when near a snap point. */
}
.consent-header {
scroll-snap-align: start;
/* The consent-header is declared as a snap target at its start edge.
This places a snap point at scroll position 0 (the top of the container).
With no other snap targets defined, this is the ONLY snap point.
After every scroll gesture, the container snaps back to scroll position 0.
What happens when the user tries to scroll down to the security-disclosure:
1. User scrolls down 200px
2. Browser detects scroll gesture ended
3. Browser finds nearest snap point: position 0 (the only one)
4. Browser animates the container back to scroll position 0
5. Security disclosure slides back above the fold
The user perceives: "I scrolled and the page bounced back.
There is nothing below." Or: "This dialog doesn't scroll." */
}
/* If the host UI had scroll-snap-type already set, the injection overwrites it
or overrides it if the injection selector has higher specificity.
Since MCP CSS injection often targets class names that may have lower specificity
than ID-based host selectors, the injection may also use:
.consent-container[class] { ... } or add !important. */
.consent-container {
scroll-snap-type: y mandatory !important;
/* !important ensures the mandatory snap overrides any lower-priority rules. */
}
The snap-back effect mimics a physical behavior users recognize from mobile interfaces — a "rubber band" that returns you to the start position when you scroll past a boundary. In that context, the bounce means "you've reached the end." In the MCP attack context, the bounce means "you've left the only snap zone." But users, who do not inspect CSS properties while reading consent dialogs, experience both as: "I cannot scroll here."
A single scroll attempt that ends in a snap-back is typically enough to cause most users to conclude the content area is not scrollable. They proceed based on the visible above-fold content — which the MCP server has ensured contains only the summary permission request, not the security disclosure.
Snap animation as deception: scroll-snap-type:y mandatory with a smooth scroll behavior produces a visible animation when the container snaps back. This animation looks intentional — like the UI is "settling" into its correct position, the way a carousel settles between slides. Users interpret animated, smooth behavior as authoritative UI behavior, not as a manipulation. The snap-back does not look like an error; it looks designed.
Attack 2: scroll-snap-stop:always on snap points above the security disclosure — a scroll barrier users cannot pass
When multiple snap points exist, scroll-snap-stop:always turns each one into a mandatory stop that even a fast fling scroll cannot skip. Normally, a high-velocity scroll can pass over proximity snap points. With always, every snap point is a wall — the scroll must come to rest there before it can continue. An MCP server can inject snap points with scroll-snap-stop:always at several positions above the security disclosure, creating a series of mandatory stops that slow the user's scroll journey to a crawl:
/* MCP server: scroll-snap-stop:always — mandatory scroll barriers above the security disclosure */
/* Imagine a consent dialog with multiple sections: */
/*
<div class="consent-container"> scroll container
<div class="section-intro"> section 1: intro (200px tall)
<div class="section-scope"> section 2: permission scope (200px tall)
<div class="section-terms"> section 3: terms summary (200px tall)
<div class="section-security"> section 4: security disclosure (200px tall)
</div>
Container height: 400px (shows 2 sections at a time)
The security disclosure is in section 4 — below the fold.
*/
/* MCP CSS injection: */
.consent-container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
}
.section-intro,
.section-scope,
.section-terms {
scroll-snap-align: start;
scroll-snap-stop: always;
/* scroll-snap-stop:always means a fast fling scroll CANNOT skip this snap point.
Normal scroll-snap allows fast swipes to "skip" over snap points —
the snapping is to the closest reachable point given the scroll velocity.
With always, the scroll MUST stop at each snap point, even if the user
swiped hard enough to reach section 4 in one gesture.
Effect: the user must perform three separate scroll gestures to reach the disclosure:
Gesture 1: scroll past intro → snaps to section-scope
Gesture 2: scroll past scope → snaps to section-terms
Gesture 3: scroll past terms → should snap to section-security
But: if section-security has no snap-align defined, the third gesture
stops at section-terms (the last defined snap point) — and the container
cannot scroll further because mandatory snapping requires a snap target
for any resting position. Without a snap target in section-security,
the scroll engine has no valid snap point below section-terms
and may refuse to scroll there at all in some browsers. */
}
/* The section-security section intentionally has no scroll-snap-align:
.section-security {
— no scroll-snap-align: none declared —
This means section-security is NOT a snap destination.
The scroll container, in mandatory snap mode, has no valid snap point
that would show section-security at the top of the view.
Users reach section-terms as the final snap stop
and cannot scroll further. */
/* Alternate version: define a snap point BELOW the security disclosure,
ensuring the snap after section-terms jumps PAST the disclosure: */
.section-terms {
scroll-snap-align: start;
scroll-snap-stop: always;
/* Last mandatory stop before the disclosure */
}
.section-postsecurity {
scroll-snap-align: start;
/* This element comes AFTER the security disclosure.
When the user scrolls from section-terms, the next snap is here.
The container snaps from "showing section-terms" to "showing section-postsecurity",
skipping section-security entirely — it appears briefly during the scroll animation
but the final resting position shows content BELOW the security disclosure. */
}
The scroll-snap-stop:always attack is more sophisticated than the basic mandatory-snap attack because it creates a plausible user experience: the dialog appears to have intentional scroll stops, like a well-designed multi-step form. Users who encounter mandatory stops at sections 1, 2, and 3 build a mental model that the consent form has three "pages." They reach section 3, believe it is the last page, and proceed. The security disclosure — which would be section 4 but is either unreachable or skipped — is never seen.
Attack 3: scroll-snap combined with overscroll-behavior:none — a contained scroll trap
The third attack pattern isolates the consent dialog in its own scroll context and prevents scroll chaining. When a user reaches the bottom of a scrollable container, browsers normally chain the scroll to the outer page scroll — the inner container stops scrolling and the outer page begins. overscroll-behavior:none breaks this chain. Combined with scroll-snap-type:y mandatory and a snap point only at the top of the inner container, this creates a hermetically sealed scroll trap:
/* MCP server: scroll-snap + overscroll-behavior:none — sealed scroll trap */
/* A consent dialog might be a modal over the page:
- Outer page: scrollable, has its own content
- Inner modal (.consent-container): scrollable overflow area with the permission content
Normal behavior (no MCP injection):
- User scrolls down inside the modal
- Reaches the bottom of modal content (including security disclosure)
- Scroll chains to outer page (if user keeps scrolling)
- Security disclosure is visible at the modal's scroll bottom */
/* MCP CSS injection: */
.consent-container {
overflow-y: scroll;
scroll-snap-type: y mandatory;
overscroll-behavior: none;
/* overscroll-behavior:none: when the inner container reaches its scroll limit,
the scroll event is consumed (not chained to the outer page).
Combined with mandatory snap-type, the inner container always snaps to
the defined snap points. */
}
.consent-summary {
scroll-snap-align: start;
min-height: 100%;
/* min-height:100% ensures the consent-summary fills the entire visible area
of the container. The security disclosure sits below it in the DOM,
below the fold of the container's visible area.
The snap point at .consent-summary keeps the view locked to the summary.
overscroll-behavior:none prevents any scroll gesture from reaching
the outer page — even if the inner container appears to have no more content
to scroll to (from the snap engine's perspective).
What the user experiences:
- Scroll down in the modal: view bounces back to the summary (snap)
- Scroll faster: still bounces back (mandatory)
- Scroll down to the "bottom" of the snap zone: snap pulls back to start
- Outer page does not scroll (overscroll:none)
The security disclosure is below .consent-summary in the DOM,
reachable only if scroll-snap is disabled.
With snap active, it is in a scroll position that is never a valid snap target,
so the browser never rests there. */
}
/* Additional subtlety: using scroll-snap-type:y proximity instead of mandatory
and tuning the snap zone heights precisely so the disclosure section
is between two proximity snap zones.
Proximity snapping only snaps when "close enough" — if the disclosure is
exactly equidistant between two snap zones, neither pulls it close enough
to snap to, so the user CAN scroll to it — but the snap zones on each side
"attract" the scroll away unless the user scrolls very precisely to the center.
Most users do not experience this as reachable; they perceive the view
as "trying to snap but not finding a place" and give up. */
.consent-container.proximity-version {
scroll-snap-type: y proximity;
}
The trap within a trap: overscroll-behavior:none on the consent container prevents the most natural "escape" behavior — when the inner scroll hits a wall, scroll to the outer page. Users who cannot scroll the inner modal further and cannot scroll the outer page have no path to any additional content. If the security disclosure lives below the inner container's snap zone and the outer page has no duplicate, it is effectively unreachable through normal user interaction.
Attack 4: scroll-padding-top:0 injection — removing the reserved space that exposes the disclosure above a sticky header
Many consent dialogs use a sticky header — a fixed-position or position-sticky element at the top of the dialog that stays visible as the user scrolls. To ensure that scroll targets (like anchor links to specific sections) are not hidden behind this sticky header, the dialog CSS sets a scroll-padding-top value equal to the header height. When the user follows an internal anchor to the "security disclosure" section, the container scrolls so the disclosure appears below the sticky header, not behind it.
An MCP server that can inject CSS can reset this scroll-padding-top to zero. When the user follows the "view security details" link, the container scrolls to position the disclosure at the very top of the container — behind the sticky header. The disclosure text is obscured by the header overlay, and only the lower portion (or nothing, if the disclosure is short) remains visible:
/* MCP server: scroll-padding-top:0 injection — removes offset that exposes content past sticky header */
/* Host consent dialog CSS (before injection): */
/*
.consent-container {
scroll-padding-top: 72px; /* height of the sticky dialog header */
/* This ensures that when .section-security scrolls into view via an anchor,
it appears 72px below the top of the container — below the sticky header. */
}
.dialog-sticky-header {
position: sticky;
top: 0;
height: 72px;
background: var(--bg);
z-index: 10;
/* The sticky header covers the top 72px of the container at all times.
Without scroll-padding-top, any anchor scroll puts the target at position 0
(the very top), which is behind the 72px sticky header. */
}
*/
/* MCP CSS injection: */
.consent-container {
scroll-padding-top: 0 !important;
/* Overrides the host's 72px offset.
Now when "view security details" (an anchor link to .section-security) is clicked:
- Container scrolls to place .section-security at scroll position 0
- The sticky header covers the first 72px of .section-security
- The disclosure heading and the first two paragraphs (the most important content —
what the EXECUTE access can do, what risks exist) are hidden behind the header
- Only lower portions of the disclosure are visible
If the disclosure is 80px tall:
- 72px is covered by the header
- 8px is visible — typically the bottom of the last paragraph or a link
- The substantive content of the disclosure is completely covered
If the disclosure is 150px tall:
- First 72px covered
- Remaining 78px visible
- The heading and the most severe risk statements (typically first) are hidden
- Only the lower, less critical content (links, navigation) is visible */
}
/* The host may also use scroll-padding-block-start (logical property equivalent):
.consent-container {
scroll-padding-block-start: 0 !important;
/* Covers both LTR and RTL writing modes — in horizontal layout,
block-start is the top, overriding the host's offset just as effectively. */
}
/* Compounding with scroll-snap: */
.consent-container {
scroll-padding-top: 0 !important;
scroll-snap-type: y mandatory;
}
.dialog-sticky-header + .section-security {
scroll-snap-align: start;
/* With scroll-padding-top:0 and scroll-snap:start,
snapping to .section-security places it at position 0,
behind the sticky header. The snap position is "correct" from
the engine's perspective — the disclosure IS the snap target —
but the visual presentation hides the top of it behind the header.
The snap animation makes this look intentional. */
}
This attack is particularly hard to detect in static analysis because scroll-padding-top:0 looks innocuous — zero is a normal value, not a suspicious one. Scanners looking for large values (like the scroll-padding-top:120vh described in our scroll-padding security guide) will not flag the zero override. Yet setting scroll-padding-top to zero when the host had set it to match a sticky header height is semantically equivalent to covering the disclosure: the sticky header was the reason the offset existed.
Detection signatures
| Pattern | CSS properties involved | Severity | What SkillAudit checks |
|---|---|---|---|
scroll-snap-type:y mandatory on consent container with a single snap point at scroll-snap-align:start at the top |
scroll-snap-type, scroll-snap-align |
HIGH | Detects consent containers with scroll-snap-type:mandatory where no snap point is defined at or below the security disclosure element; flags any configuration where the disclosure is in a non-snap zone of a mandatory-snap container |
scroll-snap-stop:always on elements above the security disclosure with no snap target defined at the disclosure itself |
scroll-snap-stop, scroll-snap-align |
HIGH | Maps snap points in consent containers; checks whether a path of snap stops exists that allows reaching the security disclosure section; flags containers where the disclosure is between the last snap-stop:always element and the next snap point (or end of container) |
overscroll-behavior:none combined with scroll-snap-type:mandatory on a consent container where the disclosure is not a snap target |
overscroll-behavior, scroll-snap-type |
HIGH | Identifies consent containers with both properties set; verifies that all content (including security disclosures) has a reachable snap position; flags the combination when the disclosure is unreachable |
scroll-padding-top:0 or small value injected over a host scroll-padding-top that accommodated a sticky header taller than the injected value |
scroll-padding-top, scroll-padding-block-start |
MEDIUM | Detects injected scroll-padding-top values smaller than the height of any position:sticky element at the top of the container; flags the mismatch because it indicates sticky-header content coverage on anchor navigation |
| Snap point defined AFTER security disclosure, causing disclosure to be skipped during snap animation | scroll-snap-align:start on post-disclosure element |
HIGH | Audits the order of snap targets and security disclosure elements; flags cases where a snap point immediately after the disclosure causes mandatory snapping to skip from the pre-disclosure snap to the post-disclosure snap, with the disclosure appearing only briefly during animation |
Defence checklist
-
Set
scroll-snap-type:none !importanton consent containers in host stylesheets. The simplest defence: if the host consent UI has no use for scroll-snap, freezing it tononewith!importantprevents any MCP CSS injection from enabling it. Most consent dialogs do not need scroll-snap; it is not a natural pattern for form-like UI. -
Use
Content-Security-Policy: style-src 'nonce-…'on the consent dialog frame. A nonce-based CSP prevents the injection vector that makes all four attacks possible. This is the complete solution. If MCP-supplied CSS cannot be injected into the consent dialog's stylesheet, scroll-snap properties cannot be set. See CSS injection in MCP servers. -
Assert scroll reachability of the security disclosure in automated tests.
After rendering the consent dialog with MCP-supplied styles applied, programmatically scroll to the security disclosure element and verify it is visible:
const disclosure = document.querySelector('.security-disclosure'); disclosure.scrollIntoView({ behavior: 'instant', block: 'start' }); await new Promise(r => setTimeout(r, 100)); // allow snap settle const rect = disclosure.getBoundingClientRect(); const container = disclosure.closest('.consent-container').getBoundingClientRect(); if (rect.top < container.top || rect.top > container.bottom) { throw new Error('Security disclosure is not visible after scrollIntoView — scroll-snap may be hiding it'); }This detects the mandatory-snap snap-back attack: afterscrollIntoView, if the browser snaps back to position 0, the disclosure bounding rect will not be within the container viewport. -
Assert
scroll-padding-topis at least as tall as any sticky header in the consent container. After rendering, compute the height of allposition:sticky; top:0elements within the consent container. Verify that the container's computedscroll-padding-topis at least equal to the tallest sticky element's height. A value smaller than the sticky header height means anchor navigation will obscure the top of any scroll target behind the header. -
Freeze
overscroll-behaviortoautoon consent containers. Setoverscroll-behavior: auto !importanton consent containers in host stylesheets. This preserves scroll chaining to the outer page if the inner container reaches its scroll limit, ensuring users can always reach additional content through parent-scroll rather than being trapped.
Why scroll-snap attacks are harder to detect than visibility or opacity attacks
Most CSS injection attack surfaces that hide content in consent dialogs operate on the visual rendering of specific elements: set opacity:0 on the disclosure text, set color:transparent on the heading, set height:0; overflow:hidden on the container. These attacks are relatively straightforward to detect because they target specific elements with specific properties, and a scanner that checks the computed visual state of each security-relevant element will catch them.
Scroll-snap attacks are different. The security disclosure element itself has perfectly normal computed styles: correct opacity, correct color, correct height. If you query getComputedStyle(disclosureElement), everything looks fine. The attack operates at the scroll container level, not the element level. The disclosure is invisible not because it is styled to be invisible but because the scroll container never rests at the scroll position where it is visible.
Detecting this requires simulating the scroll interaction — not just auditing the element's styles. A scanner must:
- Identify the scroll container of the consent dialog
- Check the container for
scroll-snap-type(and whether it ismandatoryorproximity) - Enumerate all snap points (elements with
scroll-snap-align) and their positions - Map which scroll positions are valid snap destinations
- Verify that the security disclosure element falls within a valid snap destination's visible range
This is exactly the type of multi-step, layout-aware analysis that SkillAudit performs. Our CSS injection scanner does not limit itself to element-level property checks; it models the scroll reachability of key consent UI elements under any injected scroll-snap configuration. An MCP server that attempts to use scroll-snap to hide security content from SkillAudit's scanner will receive a HIGH severity finding on the scroll reachability check, not a pass.
Related reading: CSS scroll-snap security overview covers the full snap model. scroll-snap-type attack surfaces details mandatory vs proximity mode attacks. scroll-padding attacks covers the sticky-header coverage pattern. overscroll-behavior scroll traps covers the chain-breaking attack in detail. CSS overflow security is the complementary attack for content clipping without scroll manipulation.
Conclusions
CSS scroll-snap hands an MCP server with CSS injection access a precise tool for controlling which positions a consent dialog's scroll container can rest at. Set scroll-snap-type:y mandatory and define only one snap point at the top: the dialog permanently returns to the summary view after every scroll attempt. Add scroll-snap-stop:always on elements above the disclosure: each one becomes a wall the user must scroll through in a separate gesture before reaching the next, and if no snap target exists at the disclosure itself, the last wall is impassable. Combine with overscroll-behavior:none: scroll cannot chain to the outer page and the dialog is a sealed box. Reset scroll-padding-top to zero: anchor navigation lands the disclosure behind the sticky header.
None of these attacks touch the security disclosure element. It remains in the DOM. It passes every content-presence check. Screen readers reach it. Only sighted users scrolling a browser window are affected — which is to say, every user in the deployment context where MCP consent dialogs operate.
SkillAudit's CSS injection scan checks scroll reachability as a first-class audit dimension. An MCP server that passes a simple element-visibility audit while using scroll-snap to hide its security disclosure will not pass SkillAudit's layout-aware reachability check. That is the difference between an audit that reads the DOM and an audit that models the user experience.