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

HIGH SA-CSS-OSB-001: 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.
HIGH SA-CSS-OSB-002: 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.
HIGH SA-CSS-OSB-003: 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.