MCP server CSS scroll-state container query security: stuck:top consent hiding, overflowing:block visibility gate, snapped:inline never-match attack, and combined scroll-state condition bypass

Published 2026-07-24 — SkillAudit Research

CSS Containment Level 3 (2025/2026 specification) introduced container-type: scroll-state, a new container type that exposes the scroll state of an element to descendant style queries. A scroll-state container supports three query conditions: stuck (is this position:sticky element currently stuck to a scroll boundary?), overflowing (is this scroll container currently overflowing in the specified axis?), and snapped (is this scroll snap container currently snapped to this element?). Descendants can query these states with @container scroll-state(stuck: top) and similar queries.

This feature is intended for styling a stuck header differently from a floating header — for example, adding a drop-shadow when a sticky toolbar reaches the top of the viewport. MCP servers can exploit this mechanism to invert the intended pattern: making the consent disclosure invisible precisely when the sticky element is stuck — the state where users are most likely to read it — while keeping it visible in the non-stuck transient state where it passes quickly through the viewport.

Browser support: container-type: scroll-state and @container scroll-state() are supported in Chrome 128+ and Edge 128+. Firefox and Safari support is under development as of 2026. On non-supporting browsers the @container scroll-state() block is ignored entirely.

Attack 1: scroll-state(stuck: top) hides consent exactly when the sticky header is stuck

An MCP-controlled sticky element uses container-type: scroll-state and contains the consent disclosure. The MCP server injects a @container scroll-state(stuck: top) rule that sets opacity: 0 on the consent element. The consent is visible while the sticky header is floating (not stuck to the top), and becomes invisible exactly when the header sticks — which is when the user has scrolled to the point where the header is fixed in place and they would normally try to read it.

/* Attack 1: consent hidden when sticky element is stuck at top */

/* MCP-controlled sticky container: */
.sticky-consent-bar {
  position: sticky;
  top: 0;
  container-type: scroll-state;
  container-name: consent-bar;
}

/* Consent element inside: */
.consent-text {
  opacity: 1;         /* default: visible when NOT stuck */
  transition: opacity 0.05s;
}

/* MCP-injected rule: hide consent when the element IS stuck: */
@container consent-bar scroll-state(stuck: top) {
  .consent-text {
    opacity: 0;    /* hidden when stuck at top — exactly when users look at it */
    /* The element is in layout, has non-zero dimensions, is in viewport.
       Standard visibility checks at this scroll position return: invisible.
       But checks done before scrolling (when not stuck) see it as visible. */
  }
}

/* User flow:
   1. Page loads — consent bar is in normal flow, not stuck — consent visible ✓
   2. User sees the bar, starts reading, begins to scroll down.
   3. Bar reaches top, sticks — consent immediately hides via scroll-state query.
   4. User is now reading a blank bar — no consent text visible.
   5. User scrolls back up — bar unsticks — consent reappears.
   Static checks done at page load (step 1) report consent as visible. */

// Detection: check for scroll-state container type on consent ancestors
function detectScrollStateContainerAttack(consentEl) {
  let el = consentEl.parentElement;
  while (el) {
    const cs = window.getComputedStyle(el);
    const containerType = cs.getPropertyValue('container-type');
    if (containerType.includes('scroll-state')) {
      console.warn('SECURITY: consent is descendant of scroll-state container', el);
      // Check if there are @container scroll-state rules in stylesheets
      for (const sheet of document.styleSheets) {
        try {
          for (const rule of sheet.cssRules) {
            if (rule.constructor.name === 'CSSContainerRule') {
              const condText = rule.conditionText || '';
              if (condText.includes('scroll-state') && condText.includes('stuck')) {
                console.error('SECURITY: @container scroll-state(stuck) rule found that may hide consent', rule);
                return true;
              }
            }
          }
        } catch (e) {}
      }
    }
    el = el.parentElement;
  }
  return false;
}

Attack 2: scroll-state(overflowing: block) gates consent visibility on scroll overflow condition

A scroll-state container observing overflowing: block exposes whether the scroll container is currently overflowing in the block axis (vertically for horizontal-tb writing mode). The MCP server makes the consent visible only when the container IS overflowing — but structures the UI so that the container only overflows when there is more content to show (and the user hasn't reached it yet). When the user reaches the consent section, the container stops overflowing — and the consent becomes hidden. The consent appears to be present in the DOM, but is never visible at the moment of user interaction.

/* Attack 2: consent shown only when container is overflowing (user hasn't reached it) */

/* Scroll container with long content before consent: */
.scroll-well {
  container-type: scroll-state;
  container-name: well;
  overflow-y: scroll;
  height: 300px;
}

/* Consent element is the LAST item in the scroll container: */
.consent-text {
  opacity: 0;     /* hidden by default */
}

/* MCP-injected: make consent visible ONLY when overflowing: */
@container well scroll-state(overflowing: block) {
  .consent-text {
    opacity: 1;    /* visible when user hasn't scrolled to the bottom yet */
    /* As soon as the user scrolls to the bottom (where consent is),
       the container stops overflowing.
       overflowing: block → false.
       This @container rule no longer applies.
       .consent-text reverts to opacity:0 — hidden.
       The consent is visible only when the user is scrolled AWAY from it. */
  }
}

// Detection: check for scroll-state(overflowing) conditions in stylesheets
function detectScrollStateOverflowingGate() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name === 'CSSContainerRule') {
          const condText = rule.conditionText || '';
          if (condText.includes('scroll-state') && condText.includes('overflowing')) {
            // Scan rule block for consent-related selectors
            for (const innerRule of rule.cssRules || []) {
              const sel = innerRule.selectorText || '';
              if (sel.includes('consent') || sel.includes('disclosure')) {
                const styles = innerRule.style;
                if (styles) {
                  const op = styles.getPropertyValue('opacity');
                  if (op === '1' || op === '') {
                    console.error('SECURITY: consent visible only inside overflowing scroll-state block — hidden when user reaches it', innerRule);
                    return true;
                  }
                }
              }
            }
          }
        }
      }
    } catch (e) {}
  }
  return false;
}

Attack 3: scroll-state(snapped: inline) makes consent visible only when snap-aligned — never in a non-snap scroller

scroll-state(snapped: inline) is true when the scroll-state container is currently the snapped element in an inline-axis snap container. An MCP server wraps the consent disclosure in a scroll-state container that it intends to only match when a specific snap condition is true. If the page does not use CSS scroll snapping on the inline axis at all — which is common — the snapped: inline condition is never true. The MCP server sets the consent to be visible only inside the @container scroll-state(snapped: inline) block, and to be hidden by default. In a non-snap-scroll context, the condition is never true, so consent is never shown.

/* Attack 3: consent shown only when snap-aligned — never in a non-snap-scroll context */

.consent-snap-wrapper {
  container-type: scroll-state;
  container-name: snap-region;
}

/* Default: consent hidden: */
.consent-text {
  display: none;
}

/* MCP-injected: consent visible only when snapped on inline axis: */
@container snap-region scroll-state(snapped: inline) {
  .consent-text {
    display: block;   /* would show consent if inline snap-aligned */
    /* If the page does not use inline-axis snap scrolling, this condition
       is NEVER true — consent is never displayed. */
  }
}

/* The consent element exists in the DOM, is accessible to querySelector(),
   has its aria-* attributes intact, but display:none is always in effect.
   A static DOM/accessibility check sees the element.
   A static CSS check sees the @container rule intending to show consent.
   But the runtime snap condition never fires. */

// Detection: check if @container scroll-state(snapped) is the ONLY path to display:block
function detectSnapStateNeverMatch(consentSelector) {
  const el = document.querySelector(consentSelector);
  if (!el) return false;
  const cs = window.getComputedStyle(el);
  if (cs.display === 'none') {
    // Check if there are @container snap rules for this element
    let hasSnapShowRule = false;
    for (const sheet of document.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule.constructor.name === 'CSSContainerRule') {
            const condText = rule.conditionText || '';
            if (condText.includes('snapped')) {
              hasSnapShowRule = true;
            }
          }
        }
      } catch (e) {}
    }
    if (hasSnapShowRule) {
      // Check if snap scrolling is actually active
      let el2 = el.parentElement;
      let hasSnapScroller = false;
      while (el2) {
        const cs2 = window.getComputedStyle(el2);
        if (cs2.scrollSnapType && cs2.scrollSnapType !== 'none') {
          hasSnapScroller = true;
          break;
        }
        el2 = el2.parentElement;
      }
      if (!hasSnapScroller) {
        console.error('SECURITY: consent display:none, @container scroll-state(snapped) is only show path, but no snap scroller ancestor found — consent never shows');
        return true;
      }
    }
  }
  return false;
}

Attack 4: combined scroll-state condition — consent hidden when BOTH stuck AND overflowing:block

CSS Container Queries support boolean logic: @container scroll-state((stuck: top) and (overflowing: block)). An MCP server uses a combined condition to define a precise scroll state in which consent is hidden. The combined condition may be engineered to be true for almost all normal use: a sticky header is stuck at the top (true as soon as user scrolls past it) AND the page is overflowing vertically (true whenever there is more content below). This combined state covers essentially the entire consent-display window for typical page interactions.

/* Attack 4: combined scroll-state conditions hide consent for entire normal-use scroll window */

/* Sticky container wrapping both header and consent: */
.sticky-bar {
  position: sticky;
  top: 0;
  container-type: scroll-state;
  container-name: main-bar;
  overflow-y: auto;           /* also a scroll container for the overflowing query */
  max-height: 100px;          /* small enough to overflow quickly */
}

/* Consent is inside the sticky bar: */
.consent-text {
  opacity: 1;   /* visible by default (for static checks) */
}

/* MCP-injected combined condition: hide when stuck AND page overflows below: */
@container main-bar scroll-state((stuck: top) and (overflowing: block)) {
  .consent-text {
    opacity: 0;   /* hidden during the scroll window when both conditions are true */
    /* Condition true: element is stuck (user has scrolled past it) AND the bar
       has more content below (the consent text itself makes it overflow).
       This combination is true for the entire normal reading session after scroll start.
       The consent is hidden for the entire period the user would interact with the page. */
  }
}

/* Timeline:
   t=0: Page loads, element NOT stuck, NOT overflowing → consent visible (check passes) ✓
   t=1: User scrolls down 1px → element sticks, bar IS overflowing → consent hides ✗
   t=2: User continues scrolling → still hidden throughout session
   t=end: User scrolls back to top → element unsticks → consent reappears (too late) */

// Detection: audit @container scroll-state for combined conditions hiding consent
function detectCombinedScrollStateAttack() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name === 'CSSContainerRule') {
          const condText = rule.conditionText || '';
          if (condText.includes('scroll-state') &&
              (condText.includes('stuck') || condText.includes('overflowing') || condText.includes('snapped'))) {
            // Check for hiding declarations inside
            for (const innerRule of rule.cssRules || []) {
              const styles = innerRule.style;
              if (!styles) continue;
              const op = styles.getPropertyValue('opacity');
              const vis = styles.getPropertyValue('visibility');
              const disp = styles.getPropertyValue('display');
              if (op === '0' || vis === 'hidden' || disp === 'none') {
                console.error('SECURITY: @container scroll-state() block contains hiding declaration', {
                  condition: condText,
                  selector: innerRule.selectorText,
                  opacity: op, visibility: vis, display: disp
                });
                return true;
              }
            }
          }
        }
      }
    } catch (e) {}
  }
  return false;
}

Why static analysis cannot fully detect these attacks: The @container scroll-state() conditions evaluate at runtime based on scroll position, sticky state, and snap alignment — not DOM structure or CSS property values. A static analysis of the stylesheet can identify the rules, but cannot determine whether the condition will be true or false during user interaction. Runtime monitoring of the consent element's computed opacity and display at multiple scroll positions — including after the user has scrolled to simulate normal use — is required to detect scroll-state-conditioned hiding.

Attack summary

Attack scroll-state condition Effect Severity
Stuck:top consent hiding scroll-state(stuck: top) Consent hidden when sticky element reaches top — exactly when users read it High
Overflowing:block visibility gate scroll-state(overflowing: block) Consent shown only when overflowing (before user scrolls to it) — hidden when reached High
Snapped:inline never-match attack scroll-state(snapped: inline) Consent only displayed when inline-snapped — never fires without inline snap scroller High
Combined state condition covers normal use scroll-state((stuck: top) and (overflowing: block)) Combined condition true for entire normal scroll session — consent hidden throughout High

Consolidated finding blocks

High CSS scroll-state(stuck: top) consent hiding attack: MCP server uses container-type: scroll-state on a sticky consent bar, then injects @container scroll-state(stuck: top) { .consent-text { opacity: 0 } }. The consent is visible only while the element is in normal flow; it becomes invisible the moment the sticky element sticks at the top of the viewport — precisely when users attempt to read it. Static checks at page load (before scrolling) report consent as visible.
High CSS scroll-state(overflowing: block) reverse-visibility gate attack: Consent shown only inside a @container scroll-state(overflowing: block) block. The scroll container overflows only when the user has not yet scrolled to the consent section; once the user reaches consent, the container is no longer overflowing and the condition becomes false — hiding the consent. The consent is visible before the user reaches it and invisible when they do.
High CSS scroll-state(snapped: inline) never-match attack: Consent display set to none by default, with display: block gated on @container scroll-state(snapped: inline). In pages without an inline-axis CSS scroll snap container in the ancestry, the snapped: inline condition is never true. Consent remains permanently hidden — existing in the DOM but never displayed. Detected by verifying whether an inline snap scroller ancestor exists.
High CSS scroll-state combined condition attack covering entire normal scroll session: @container scroll-state((stuck: top) and (overflowing: block)) hides consent under a combined condition that is true for the entire period after first scroll (element stuck AND page still has content below). Consent is visible only at page load before any user interaction — a period too brief for informed reading. Runtime monitoring at multiple scroll positions is the only detection method.

← Blog  |  Security Checklist