MCP Security Reference

MCP server CSS will-change consent security

The CSS will-change property is a performance hint that informs the browser which properties an element is likely to animate, allowing it to pre-create compositing layers and stacking contexts. As a side effect, will-change:transform creates a new containing block — breaking position:fixed for any descendant consent element that expects to position relative to the viewport — and will-change:contents forces a new stacking context that alters z-index layering for consent overlays. Neither effect is visible in the CSS as an intentional layout rule; both appear as browser-optimization hints.

Attack findings

HIGHSA-CSS-WC-001 — will-change:transform on consent container ancestor creates a new containing block; consent elements with position:fixed inside it position relative to ancestor, not viewport — consent scrolls off-screen with the ancestor on page scroll
HIGHSA-CSS-WC-002 — will-change:contents on consent container forces a new stacking context; a consent-covering overlay with z-index:1 placed before the consent in the stacking order now paints over it — z-index previously insufficient to cover is now sufficient
HIGHSA-CSS-WC-003 — will-change:transform applied to scroll container ancestor: consent element positioned absolute inside the transformed ancestor remains in DOM and in layout but translates out of the ancestor's scroll viewport on initial render; scrollTop=0 never reaches consent position
MEDIUMSA-CSS-WC-004 — will-change applied via JS at install-click time (el.style.willChange='transform') breaks position:fixed at the moment the install interaction begins; static analysis misses runtime-applied will-change

Background: will-change containing block and stacking context side-effects

The CSS specification (CSS Transforms Level 1) states that an element with a transform (or will-change:transform, which creates the same compositing context) establishes a new containing block for all absolutely- and fixed-positioned descendants. This is the same side-effect as transform:translateZ(0), a well-known trick for forcing GPU compositing. In normal use, this behavior is expected: the developer who applies the transform controls the positioning context. In the consent attack, an ancestor element outside the consent component applies will-change:transform, silently overriding the position:fixed intent of the consent element the component author wrote.

Audit gap: Static CSS analysis that checks position:fixed on a consent element and concludes "this element is viewport-fixed" is wrong when any ancestor has will-change:transform, transform, perspective, or filter. The effective containing block for position:fixed is not the viewport but the nearest transformed ancestor. Consent positioned this way scrolls off-screen with its containing block.

Attack 1 — will-change:transform breaks position:fixed (SA-CSS-WC-001)

A consent element authored as position:fixed; top:0; left:0; width:100%; height:100% (a modal overlay) expects to cover the entire viewport regardless of scroll position. When an ancestor has will-change:transform, the consent element's containing block becomes that ancestor. If the ancestor is scrolled, the consent element moves with it. If the ancestor has overflow:hidden, the consent element is clipped to the ancestor's bounds. The consent element is in the DOM, computed style shows position:fixed, but getBoundingClientRect() reveals it has scrolled out of the visible viewport area.

/* Attack: ancestor will-change:transform breaks position:fixed consent */
.page-wrapper {
  will-change: transform;   /* Performance hint — also creates new containing block */
  overflow: hidden;         /* Clips fixed-positioned descendants to wrapper bounds */
}

.consent-modal {
  position: fixed;          /* Intent: viewport-level modal */
  top: 0; left: 0;
  width: 100%; height: 100%;
  /* Actual: positioned relative to .page-wrapper, not viewport */
  /* If .page-wrapper scrolls or has overflow:hidden — consent clipped */
}

/* Detection */
function checkFixedContainingBlock(el) {
  if (getComputedStyle(el).position !== 'fixed') return null;
  // Walk ancestors for will-change:transform
  let ancestor = el.parentElement;
  while (ancestor) {
    const wc = getComputedStyle(ancestor).willChange;
    const tr = getComputedStyle(ancestor).transform;
    if (wc.includes('transform') || (tr && tr !== 'none')) {
      return { vuln: 'SA-CSS-WC-001',
               detail: `position:fixed containing block is ${ancestor.tagName} not viewport` };
    }
    ancestor = ancestor.parentElement;
  }
  return null;
}

SA-CSS-WC-001 (High). Detection requires walking the DOM ancestor chain of any fixed-positioned consent element and checking each ancestor for will-change:transform, transform, perspective, or filter. Any of these properties creates a new containing block. SkillAudit checks the full ancestor chain for every fixed-positioned consent element.

Attack 2 — will-change:contents breaks stacking context for consent overlays (SA-CSS-WC-002)

The CSS specification states that will-change:contents may cause the browser to create a new stacking context. In practice, Chromium creates a new stacking context for elements with will-change:contents. This changes how z-index is evaluated for the element's descendants relative to elements outside it. An overlay that previously painted below the consent element (insufficient z-index) now paints over it when the consent element's ancestor establishes a new stacking context via will-change:contents. The stacking order changes invisibly — neither element's z-index value changes, only the stacking context they participate in.

/* Attack: will-change:contents creates stacking context — overlay now covers consent */
.consent-section {
  will-change: contents;    /* Creates new stacking context in Chromium */
  position: relative;
  z-index: 1;
}

.decorative-overlay {
  position: absolute;
  z-index: 0;               /* Previously: painted below consent
                               After stacking context change: paints over it
                               because the stacking context changes comparison */
  background: rgba(255,255,255,0.95);
  inset: 0;
  pointer-events: none;
}

Attack 3 — will-change:transform on scroll container traps consent off-screen (SA-CSS-WC-003)

When will-change:transform is applied to a scroll container that is the containing block for an absolutely-positioned consent element, the consent element's initial render position can be placed below the scroll container's initial scroll offset, making it unreachable without scrolling down. The key is that the initial scrollTop is set to a value that skips the consent section: the page loads, the scroll container renders at scrollTop=0 visually showing the install button, but the consent lives at a scroll position that the container never reaches in normal interaction flow.

/* Attack: will-change:transform + scrollTop pre-set bypasses consent scroll position */
.install-scroll-container {
  will-change: transform;
  overflow-y: scroll;
  height: 200px;            /* Fixed-height container */
}
/* JS: on DOMContentLoaded, set scrollTop past the consent section */
document.querySelector('.install-scroll-container').scrollTop = 9999;
/* Consent exists in DOM at scroll position 300px; container shows only bottom */

Attack 4 — JS-applied will-change at interaction time (SA-CSS-WC-004)

JavaScript can apply will-change to any element at runtime via el.style.willChange = 'transform'. When applied to a consent ancestor at the moment the user begins the install interaction — for example, in a mousedown or touchstart listener on the install button — the containing block change takes effect immediately. The consent element was correctly positioned before the interaction began; it breaks at the same moment the user commits to install. Static analysis of the stylesheet does not see this behavior; only analysis that executes the JS and monitors style mutations detects it.

/* Attack: JS applies will-change at install interaction time */
document.querySelector('#install-btn').addEventListener('mousedown', () => {
  // Applied 50ms before click fires — consent repositions before install commit
  document.querySelector('.page-wrapper').style.willChange = 'transform';
});

/* Detection: MutationObserver on consent-ancestor style attribute */
const observer = new MutationObserver(mutations => {
  for (const m of mutations) {
    if (m.attributeName === 'style') {
      const wc = m.target.style.willChange;
      if (wc && wc.includes('transform')) {
        flagFinding('SA-CSS-WC-004',
          'will-change:transform applied at runtime — check fixed consent positioning');
      }
    }
  }
});
observer.observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] });

SkillAudit detection: SkillAudit checks all fixed-positioned consent elements for transformed or will-change ancestors, evaluates stacking context membership for consent elements in will-change:contents sections, and monitors runtime style mutations during the install interaction simulation. Run a free audit →

Detection summary

Attack IDwill-change value + mechanismKey detection signal
SA-CSS-WC-001will-change:transform → position:fixed containing blockFixed consent ancestor has will-change:transform or transform ≠ none
SA-CSS-WC-002will-change:contents → stacking context overrideAncestor has will-change:contents + overlay paints over consent in computed layer
SA-CSS-WC-003will-change:transform on scroll container → scrollTop bypassConsent rect outside scroll container visible bounds at load scrollTop
SA-CSS-WC-004JS will-change applied at mousedown → runtime containing block breakMutationObserver detects style.willChange mutation during interaction