Security reference · CSS injection · overscroll-behavior · Consent manipulation
MCP server CSS overscroll-behavior consent security — scroll containment bypass attack
CSS overscroll-behavior controls what happens when a scroll container reaches its scroll boundary. In an MCP install flow, three values — contain, none, and none on the Y axis — can be weaponised to prevent users from accessing consent terms: by trapping scroll inside the install dialog, suppressing iOS elastic bounce that would reveal hidden text, and combining with anchor links to pre-position the viewport past the consent section before the user sees it.
overscroll-behavior attack surface overview
| Attack ID | CSS value | Attack mechanism | User confusion vector |
|---|---|---|---|
| SA-CSS-OSB-001 | overscroll-behavior: contain |
Prevents scroll chaining from install dialog to parent page with full consent terms | User reaches bottom of dialog and assumes they have seen all content; parent page with full terms is inaccessible |
| SA-CSS-OSB-002 | overscroll-behavior: none |
Suppresses iOS/macOS elastic bounce that would briefly reveal text below a max-height clip |
Consent container shows 3 of 5 lines; bounce would show hidden lines momentarily; none kills that reveal |
| SA-CSS-OSB-003 | overscroll-behavior-y: none |
Disables pull-to-refresh and bounce on a container that is auto-scrolled to #install anchor past consent |
URL hash auto-scrolls viewport to install button; overscroll-behavior-y:none prevents bounce-back that might reveal consent above |
Why these attacks evade standard consent audits: Consent element audits inspect the consent element's own computed styles — color, font-size, visibility, display, opacity, clip-path. In all three overscroll-behavior attacks, the consent element has completely normal, legible styles. The attack is in the scroll interaction layer — a property on a scroll container ancestor or a combination of scroll position and bounce suppression. Element-level audits return no findings. Only behavioral analysis of scroll reachability detects these patterns.
Background: how overscroll-behavior works
When a scroll container reaches its top or bottom boundary and the user continues scrolling, the browser's default behavior is to chain the remaining scroll to the nearest scrollable ancestor — typically the document. On iOS and macOS, reaching a scroll boundary also triggers an elastic bounce effect that momentarily stretches the content beyond the boundary, then springs back. overscroll-behavior controls both of these behaviors independently of the scroll container's visible content or dimensions.
The three values are: auto (default — scroll chaining and bounce allowed), contain (no scroll chaining to parent, but device-native bounce still occurs within the element), and none (no scroll chaining and no bounce effect). The property accepts separate X and Y axis values via overscroll-behavior-x and overscroll-behavior-y. None of these values affect the consent element's computed style properties that a standard audit checks.
Attack 1: overscroll-behavior: contain traps user inside install dialog (SA-CSS-OSB-001)
An MCP install flow presents a modal dialog with a scrollable body. The dialog contains a summary of the permissions being granted — not the full consent terms, which live on the parent page below the modal. When the user reaches the bottom of the dialog's scrollable content, normal browser behavior would chain the remaining scroll impulse to the parent page, causing the parent page to begin scrolling and revealing the full consent terms below. With overscroll-behavior: contain on the dialog's scroll container, scroll chaining is suppressed: reaching the dialog's bottom boundary produces a bounce (on supporting platforms) but does not scroll the parent. The user experiences the dialog as a complete, self-contained presentation and assumes its truncated consent summary is the entirety of the agreement.
/* Attack: overscroll-behavior:contain traps scroll inside install dialog */
.install-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 480px;
max-height: 60vh;
overflow-y: auto;
overscroll-behavior: contain; /* ← prevents scroll chaining to parent page */
}
/* The parent page layout: */
body {
overflow-y: auto;
/* Full consent terms are rendered here, below the dialog's position
in the document flow, at approximately document.body.scrollHeight - 800px */
}
.full-consent-terms {
/* This element is in the DOM, in the parent page body */
/* Normal styles — color, font-size, visibility are all correct */
/* It is reachable by scrolling the parent page */
/* But overscroll-behavior:contain on the dialog prevents the user
from ever triggering parent-page scroll while focus is in the dialog */
margin-top: 2000px; /* placed below the dialog's fixed position area */
}
/* What the user sees:
- Install dialog opens
- Dialog shows: "By installing this server you grant access to..." (truncated)
- User scrolls to bottom of dialog — sees "I agree" button
- Naturally clicks "I agree"
- Full consent terms on parent page are never seen
- overscroll-behavior:contain ensures reaching the dialog bottom
does not scroll the parent, so the full terms stay hidden */
/* What an element-level audit sees on .full-consent-terms:
color: inherit → legible ✓
font-size: 14px → readable ✓
visibility: visible → visible ✓
display: block → normal ✓
opacity: 1 → opaque ✓
No anomalies on the consent element itself. */
SA-CSS-OSB-001 (High). Detection requires identifying the install dialog's scroll container and checking its overscroll-behavior computed value. If overscroll-behavior is contain or none on a modal install dialog, and the parent page contains additional consent content below the dialog's fixed position, flag as SA-CSS-OSB-001. The consent element on the parent page is unreachable for any user who does not manually close the dialog and scroll the parent.
/* Detection: check install dialog for overscroll-behavior:contain */
function detectOverscrollContainTrap() {
const CONSENT_KEYWORDS = ['authorize', 'grant', 'permission', 'agree', 'terms', 'access'];
// Find all scroll containers within the install dialog/modal
const dialogs = document.querySelectorAll(
'[role="dialog"], .modal, .install-dialog, [data-modal], dialog'
);
const findings = [];
dialogs.forEach(dialog => {
// Find scrollable descendants inside the dialog
const allEls = dialog.querySelectorAll('*');
allEls.forEach(el => {
const cs = getComputedStyle(el);
const overscroll = cs.overscrollBehavior || cs.overscrollBehaviorY;
if (overscroll === 'contain' || overscroll === 'none') {
// Check if parent page has consent content the user cannot reach
const parentPageConsentEls = document.querySelectorAll(
'.consent, .terms, [data-consent], .full-terms'
);
parentPageConsentEls.forEach(consentEl => {
const text = consentEl.textContent.toLowerCase();
const hasConsentKeywords = CONSENT_KEYWORDS.some(k => text.includes(k));
if (hasConsentKeywords) {
findings.push({
vuln: 'SA-CSS-OSB-001',
severity: 'HIGH',
element: el,
detail: `overscrollBehavior:${overscroll} on dialog scroll container; ` +
`parent consent at ${consentEl.className} is unreachable`
});
}
});
}
});
});
return findings;
}
// Also check computed overscroll behavior on the document root
function checkDocumentOverscroll() {
const htmlEl = document.documentElement;
const bodyEl = document.body;
const htmlOverscroll = getComputedStyle(htmlEl).overscrollBehavior;
const bodyOverscroll = getComputedStyle(bodyEl).overscrollBehavior;
// overscroll-behavior:none on html/body suppresses all page-level bounce and chaining
if (htmlOverscroll === 'none' || bodyOverscroll === 'none') {
return {
vuln: 'SA-CSS-OSB-001-variant',
detail: `Document-level overscroll suppressed: html=${htmlOverscroll}, body=${bodyOverscroll}`
};
}
return null;
}
Attack 2: overscroll-behavior: none kills iOS bounce-reveal of hidden consent lines (SA-CSS-OSB-002)
On iOS and macOS, when a scroll container reaches its boundary, the elastic scroll effect briefly stretches the content beyond the container's visible area before springing back. For a consent container with max-height set to show only three of five lines of text, this bounce effect momentarily reveals the hidden fourth and fifth lines — the user can read the truncated consent during the bounce animation even though the container clips the text at rest. overscroll-behavior: none suppresses this elastic bounce: the container simply stops scrolling at the boundary with no spring effect. The user sees only the three lines that fit at rest, and the bounce that would have shown the remaining lines never occurs.
/* Attack: overscroll-behavior:none kills iOS bounce that would reveal hidden consent lines */
.consent-text-container {
max-height: 72px; /* exactly 3 lines at line-height:24px */
overflow-y: auto;
overscroll-behavior: none; /* ← suppresses elastic bounce on iOS/macOS */
}
/* The consent text inside the container: */
.consent-text {
/* 5 lines of text at 24px line-height = 120px total */
/* Container clips at 72px — lines 4 and 5 are hidden at rest */
line-height: 24px;
/* Line 1: "By installing this MCP server you authorize" */
/* Line 2: "access to your file system, browser history," */
/* Line 3: "clipboard contents, and network requests." */
/* Line 4: "This server may transmit data to third-party" ← HIDDEN */
/* Line 5: "endpoints outside your organization's control." ← HIDDEN */
}
/* On iOS without overscroll-behavior:none:
- User scrolls to bottom of container
- Browser triggers elastic bounce
- During bounce: lines 4 and 5 are briefly visible (~200–400ms)
- User can read the critical third-party transmission disclosure
- Bounce springs back — lines 4 and 5 hidden again
With overscroll-behavior:none:
- User scrolls to bottom of container
- Container stops immediately — no bounce
- Lines 4 and 5 are NEVER visible
- User sees only 3 lines and assumes that is the full consent */
/* Contrast: scrollbar appearance */
/* The container has overflow-y:auto, so a scrollbar MAY appear.
However on iOS there is no persistent scrollbar — the scrollbar
appears only while scrolling. A user who stops scrolling when
they reach line 3 (the visible boundary) never sees the scrollbar
indicator that more content exists below. */
/* What an element-level audit sees on .consent-text:
color: #1a1a1a → legible ✓
font-size: 16px → readable ✓
opacity: 1 → opaque ✓
visibility: visible → visible ✓
The max-height clip and overscroll suppression are on the CONTAINER. */
Scroll indicator suppression: On iOS, the scroll indicator (the thin scrollbar that appears during touch scroll) is overlaid on top of the scrollable content and disappears when the user stops scrolling. A consent container that shows 3 of 5 lines has a scroll indicator visible only while the user is actively scrolling. If the container is rendered with its scroll position at the top and the user can see the bottom of line 3 against the container boundary, many users assume line 3 is the last line — especially if the line ends with a period. Combining overscroll-behavior: none with a sentence that ends naturally at line 3 maximises the effectiveness of this attack.
/* Detection: max-height clip + overscroll-behavior:none on consent containers */
function detectOverscrollBounceSuppress() {
const CONSENT_KEYWORDS = ['authorize', 'grant', 'access', 'permission', 'agree',
'third-party', 'transmit', 'data', 'network'];
const findings = [];
document.querySelectorAll('*').forEach(el => {
const text = el.textContent.toLowerCase();
if (!CONSENT_KEYWORDS.some(k => text.includes(k))) return;
const cs = getComputedStyle(el);
const overscroll = cs.overscrollBehavior;
const overscrollY = cs.overscrollBehaviorY;
const maxHeight = cs.maxHeight;
const overflowY = cs.overflowY;
// Flag: overscroll suppression on a height-clipped consent container
if (
(overscroll === 'none' || overscrollY === 'none') &&
maxHeight !== 'none' &&
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'hidden')
) {
// Measure actual content height vs container height
const scrollHeight = el.scrollHeight;
const clientHeight = el.clientHeight;
const hiddenPx = scrollHeight - clientHeight;
if (hiddenPx > 0) {
findings.push({
vuln: 'SA-CSS-OSB-002',
severity: 'HIGH',
element: el,
detail: `overscrollBehavior:${overscroll || overscrollY}; ` +
`maxHeight:${maxHeight}; ${hiddenPx}px of consent text hidden below clip; ` +
`iOS elastic bounce suppressed — hidden text never revealed`
});
}
}
});
return findings;
}
// Platform-aware severity: this attack is iOS/macOS-specific
// On Windows/Linux (Chrome/Firefox), overscroll bounce is not the default.
// Flag as HIGH on mobile UA, MEDIUM on desktop UA.
function platformAdjustedSeverity(finding) {
const ua = navigator.userAgent;
const isMobile = /iPhone|iPad|iPod|Android/i.test(ua);
const isMac = /Mac OS X/i.test(ua) && !/iPhone|iPad/i.test(ua);
if (isMobile || isMac) {
return Object.assign({}, finding, { severity: 'HIGH' });
}
return Object.assign({}, finding, { severity: 'MEDIUM',
note: 'Elastic bounce is not the default on this platform; impact reduced but still flagged.' });
}
Attack 3: overscroll-behavior-y: none combined with #install anchor link pre-scrolls past consent (SA-CSS-OSB-003)
The install page URL contains a hash: https://mcp-install.example.com/install/server-name#install. On page load, the browser performs a hash scroll: it finds the element with id="install" and scrolls the viewport so that element is visible. The #install element is the install button, which is placed in the DOM after the consent section. The viewport is automatically positioned at the install button, skipping past the consent text above it. overscroll-behavior-y: none on the consent container prevents pull-to-refresh and bounce-back scrolling — a user who is at the install button and attempts to scroll up (or pull to refresh) encounters reduced scroll momentum near the consent section boundary, making it harder to reach the consent casually. The combination of anchor pre-scroll and bounce suppression means the consent is above the initial viewport position and the user's natural scroll path (pull-to-refresh) to reach it is also suppressed.
/* HTML structure of the install page */
/*
<div class="install-page">
<section class="consent-section">
<!-- Consent text: "By continuing, you authorize..." -->
<!-- This is ABOVE the install button in DOM order -->
</section>
<section id="install" class="install-button-section">
<!-- Install button is here -->
<!-- The URL hash #install auto-scrolls HERE on page load -->
</section>
</div>
*/
/* CSS: overscroll-behavior-y:none on the consent container */
.consent-section {
overscroll-behavior-y: none;
/* The consent section has no scrollable content of its own —
overscroll-behavior-y:none here affects the pull-to-refresh
and momentum scroll interactions when the DOCUMENT scroll
position is within this section's bounding rect.
On some platforms this reduces the rubber-band effect
when trying to scroll up past this element. */
/* Also: if the consent section IS a scroll container */
overflow-y: auto;
max-height: 150px; /* visible portion of consent */
/* overscroll-behavior-y:none prevents the user from pulling
down past the top of this scroll container to trigger
the parent scroll that would move the viewport upward
to show whatever is above this section */
}
/* The #install anchor auto-scroll behavior */
/* URL: https://mcp-install.example.com/server#install */
/* Browser action on page load:
1. Parse URL hash: #install
2. Find element: document.getElementById('install')
3. Scroll element into view — scroll position is now at the install button
4. Consent section (above install button) is ABOVE the viewport
This is standard browser behavior — no JavaScript needed.
The install page URL is constructed with the #install hash
by the MCP server registry link. */
/* Additional JS to harden the pre-scroll */
document.addEventListener('DOMContentLoaded', () => {
const installSection = document.getElementById('install');
if (installSection) {
// Reinforce the anchor scroll after any layout shifts
// scrollIntoView with behavior:'instant' — no animation
installSection.scrollIntoView({ behavior: 'instant', block: 'start' });
// This fires after DOMContentLoaded, so layout shifts from late-loading
// ads or images do not accidentally reveal the consent section.
}
});
/* What the user experiences:
1. Opens install link (URL has #install hash)
2. Page loads — viewport immediately shows the install button
3. Consent section is above the fold — user does not see it
4. User sees "Install [Server Name]" button and clicks
5. Consent text was in the DOM all along — above the visible area */
/* overscroll-behavior-y:none prevents bounce/pull-to-refresh effects
that might naturally drag the viewport up into the consent section.
On iOS, pulling down at the page boundary (already at scroll top)
would trigger a pull-to-refresh bounce — but if the install button
is at the top of the visible viewport (not the document top),
this interaction is in the middle of the page, not at the boundary.
overscroll-behavior-y:none eliminates any elastic momentum that
might carry the user's upward scroll into the consent section. */
SA-CSS-OSB-003 (High). The anchor-hash pre-scroll is entirely driven by URL construction — no CSS or JavaScript is required on the page itself. The MCP server registry link simply appends #install to the install page URL. Detection requires checking whether the page URL contains a hash, whether the hash target is the install button or below the consent section, and whether the consent section is above the hash target in document order. The presence of overscroll-behavior-y: none on the consent container is a secondary signal that amplifies the attack.
/* Detection: anchor hash bypasses consent section */
function detectAnchorHashConsentBypass() {
const hash = window.location.hash; // e.g. '#install'
if (!hash) return null;
const hashTarget = document.querySelector(hash);
if (!hashTarget) return null;
// Find consent elements above the hash target in document order
const CONSENT_KEYWORDS = ['authorize', 'grant', 'permission', 'agree',
'terms', 'access', 'third-party'];
const findings = [];
// Get the Y position of the hash target in the document
const hashTargetRect = hashTarget.getBoundingClientRect();
const hashTargetDocTop = hashTargetRect.top + window.scrollY;
// Check all consent-bearing elements
document.querySelectorAll('*').forEach(el => {
const text = el.textContent.toLowerCase();
if (!CONSENT_KEYWORDS.some(k => text.includes(k))) return;
if (el.textContent.trim().length < 50) return; // ignore short snippets
const elRect = el.getBoundingClientRect();
const elDocTop = elRect.top + window.scrollY;
if (elDocTop < hashTargetDocTop) {
// Consent element is above the hash target — hash scroll bypasses it
const cs = getComputedStyle(el);
const overscrollY = cs.overscrollBehaviorY || cs.overscrollBehavior;
findings.push({
vuln: 'SA-CSS-OSB-003',
severity: 'HIGH',
consentElement: el,
hashTarget: hashTarget,
detail: `URL hash ${hash} auto-scrolls viewport to ${hashTarget.id || hashTarget.tagName}; ` +
`consent section is ${Math.round(hashTargetDocTop - elDocTop)}px above; ` +
`overscrollBehaviorY: ${overscrollY} on consent container`
});
}
});
return findings;
}
// MutationObserver to catch dynamic hash target injection
function watchHashTargetInjection() {
const originalHash = window.location.hash;
const observer = new MutationObserver(() => {
const currentHash = window.location.hash;
if (currentHash !== originalHash) {
const newFindings = detectAnchorHashConsentBypass();
if (newFindings && newFindings.length > 0) {
newFindings.forEach(f => console.warn('[SkillAudit]', f.vuln, f.detail));
}
}
});
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['id'] });
return observer;
}
SkillAudit detection
overscroll-behavior: contain on a modal install dialog prevents scroll chaining to the parent page where full consent terms are rendered. SkillAudit checks the computed overscrollBehavior on all scroll containers within install dialogs and verifies whether any parent-page consent content becomes unreachable as a result of scroll-chain suppression.
overscroll-behavior: none on a max-height-clipped consent container suppresses the iOS elastic bounce that would reveal hidden consent lines below the clip boundary. SkillAudit compares scrollHeight to clientHeight on consent containers with overscroll-behavior: none and flags containers where content is hidden below the clip and bounce suppression is active.
overscroll-behavior-y: none on the consent section combined with a URL #install anchor hash that auto-positions the viewport at the install button, skipping past the consent above. SkillAudit checks URL hash targets for position relative to consent-bearing elements and flags when the hash target is below the consent section in document order, with overscroll suppression making recovery scrolling harder.
Run SkillAudit to detect SA-CSS-OSB patterns in any MCP server before install. SkillAudit performs behavioral scroll reachability analysis — not just element property inspection — so scroll containment attacks that evade standard CSS audits are caught before you grant permissions.