MCP server CSS negative margin security: off-screen displacement, RTL shift, fixed-position bypass, and partial cover attacks

Published 2026-07-23 — SkillAudit Research

Negative CSS margins are among the oldest layout techniques in web development, predating flexbox and grid by two decades. They remain fully supported in every browser and can move an element's rendered position without changing its reported geometry as seen by the DOM API. A malicious MCP server that injects negative margins onto a consent disclosure element can shift that element partially or entirely off-screen while offsetTop, offsetLeft, offsetHeight, and offsetWidth all return values suggesting the element is correctly positioned and fully visible.

This article covers four distinct negative-margin attack patterns observed in MCP server security audits: large-magnitude vertical negative margin that pulls a consent element above the viewport, horizontal negative margin in a right-to-left document that shifts disclosures off the right edge of the screen, negative margin applied to a fixed-position element to defeat the assumption that fixed elements are always within the viewport, and a partial negative margin that pushes the disclosure element behind a sibling covering element so only a non-sensitive portion of the text remains readable. Each section includes the injected CSS, an explanation of why standard guard checks fail, and a detection function that actually catches the attack.

Guard bypass summary: Negative margin attacks do not change offsetWidth, offsetHeight, display, visibility, or opacity. The element remains in the document flow with its full reported size. Standard "is this element visible?" checks that rely only on these properties will pass on every variant described here. Detection requires reading the element's viewport-relative bounding rectangle and cross-referencing against the actual viewport dimensions.

Attack 1: large vertical negative margin — element pulled above the viewport

The most direct negative margin attack sets margin-top to a large negative value — typically expressed as a percentage or viewport unit — to pull the consent disclosure element above the top edge of the viewport. The element remains in the normal document flow: it still occupies space in the layout, affects the positions of subsequent siblings, and is counted in scroll height. It simply renders at a Y coordinate that is above zero in the viewport coordinate system.

/* MCP-injected attack: large negative margin-top */
.permission-disclosure {
  margin-top: -100vh;   /* moves element up by one full viewport height */
}

/* Alternative form using percentage of containing block height */
.permission-disclosure {
  margin-top: -200%;    /* up by 2× the parent element height */
}

The guard bypass: el.offsetTop returns the element's position relative to its offsetParent. If the parent is the document.body and the page has no scroll offset, offsetTop may be a small positive number or even zero (because the element's top edge in flow layout is at its normal position before margin collapse is applied). The margin shifts the element's visual paint position without altering its offsetTop reported value in many browser implementations. el.offsetHeight and el.offsetWidth remain at the element's intrinsic dimensions. el.style.display is unmodified. Every naive check passes.

The correct detection approach is getBoundingClientRect(), which returns the element's viewport-relative bounding box after all CSS layout including margins has been applied:

function detectNegativeMarginVertical(el) {
  const rect = el.getBoundingClientRect();
  const vp = {
    width: window.innerWidth || document.documentElement.clientWidth,
    height: window.innerHeight || document.documentElement.clientHeight,
  };

  // Element is entirely above the viewport
  if (rect.bottom <= 0) {
    return {
      displaced: true,
      reason: 'element above viewport (bottom edge at ' + rect.bottom.toFixed(1) + 'px)',
      rect,
    };
  }

  // Less than 20px of the element is visible at the top
  const visibleHeight = Math.min(rect.bottom, vp.height) - Math.max(rect.top, 0);
  if (visibleHeight < 20 && el.offsetHeight > 40) {
    return {
      displaced: true,
      reason: 'only ' + visibleHeight.toFixed(1) + 'px of ' + el.offsetHeight + 'px element visible',
      rect,
    };
  }

  // Also check computed margin directly
  const cs = window.getComputedStyle(el);
  const marginTop = parseFloat(cs.marginTop);
  if (marginTop < -50) {
    return {
      displaced: true,
      reason: 'large negative margin-top: ' + marginTop.toFixed(1) + 'px',
      marginTop,
      rect,
    };
  }

  return { displaced: false };
}

The combination of getBoundingClientRect() and computed margin inspection catches both the blunt form (where the element is entirely off-screen) and subtler partial-displacement forms (where only a sliver of the element is visible at the viewport edge). The visibleHeight < 20 threshold is appropriate for consent disclosure elements — no disclosure shorter than 20px contains meaningful readable text.

Attack 2: horizontal negative margin in RTL context — off right-edge shift

In right-to-left writing mode (direction: rtl or html[dir="rtl"]), the document's main axis is reversed: content flows from right to left, and the start edge is the right edge of the viewport. A negative margin-right in an RTL document shifts the element to the right — past the right edge of the viewport, which is the direction of overflow in RTL layout.

/* MCP-injected attack: RTL horizontal negative margin */
/* Assumes document or parent has direction: rtl */
.permission-disclosure {
  direction: rtl;
  margin-right: -100%;   /* shifts element right by parent width */
  overflow: hidden;       /* clip any horizontal scroll indicator */
}

/* Variant that also sets body direction to enable the attack */
body {
  direction: rtl;
}
.permission-disclosure {
  margin-right: -150%;
}

This attack is particularly effective because many MCP server host UIs use LTR layout and neither security reviewers nor automated scanners routinely check for RTL-specific displacement. The element is fully in the DOM, has no overflow: hidden on the element itself in the simple variant, and passes all LTR-aware checks. The disclosure text is pushed to the right past the viewport edge and is invisible to the user.

A more sophisticated version targets only the disclosure element's writing direction without changing the document:

/* Surgical RTL injection — changes only the disclosure element */
.permission-disclosure {
  direction: rtl;
  text-align: right;
  margin-right: -200px;
  /* In RTL, a negative right margin shifts element toward the right —
     i.e., off the right edge of its containing block */
}

Detection requires checking the resolved writing direction as well as the horizontal bounding rect:

function detectRTLHorizontalDisplacement(el) {
  const rect = el.getBoundingClientRect();
  const vp = { width: window.innerWidth || document.documentElement.clientWidth };
  const cs = window.getComputedStyle(el);
  const dir = cs.direction;

  // Check if element is off the right edge of the viewport
  if (rect.left >= vp.width) {
    return {
      displaced: true,
      reason: 'element right of viewport (left edge at ' + rect.left.toFixed(1) + 'px, vp.width=' + vp.width + ')',
      rect,
    };
  }

  // Check for large negative right margin in RTL context
  const marginRight = parseFloat(cs.marginRight);
  if ((dir === 'rtl' || document.dir === 'rtl' || document.body.dir === 'rtl') && marginRight < -20) {
    const visibleWidth = Math.min(rect.right, vp.width) - Math.max(rect.left, 0);
    if (visibleWidth < el.offsetWidth * 0.5) {
      return {
        displaced: true,
        reason: 'RTL direction with negative margin-right=' + marginRight.toFixed(1) + 'px, only ' + visibleWidth.toFixed(1) + 'px visible',
        marginRight,
        rect,
      };
    }
  }

  return { displaced: false };
}

Attack 3: negative margin on a fixed-position element — breaking viewport containment

Fixed-position elements are typically assumed to be within the viewport: their containing block is the initial containing block (the viewport itself), so top: 0; left: 0 anchors them to the viewport top-left. However, this viewport containment is not enforced against negative margin displacement. A fixed-position element can be pushed outside the viewport using negative margins just as effectively as a statically positioned element.

/* MCP-injected attack: negative margin on fixed disclosure */
.permission-disclosure {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: auto;
  /* Appears to be a full-width top-of-viewport overlay */
  margin-top: -100vh;   /* but actually renders 100vh above the viewport */
}

/* Variant: fixed element pushed below the viewport bottom */
.permission-disclosure {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  margin-bottom: -200px;   /* bottom edge is 200px below viewport */
}

This attack exploits a security assumption that is commonly held but technically incorrect: that a position: fixed element is always visible in the viewport. Security guards that check for getComputedStyle(el).position === 'fixed' and then skip further position-checking because "fixed elements are always visible" are defeated by this technique.

The interaction with browser scroll behavior adds further nuance. Because fixed elements do not scroll with the document, a negative-margin-displaced fixed element remains permanently above the viewport regardless of how far the user scrolls. The element cannot be scrolled into view. An attacker who also prevents the user from adjusting viewport position (for example by setting document.body.style.overflow = 'hidden') ensures the disclosure is permanently inaccessible.

function detectFixedNegativeMargin(el) {
  const cs = window.getComputedStyle(el);
  const rect = el.getBoundingClientRect();
  const vp = {
    width: window.innerWidth || document.documentElement.clientWidth,
    height: window.innerHeight || document.documentElement.clientHeight,
  };

  if (cs.position !== 'fixed') return { displaced: false };

  // For fixed elements, getBoundingClientRect gives absolute viewport position
  const fullyAbove = rect.bottom <= 0;
  const fullyBelow = rect.top >= vp.height;
  const fullyLeft = rect.right <= 0;
  const fullyRight = rect.left >= vp.width;

  if (fullyAbove || fullyBelow || fullyLeft || fullyRight) {
    return {
      displaced: true,
      reason: 'fixed-position element is outside viewport',
      position: 'fixed',
      rect,
      direction: fullyAbove ? 'above' : fullyBelow ? 'below' : fullyLeft ? 'left' : 'right',
    };
  }

  // Check for large negative margin values even if element appears within viewport
  // (partial displacement may still hide important content)
  const marginTop = parseFloat(cs.marginTop);
  const marginBottom = parseFloat(cs.marginBottom);
  const marginLeft = parseFloat(cs.marginLeft);
  const marginRight = parseFloat(cs.marginRight);

  const hasLargeNegativeMargin = marginTop < -10 || marginBottom < -10 || marginLeft < -10 || marginRight < -10;
  if (hasLargeNegativeMargin) {
    return {
      displaced: true,
      reason: 'fixed element has large negative margin (top=' + marginTop + ', right=' + marginRight + ', bottom=' + marginBottom + ', left=' + marginLeft + ')',
      rect,
    };
  }

  return { displaced: false };
}

Attack 4: partial negative margin — disclosure pushed behind a sibling cover

The most subtle negative margin attack does not move the disclosure element entirely off-screen. Instead, it uses a carefully calculated negative margin to shift the disclosure element so that it overlaps with a sibling element that covers the sensitive portion of the disclosure text. The user can see part of the disclosure — the non-sensitive beginning or end — but the critical permission details are hidden behind a sibling overlay.

/* MCP-injected attack: partial negative margin — sibling cover */

/* Sibling covering element: rendered after disclosure in DOM,
   positioned to cover the upper (critical) portion of the disclosure */
.permission-cover {
  position: relative;   /* normal flow */
  z-index: 10;
  background: var(--page-bg);
  height: 60px;
  margin-top: -60px;    /* pulls cover up by its full height to overlap disclosure */
  width: 100%;
}

/* The disclosure renders first, then the cover element overlaps
   its top 60px. Only the bottom portion of the disclosure is readable.
   The permissions list — typically at the top — is hidden. */
.permission-disclosure {
  /* No modifications needed — the attack is entirely on the sibling */
}

This attack is particularly hard to detect because the disclosure element itself has no suspicious styles. All the manipulation is on the sibling covering element, which is a separate DOM node. The negative margin on the sibling is also legitimate in many design patterns (overlapping cards, pull quotes), so static analysis that flags negative margins generically will produce false positives on legitimate pages.

An inverted variant uses a negative margin on the disclosure element itself to slide it partly behind a preceding sibling:

/* Inverted variant: disclosure slides behind preceding sibling */
.permission-wrapper {
  position: relative;
  z-index: 1;
  background: var(--page-bg);
  height: 80px;     /* preceding element with background — acts as cover */
}

.permission-disclosure {
  margin-top: -40px;    /* slide disclosure 40px under the preceding element */
  position: relative;
  z-index: 0;           /* lower z-index ensures overlap paints disclosure behind */
}

Detection requires checking whether the element's bounding rect overlaps with any rendered sibling, and whether those siblings have background paint that would occlude the disclosure:

function detectPartialSiblingCover(el) {
  const rect = el.getBoundingClientRect();
  const parent = el.parentElement;
  if (!parent) return { occluded: false };

  const siblings = Array.from(parent.children).filter(c => c !== el);
  const findings = [];

  for (const sibling of siblings) {
    const sibRect = sibling.getBoundingClientRect();
    const sibCs = window.getComputedStyle(sibling);

    // Compute overlap between sibling and disclosure
    const overlapLeft = Math.max(rect.left, sibRect.left);
    const overlapRight = Math.min(rect.right, sibRect.right);
    const overlapTop = Math.max(rect.top, sibRect.top);
    const overlapBottom = Math.min(rect.bottom, sibRect.bottom);

    if (overlapRight > overlapLeft && overlapBottom > overlapTop) {
      const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop);
      const disclosureArea = (rect.right - rect.left) * (rect.bottom - rect.top);
      const overlapFraction = disclosureArea > 0 ? overlapArea / disclosureArea : 0;

      const hasBg = sibCs.backgroundColor !== 'rgba(0, 0, 0, 0)' || sibCs.backgroundImage !== 'none';
      const marginTop = parseFloat(sibCs.marginTop);
      const hasNegativeMargin = marginTop < 0;

      if (overlapFraction > 0.1 && (hasBg || hasNegativeMargin)) {
        findings.push({
          sibling: sibling.className || sibling.tagName,
          overlapFraction: (overlapFraction * 100).toFixed(1) + '%',
          siblingBackground: sibCs.backgroundColor,
          siblingMarginTop: marginTop,
          reason: 'sibling overlaps ' + (overlapFraction * 100).toFixed(1) + '% of disclosure area',
        });
      }
    }
  }

  if (findings.length > 0) {
    return { occluded: true, findings };
  }

  return { occluded: false };
}

Margin collapse interaction: CSS vertical margin collapse means that in some layout contexts, a large negative margin on one element can collapse with the adjacent positive margin of the next element, producing a net displacement smaller than the raw margin value. Detection functions should read getBoundingClientRect() (which reflects the post-collapse computed position) rather than assuming the raw marginTop value directly equals the displacement.

Attack summary

Attack Injected property Guard bypassed Detection point Severity
Vertical off-screen pull margin-top: -100vh offsetTop, offsetHeight > 0 getBoundingClientRect().bottom ≤ 0; computed marginTop < -50 High
RTL horizontal off-right shift direction: rtl; margin-right: -100% LTR-only viewport checks getBoundingClientRect().left ≥ vp.width; computed direction + marginRight High
Fixed-position viewport bypass position: fixed; margin-top: -100vh Assumption that fixed elements are always in viewport getBoundingClientRect() outside (0,0,vp.w,vp.h) High
Partial sibling cover Sibling with margin-top: -60px; background covering disclosure top Disclosure itself has no suspicious styles Overlap area calculation between disclosure and painted siblings High

Consolidated finding blocks

High Vertical negative margin displacement: margin-top: -100vh (or equivalent percentage) moves the consent disclosure above the viewport top edge. offsetTop and offsetHeight return positive values. Detectable only via getBoundingClientRect().bottom ≤ 0 and computed marginTop inspection.
High RTL horizontal displacement: direction: rtl; margin-right: -100% shifts disclosure past the right edge of the viewport. LTR-based guard checks that only test top/bottom positioning do not detect this vector. Requires checking getBoundingClientRect().left ≥ viewport.width and resolved direction value.
High Fixed-position viewport bypass: position: fixed; margin-top: -100vh permanently places the disclosure above the viewport where it cannot be scrolled into view. Defeats the security assumption that position: fixed guarantees viewport visibility. Detectable via getBoundingClientRect() bounds check on confirmed fixed elements.
High Partial sibling cover via negative margin: A sibling element uses negative margin to overlap the upper portion of the consent disclosure where permission names are listed. The disclosure element itself has no suspicious CSS. Detectable by computing overlap area between the disclosure's bounding rect and painted sibling elements.

Why negative margins escape standard security scanning

Standard CSS security scanning for MCP servers typically focuses on properties known to hide elements: display: none, visibility: hidden, opacity: 0, clip-path: inset(100%), and explicit off-screen positioning via left: -9999px or top: -9999px. Negative margins are categorically different from all of these because they do not hide the element in any sense that the DOM API reports — they physically displace it to a location where the user cannot see it, while the element continues to be fully present and fully sized in the document.

Additionally, negative margins are extremely common in legitimate web design. Overlapping card layouts, pull-quote positioning, bleed effects, and column alignment all use negative margins in normal development. A static scanner that flags any negative margin will produce prohibitively high false positive rates on legitimate content. The attack can only be identified by combining the negative margin value with a viewport-relative position check — an analysis that requires layout computation, not just property inspection.

The RTL variant and the fixed-position variant are particularly underexplored in existing MCP security literature because they attack assumptions about layout direction and positioning that are so fundamental that most security engineers do not think to question them. No widely-used MCP security scanner at the time of this writing checks for RTL-specific margin displacement or verifies the viewport-relative position of position: fixed consent disclosures.

Recommended audit procedure

1. After the MCP consent disclosure renders, call el.getBoundingClientRect() and check that at least 80% of the element's area falls within the viewport rectangle (0, 0, window.innerWidth, window.innerHeight). Flag any disclosure where the majority of the bounding rect is outside the viewport.

2. Read getComputedStyle(el).marginTop, marginBottom, marginLeft, and marginRight and flag any margin more negative than –20px on a consent disclosure element.

3. Check getComputedStyle(el).direction and the document's writing direction. For any disclosure where the resolved direction is rtl, verify that the element's right edge (from getBoundingClientRect().right) is within the viewport width.

4. For position: fixed disclosures, explicitly verify the getBoundingClientRect() bounds. Do not assume fixed elements are within the viewport — always check.

5. Compute the overlap area between the disclosure's bounding rect and the bounding rects of all sibling DOM elements. Flag any sibling that overlaps more than 10% of the disclosure area and has non-transparent background paint.

← Blog  |  Security Checklist