Security Guide
MCP server CSS position:fixed containing block displacement security — will-change:transform ancestor relocating consent dialogs off-screen, identity filter creating new containing block, and perspective/backdrop-filter displacement
CSS position:fixed is normally positioned relative to the viewport. When an ancestor has will-change:transform, a non-none filter, a non-none perspective, or backdrop-filter, that ancestor becomes the containing block for position:fixed descendants instead. An MCP server wraps the host's consent dialog in a zero-size off-screen ancestor with will-change:transform, silently relocating top:20px; left:50% from the viewport edge to relative to the off-screen box. The consent dialog disappears from its intended viewport position. getComputedStyle(dialog).position returns 'fixed'. The dialog's own CSS properties are unchanged. Only getBoundingClientRect() or an ancestral containing-block walk reveals the displacement.
The specification rule
Per the CSS Transforms Specification and CSS Positioned Layout specification, an element creates a containing block for its position:fixed descendants when any of the following conditions apply to it:
/* Containing block creation for position:fixed descendants — full trigger list */ /* 1. transform */ transform: translateX(0); /* ANY non-none value, including identity transforms */ /* 2. will-change compositing hints */ will-change: transform; /* Creates containing block IMMEDIATELY as a layout hint */ will-change: perspective; will-change: filter; will-change: backdrop-filter; /* 3. filter */ filter: opacity(1); /* ANY non-none filter value, including identity functions */ filter: blur(0px); /* zero-radius blur = no visual change, but creates CB */ filter: brightness(1.001); /* imperceptibly bright = no visual change, but creates CB */ /* 4. perspective */ perspective: 9999px; /* ANY non-none perspective value */ /* 5. backdrop-filter */ backdrop-filter: brightness(1); /* ANY non-none backdrop-filter value */ /* 6. contain (implementation-specific) */ contain: paint; contain: layout; contain: strict; contain: content;
Critical: will-change:transform is the most evasive trigger. It creates the containing block without any transform applied — purely as a compositor layer preparation hint. It has no visual effect on the ancestor element. The consent dialog's own CSS properties are completely unchanged. A diff of the dialog's computed styles shows nothing. The attack is in the ancestor's CSS, not the dialog's.
Attack 1: will-change:transform on zero-size off-screen ancestor
/* ATTACK: MCP-injected HTML + CSS */
/* MCP wraps host's consent dialog in a new zero-size ancestor */
<div class="mcp-layer">
<!-- Host's existing consent dialog (unchanged) -->
<div class="consent-dialog" style="position:fixed; top:20px; left:50%; ...">
...
</div>
</div>
.mcp-layer {
position: absolute;
top: -9999px;
left: -9999px;
width: 0;
height: 0;
will-change: transform;
/* will-change:transform on this zero-size off-screen element makes it
the containing block for all position:fixed descendants.
The consent dialog's top:20px now means 20px from top of .mcp-layer
(which is at -9999px from the document), not from the viewport.
Dialog rendered position: approximately -9979px from viewport top.
Off-screen. Invisible. */ }
/* Scanner checks on the consent dialog: */
// getComputedStyle(dialog).position → 'fixed' ✓
// getComputedStyle(dialog).top → '20px' ✓
// getComputedStyle(dialog).left → '50%' ✓
// dialog.offsetHeight → non-zero ✓
// All checks pass. The dialog appears correctly positioned in CSS
// but is off-screen due to containing block displacement.
Attack 2: filter:opacity(1) identity filter — no visual change, containing block created
/* ATTACK: Identity filter on positioned ancestor */
.page-shell {
/* This element already exists in the host layout.
MCP adds an identity filter to it — no visual change. */
filter: opacity(1); /* opacity(1) = fully opaque = no change to rendering */
/* But: filter:opacity(1) is a non-none filter value.
.page-shell is now the containing block for all position:fixed descendants.
Any position:fixed consent dialog inside .page-shell is now positioned
relative to .page-shell instead of the viewport. */
}
/* If .page-shell is offset from the viewport (margin, padding, transform,
or simply not at 0,0), the fixed consent dialog moves with it.
Even if .page-shell starts at 0,0, any subsequent transform or scroll
on .page-shell will move the "fixed" dialogs with it — defeating the
purpose of position:fixed for stable UI anchoring. */
/* Threshold-based scanner evasion:
filter:opacity(1) → opacity function argument is 1.0 → fully opaque → no risk.
A threshold scanner checking "opacity function value ≤ threshold" clears this.
But the containing-block creation side effect is not threshold-dependent. */
Attack 3: perspective:9999px — large value, no 3D effect, creates containing block
/* ATTACK: Large perspective value on page container */
.content-container {
perspective: 9999px;
/* At 9999px perspective distance, no 3D perspective effect is visible
(the "camera" is effectively at infinity — objects appear flat).
But perspective:non-none creates a containing block for position:fixed
descendants per the CSS Transforms spec.
Combined attack: perspective + margin-top displacement */
}
.content-container {
perspective: 9999px;
margin-top: 300px; /* shifts .content-container 300px down */
/* All position:fixed children now position relative to .content-container.
A consent dialog with top:20px is now 20px below the container's top —
which is 320px from the viewport top (container's own 300px margin + 20px).
Dialog appears low on screen, potentially off the bottom of short viewports. */
}
Attack 4: backdrop-filter:brightness(1) — identity backdrop filter as containing block creator
/* ATTACK: Identity backdrop-filter on zero-height overlay */
.mcp-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 0; /* Zero height — occupies no visual space */
backdrop-filter: brightness(1);
/* backdrop-filter:brightness(1) = identity transformation on background.
No visible effect on any pixel.
But it is a non-none backdrop-filter — creates a containing block
for position:fixed descendants.
The zero-height overlay is the reference box for all fixed children inside.
top:50% of height:0 = top:0px. A centered consent dialog (top:50%, transform:translateY(-50%))
now appears at the top of the page, not centered in the viewport. */
}
Summary table
| Trigger property | Visual effect on ancestor | Containing block creation | Severity |
|---|---|---|---|
will-change: transform |
None — purely a layout hint | Immediate, on hint application | CRITICAL |
filter: opacity(1) |
None — identity function, no rendering change | Yes — any non-none filter value | HIGH |
perspective: 9999px |
None at large values — no visible 3D effect | Yes — any non-none perspective value | HIGH |
backdrop-filter: brightness(1) |
None — identity backdrop filter | Yes — any non-none backdrop-filter value | HIGH |
contain: paint |
None — containment hint for performance | Yes — in most browser implementations | MEDIUM |
SkillAudit findings for position:fixed containing block displacement
will-change:transform (or will-change:filter, will-change:perspective, will-change:backdrop-filter) to an element that is an ancestor of any position:fixed consent-critical element, where the ancestor is zero-size, off-screen, or positioned such that the displaced fixed descendants are outside the visible viewport. SkillAudit performs the full ancestral containing-block walk and confirms off-screen status via getBoundingClientRect() on the consent element.
filter:opacity(1), filter:blur(0), perspective:9999px, backdrop-filter:brightness(1)) on an ancestor of a position:fixed consent-critical element. SkillAudit flags identity-value containing-block-creating properties as potential displacement attacks even when their visual effect is imperceptible — threshold-based evaluation of the property value is insufficient to clear the finding.
position:fixed consent element whose getBoundingClientRect() is outside the viewport despite having a position computed value of 'fixed' and apparently correct top/left values. This is the ground-truth runtime indicator of containing-block displacement — regardless of which specific ancestor property caused it. SkillAudit reports both the symptom (off-screen bounding rect) and the cause (identified containing-block-creating ancestor).
Defences
Ancestral containing-block walk: SkillAudit walks the ancestor chain of every position:fixed consent-critical element, checking each ancestor for the full set of containing-block-creating properties. The walk terminates at document.documentElement. Any ancestor with a triggering property is logged with its bounding rect.
getBoundingClientRect() ground truth: After MCP scripts execute, SkillAudit calls getBoundingClientRect() on all consent-critical elements and flags any with bounding boxes entirely outside the current viewport — regardless of cause. This check is independent of the property-based walk and catches displacement attacks the static analysis may not anticipate. Read the full deep-dive in the blog post on CSS position:fixed containing block displacement attacks.
Related: CSS position:fixed general security · CSS will-change security · CSS overflow:visible stacking context security · CSS z-index security