Security Guide

MCP server position:fixed security — hiding consent disclosures off-screen, behind dialogs, and in 3D stacking contexts

position:fixed removes an element from normal document flow and anchors it to the viewport rather than to its containing block. This combination — no layout footprint, always viewport-relative, immune to scrolling — makes position:fixed a versatile attack primitive for hiding MCP server permission and consent disclosures. Attackers can place a fixed disclosure element 9999 pixels off the top or left edge of the viewport, stack it behind the consent dialog body using z-index:-1, or push it behind other elements in 3D space using transform:translateZ(-1px). None of these attacks register on a simple display !== "none" check. Detecting them requires viewport-intersection geometry, stacking-context analysis, and awareness of how CSS transforms alter the fixed-position containing block.

How position:fixed works — and why it is an attack surface

Normal positioned elements (position:static and position:relative) participate in document flow: they occupy space, push siblings aside, and scroll with the page. position:fixed elements do none of these things. They are lifted out of flow entirely, so their parent element collapses as if the fixed child does not exist. They are positioned relative to the initial containing block — effectively the viewport — using the top, right, bottom, and left offsets. They do not scroll.

This behaviour makes position:fixed the standard implementation choice for modal dialogs, cookie banners, and floating consent prompts. It also means that a malicious actor who controls the CSS applied to a consent disclosure can use the same properties to make the disclosure invisible to users while keeping it technically "present" in the DOM — satisfying naive audits that only check display, visibility, and opacity.

Four properties of position:fixed are directly exploitable:

  1. No layout footprint. Because fixed elements are removed from flow, hiding a disclosure element with top:-9999px leaves no gap, no blank space, and no visual anomaly in the surrounding UI. The dialog appears compact and correct.
  2. Viewport-relative coordinates. The offsets top, left, right, and bottom are measured from the viewport edges. A large negative value places the element off-screen with no scroll needed to see it — there is simply no viewport position from which it is visible.
  3. Stacking-context interaction. position:fixed establishes a new stacking context. This interacts with z-index in ways that allow a disclosure to be painted behind other elements even when its geometry overlaps the viewport entirely — the classic z-index:-1 attack.
  4. Fixed-position escape via transforms. The containing block for a position:fixed element is the initial containing block (viewport) unless an ancestor has a transform, perspective, filter, or will-change: transform property applied. In that case the ancestor becomes the containing block. This "fixed position escape" is legitimately used in complex layouts but is also exploitable to trap a fixed-position disclosure inside a transformed container, making its viewport-relative coordinates unreliable in unexpected ways.

The fixed-position escape mechanism

The fixed-position escape is the most subtle aspect of position:fixed and is directly relevant to attacks 3 and 4 below. According to the CSS specification, the containing block for a position:fixed element is the nearest ancestor that establishes a containing block for fixed-position elements. Normally this is the viewport, but the following ancestor properties break that assumption:

When any of these is applied to the consent dialog's container, a position:fixed disclosure element inside that container is positioned relative to the container rather than the viewport. This means top:0 positions the element at the top of the container, not the viewport — subverting the usual intuition about what fixed positioning does. Simultaneously, the container's transform creates a 3D rendering context, enabling translateZ(-1px) to push the disclosure behind the dialog surface in the Z axis.

Attack 1 — position:fixed + top:-9999px: off-screen above the viewport

The simplest attack: the disclosure element is fixed to the viewport but positioned 9999 pixels above its top edge. The element is rendered by the browser — it has layout dimensions, accessible text, and a non-zero offsetHeight — but it is rendered outside the visible area with no mechanism for the user to scroll to it.

/* Malicious CSS applied to the consent disclosure element */
.mcp-consent-disclosure {
  position: fixed;      /* removed from flow; no blank space in parent */
  top: -9999px;         /* 9999px above the viewport's top edge */
  left: 0;              /* aligned to the left edge — irrelevant since top is off-screen */
  width: 100%;
  background: #fff;
  padding: 16px;
  z-index: 9999;        /* high z-index — but the element is off-screen, not hidden */

  /* NOT set — these would be caught by naive scanners: */
  /* display: none;     */
  /* visibility: hidden;*/
  /* opacity: 0;        */
}

/* What getComputedStyle() reports — all look legitimate: */
/* display:        block         ← not "none" */
/* visibility:     visible       ← not "hidden" */
/* opacity:        1             ← fully opaque */
/* offsetHeight:   > 0          ← has layout dimensions */
/* textContent:    [full text]   ← content is accessible to AT */

/* What getBoundingClientRect() reports — reveals the attack: */
/* top:    -9999                 ← 9999px above viewport top */
/* bottom: -9999 + height        ← still negative if height < 9999 */
/* left:   0                     */
/* right:  window.innerWidth     */
/* Only bottom > 0 check passes if height > 9999px (unusual) */

Detection gap: The standard "is this element hidden?" checks — display !== "none", visibility !== "hidden", opacity !== "0", offsetHeight > 0 — all pass for this element. The element is technically visible in the browser's rendering model. Only checking getBoundingClientRect().bottom > 0 (the element's bottom edge is below the viewport's top) reveals that the element is entirely above the visible area. Screen readers may announce this element depending on their implementation, making it invisible to sighted users but technically "accessible".

The layout collapse is a secondary detection signal: if the parent element of a fixed-position child has no other content, its height will be zero despite the child having content. An audit that checks for unexplained height collapse near consent UI can surface this attack.

Attack 2 — position:fixed + left:-9999px: off-screen to the left of the viewport

Directionally equivalent to Attack 1 but oriented horizontally. The disclosure is fixed to the viewport but positioned 9999 pixels to the left of its left edge. This variant may evade scanners that only check the top property for large negative values.

/* Malicious CSS — off-screen to the LEFT */
.mcp-consent-disclosure {
  position: fixed;       /* removed from flow; no blank space left in the layout */
  top: 0;                /* vertically at the top of the viewport — looks valid */
  left: -9999px;         /* 9999px to the LEFT of the viewport's left edge */
  width: 400px;          /* element has a width; fits entirely to the left */
  background: #fff;
  padding: 16px;
}

/* getBoundingClientRect() reports:          */
/* top:    0                                  ← in-viewport vertically (passes!) */
/* bottom: [height]                           ← positive (passes!) */
/* left:   -9999                              ← 9999px left of viewport */
/* right:  -9999 + 400 = -9599               ← entirely left of viewport */

/* Detection:                                                                    */
/* getBoundingClientRect().right < 0                       → entirely off-screen left */
/* getBoundingClientRect().left < -window.innerWidth       → more than one viewport width off-screen */

/* A naive check of getBoundingClientRect().top >= 0 PASSES for this attack — */
/* only the horizontal axis check catches it.                                    */

A robust scanner must test all four edges of the bounding rectangle against the viewport dimensions, not only top. The element is off-screen when any of these is true: bottom <= 0, top >= window.innerHeight, right <= 0, or left >= window.innerWidth. An element that satisfies any condition has zero intersection with the visible viewport.

RTL layouts: In right-to-left document layouts (dir="rtl"), the equivalent off-screen direction may be right:-9999px rather than left:-9999px. Scanners must test both horizontal directions regardless of document directionality, because the attacker controls the CSS, not the document direction.

Attack 3 — position:fixed + top:0 + left:0 + z-index:-1: behind the consent dialog body

This is a stacking context attack rather than an off-screen attack. The disclosure element is placed at the top-left corner of the viewport — entirely within the visible area, geometry-wise — but given z-index:-1, which places it behind all elements in the same stacking context. The consent dialog body, rendered above the disclosure with a higher z-index and an opaque background, completely covers it. The disclosure is painted but invisible.

/* Malicious CSS — stacking context attack */
.mcp-consent-disclosure {
  position: fixed;       /* creates a new stacking context */
  top: 0;                /* at the top-left corner of the viewport */
  left: 0;
  width: 100%;
  height: 100%;          /* fills the entire viewport */
  z-index: -1;           /* behind ALL other page content */
  background: #fff;
  padding: 32px;
  color: #000;
  font-size: 16px;
}

/* The consent dialog is rendered ABOVE this, covering it: */
.mcp-consent-dialog {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 100;           /* 100 > -1: dialog body is above the disclosure */
  background: #1a1a2e;    /* opaque background covers the disclosure completely */
}

/* What getBoundingClientRect() reports for the disclosure:  */
/* top:    0                                                   ← in viewport */
/* bottom: window.innerHeight                                  ← in viewport */
/* left:   0                                                   ← in viewport */
/* right:  window.innerWidth                                   ← in viewport */
/* The rectangle IS inside the viewport — geometry check PASSES.             */
/* Only a z-index comparison reveals the attack.                              */

Critical detection gap: getBoundingClientRect() returns the geometric position of an element regardless of whether it is visually occluded by another element at a higher stacking level. An element with z-index:-1 and position:fixed; top:0; left:0; width:100%; height:100% returns a bounding rect that fully overlaps the viewport — it "passes" any viewport-intersection check. Detecting this attack requires comparing the z-index of the disclosure element against the z-index of the consent dialog (and its backdrop), and verifying that the disclosure is not occluded. Simple getBoundingClientRect() checks are insufficient for z-index stacking attacks.

The correct detection approach is to call document.elementFromPoint(x, y) at coordinates that lie within the disclosure's bounding rectangle. If the topmost element at those coordinates is the consent dialog (or the dialog's backdrop), not the disclosure, the disclosure is occluded and effectively hidden from the user. This check must be performed at multiple points within the bounding rectangle to handle partial occlusion.

Attack 4 — position:fixed + transform:translateZ(-1px): 3D rendering behind the dialog surface

The most advanced variant: a 3D CSS transform is used to push the disclosure element behind other elements in 3D space rather than using the 2D z-index stacking order. This attack exploits the interaction between CSS 3D transforms, transform-style:preserve-3d, and the fixed-position escape mechanism to hide a disclosure that appears to pass both geometry and z-index checks.

/* Container — establishes a 3D perspective context AND a fixed-position escape */
.mcp-dialog-container {
  perspective: 500px;            /* creates a perspective projection context */
  transform-style: preserve-3d;  /* children share the 3D rendering context */
  transform: translateZ(0);      /* ANY transform value: triggers fixed-position escape */
                                 /* ↑ now THIS container is the containing block  */
                                 /*   for position:fixed descendants, NOT the viewport */
}

/* The consent dialog surface — at Z=0 */
.mcp-consent-dialog {
  position: fixed;               /* fixed relative to .mcp-dialog-container, not viewport */
  top: 0; left: 0;
  width: 100%; height: 100%;
  transform: translateZ(0);      /* at Z=0 — the "surface" layer */
  background: #1a1a2e;
  z-index: 1;
}

/* The disclosure — pushed BEHIND the dialog surface in 3D space */
.mcp-consent-disclosure {
  position: fixed;               /* fixed relative to .mcp-dialog-container */
  top: 0; left: 0;
  width: 100%; height: 100%;
  transform: translateZ(-1px);   /* 1px BEHIND the Z=0 surface — rendered behind dialog */
                                 /* in a perspective context, negative Z = behind viewer's plane */
  background: #fff;
  color: #000;
  /* z-index is irrelevant here — 3D Z-axis ordering takes precedence             */
  /* when transform-style:preserve-3d is in effect and elements share a 3D context */
}

/* What getComputedStyle() reports for the disclosure:           */
/* position:   fixed          ← correct */
/* transform:  matrix3d(...)  ← a 3D matrix; NOT "none" */
/* z-index:    auto or 0      ← not -1; passes naive z-index check */

/* What getBoundingClientRect() reports:                         */
/* top: 0, bottom: innerHeight, left: 0, right: innerWidth      */
/* fully in viewport — geometry check PASSES                     */

/* Detection requires:                                           */
/* 1. Detecting preserve-3d on an ancestor                       */
/* 2. Detecting translateZ with negative Z value on the element  */
/* 3. Verifying no other element at higher Z occludes it         */

Why this evades 2D analysis: Standard stacking context analysis uses the computed z-index value and the stacking context hierarchy. But when transform-style:preserve-3d is active, the browser uses the 3D Z-axis order to determine paint order — overriding the 2D stacking order within the shared 3D context. An element with translateZ(-1px) and z-index:auto will paint behind an element with translateZ(0) and z-index:auto, despite having the same nominal 2D stacking level. A scanner that only reads z-index from getComputedStyle() will not detect this attack.

The fixed-position escape makes this attack particularly insidious: the container's transform:translateZ(0) causes the position:fixed children to be positioned relative to the container, not the viewport. This means the disclosure's top:0; left:0 is container-relative, not viewport-relative — the element is always visible within the container's bounds, and getBoundingClientRect() reflects that. Only inspecting the full ancestor chain for transform-style:preserve-3d and then checking each fixed-position descendant's transform matrix for a negative Z component reveals the attack.

Summary table

Attack Prerequisite What it enables Severity
position:fixed + top:-9999px Ability to set CSS position and top on the disclosure element Places disclosure 9999px above viewport; layout collapses; passes display/visibility/opacity checks; only getBoundingClientRect().bottom > 0 check catches it High
position:fixed + left:-9999px Ability to set CSS position and left on the disclosure element Places disclosure 9999px left of viewport; directionally distinct from top attack; evades scanners checking only the top property; getBoundingClientRect().right < 0 check reveals it High
position:fixed + z-index:-1 behind dialog Ability to set z-index on disclosure; consent dialog has opaque background at higher z-index Disclosure is geometrically within the viewport (passes intersection check) but painted behind dialog; only document.elementFromPoint() or z-index comparison catches it High
position:fixed + transform:translateZ(-1px) in 3D context Container has perspective + transform-style:preserve-3d + any transform (triggering fixed-position escape) Disclosure is pushed behind dialog surface in 3D Z space; passes geometry, display, and 2D z-index checks; requires 3D transform matrix inspection to detect Medium–High

Defences and detection checklist

Detecting all four attack variants requires a multi-layer audit that goes beyond simple CSS property checks. SkillAudit's methodology covers the following controls:

SkillAudit findings

High Permission disclosure element found with position:fixed; top:-9999px. Element has non-zero offsetHeight and visible textContent but is 9999px above the viewport top edge. getBoundingClientRect().bottom is negative. No scroll mechanism available to reveal the element. Layout of parent element shows unexplained height collapse.
High Consent disclosure element positioned left:-9999px with position:fixed; top:0. Vertical viewport-intersection check passes (bottom > 0) but horizontal check fails (right < 0). Element is entirely to the left of the viewport with no horizontal scroll. Display, visibility, and opacity checks all return non-hidden values — element evades naive audits.
High Disclosure element with position:fixed; top:0; left:0; z-index:-1 confirmed behind consent dialog body (z-index:100). getBoundingClientRect() shows full viewport overlap — geometry check passes. document.elementFromPoint() at centre of disclosure returns the dialog backdrop, not the disclosure. Element is rendered but completely occluded.
Medium Disclosure element with transform:translateZ(-1px) inside a container with transform-style:preserve-3d; perspective:500px; transform:translateZ(0). Fixed-position escape confirmed — containing block is the dialog container rather than the viewport. Computed transform matrix shows negative Z translation component at index [14]. Element paints behind dialog surface in 3D rendering order. 2D z-index analysis returns z-index:auto — no alert from standard stacking checks.

Related security guides: position:fixed hiding attacks are one dimension of CSS-based consent disclosure abuse. See also CSS margin attacks (negative margins that push elements off-screen while keeping them in flow), CSS z-index stacking attacks (occlusion via stacking contexts without fixed positioning), and CSS injection security (injecting style rules via MCP tool output rendered with innerHTML).