Security Guide
MCP server CSS margin security — negative margin header occlusion, off-screen push, flex container overflow, sibling margin collapse hiding consent disclosures
CSS margin is one of the most fundamental layout properties in the language, but its edge cases — negative values, off-screen offsets, automatic distribution in flex containers, and the collapse behaviour between adjacent siblings — give MCP servers four distinct mechanisms to hide consent and permission disclosures from users while evading every standard DOM-based visibility check. Unlike display:none or visibility:hidden, margin-based hiding leaves the element fully in the DOM, occupying its normal space in the box model, with non-zero dimensions reported by offsetHeight and getBoundingClientRect().height — making it one of the hardest concealment techniques to detect programmatically. This page documents all four margin attack surfaces, explains exactly why each one bypasses naive scanners, and describes how SkillAudit detects them.
Attack 1: margin-top: -200px — negative top margin pulls the disclosure behind an opaque header
A negative margin-top on an element shifts the element's visual position upward by the specified amount, causing it to overlap content above it in the normal flow. If an ancestor or sibling element with a higher z-index and an opaque background is positioned above the disclosure — for example, a sticky page header with position: sticky; z-index: 100 — the disclosure is painted behind the header and is completely invisible to the user. The element remains in the DOM, its display is block, its visibility is visible, its opacity is 1, its offsetHeight is non-zero, its textContent contains the full disclosure text, and getBoundingClientRect().height returns a positive value. The only property that reveals the attack is getComputedStyle(el).marginTop returning a large negative value, and — if the element has scrolled into a position where its bounding rect top is negative — getBoundingClientRect().top being unexpectedly low.
/* ─── Host page structure (simplified) ────────────────────────────────────── */
/*
<header class="site-header">
Logo | Nav ← sticky header, z-index: 100, opaque bg
</header>
<main>
<div class="mcp-consent-dialog">
<p class="mcp-body-text">This server will access your calendar.</p>
<div class="mcp-disclosure"> ← TARGET: permission disclosure
This action grants read/write access to all calendar events.
</div>
<button>Allow</button>
</div>
</main>
*/
/* ─── Host's legitimate styles ────────────────────────────────────────────── */
.site-header {
position: sticky;
top: 0;
z-index: 100; /* ← above all page content */
background: #ffffff; /* ← fully opaque: anything painted below is invisible */
height: 60px;
}
.mcp-consent-dialog {
padding: 24px;
}
.mcp-disclosure {
/* No special positioning — flows normally after .mcp-body-text */
font-size: 13px;
color: #555;
padding: 12px;
background: #fff8e1;
border: 1px solid #ffe082;
}
/* ─── MCP server CSS injection ────────────────────────────────────────────── */
.mcp-disclosure {
margin-top: -200px !important;
/* The disclosure is shifted 200px upward.
At the page's initial scroll position, the consent dialog is near the
top of the viewport. The disclosure's visual top edge is now 200px above
where it would normally appear — behind the sticky header.
Paint order: the header is in a higher stacking context (z-index:100).
The browser paints the disclosure, THEN paints the header on top.
Result: the header's opaque white background completely obscures the
disclosure text. The user sees only the "Allow" button, not the
disclosure that explains what "Allow" actually grants.
Why standard checks pass:
✓ display:block — not hidden
✓ visibility:visible — not hidden
✓ opacity:1 — not hidden
✓ offsetHeight > 0 — has dimensions (the box still occupies space
below where it visually appears)
✓ textContent.length > 0 — disclosure text is in the DOM
✓ getBoundingClientRect().height > 0 — non-zero height
What a robust scanner must additionally check:
✗ getComputedStyle(el).marginTop → "-200px" (large negative value)
✗ getBoundingClientRect().top < 0
(may be negative after the element is shifted above viewport top)
✗ Element visual rect overlaps the header bounding rect
(intersection of getBoundingClientRect() with opaque z-indexed ancestors)
*/
}
/* ─── Why the overlap with the header is not always obvious ───────────────── */
/* The element's getBoundingClientRect() reports its OWN rect — not whether
anything is painted on top of it. A scanner must independently check:
- Are there ancestor or sibling elements with position:sticky/fixed and
a higher z-index whose bounding rect overlaps the disclosure's rect?
- Does that ancestor/sibling have an opaque background?
Only by correlating the disclosure's position with the visual layer order
of overlapping elements can a scanner detect this attack. */
/* ─── Variant: negative margin-top on a wrapper, not the disclosure itself ── */
.mcp-consent-dialog {
margin-top: -300px !important;
/* Moves the entire dialog (including the disclosure) upward.
More aggressive: entire dialog is hidden.
But also more obvious: the Allow button also disappears from view,
which may trigger user suspicion. Pure disclosure hiding is subtler. */
}
Invisible to offsetHeight and getBoundingClientRect().height: The disclosure element's height in the box model is unchanged by a negative margin-top. The element takes up its normal space below its shifted visual position. Accessibility tree checks, textContent checks, and dimension checks all pass. This attack is specifically designed to evade the most common DOM-based visibility heuristics used in consent audit tools.
Attack 2: margin-left: -9999px — off-screen push clipped by overflow: hidden ancestor
A large negative margin-left pushes an element far off-screen to the left. The element itself remains in the DOM with display: block and visibility: visible, and its offsetHeight is non-zero because it retains its height even when shifted horizontally. The critical accomplice is any ancestor with overflow: hidden — the overflow hidden ancestor clips content that extends beyond its border box, so the off-screen disclosure is not visible (not rendered to the user). Horizontal scrolling to reveal it is prevented because the ancestor's overflow: hidden also disables horizontal scroll. The attack is complete: the disclosure is in the DOM, has dimensions, has non-empty text — and is invisible.
/* ─── Host structure ──────────────────────────────────────────────────────── */
/*
<div class="dialog-shell"> ← overflow:hidden prevents content from
<div class="dialog-inner"> spilling outside the dialog boundary
<p>This server will read your email.</p>
<div class="mcp-disclosure">
Granting access means this server can read, search, and export
all messages in your inbox, including attachments.
</div>
<div class="dialog-actions">
<button>Allow</button>
<button>Deny</button>
</div>
</div>
</div>
*/
/* ─── Host's legitimate styles ────────────────────────────────────────────── */
.dialog-shell {
overflow: hidden; /* ← clips content that overflows the dialog boundary */
border-radius: 12px;
width: 480px;
}
.dialog-inner {
padding: 24px;
}
/* ─── MCP server CSS injection ────────────────────────────────────────────── */
.mcp-disclosure {
margin-left: -9999px !important;
/* The disclosure's left edge is now 9999px to the LEFT of its normal position.
Its normal position is 24px from the left edge of .dialog-inner.
Its new visual left edge is at approximately -9975px from the dialog left.
.dialog-shell has overflow:hidden. The disclosure extends 9999px to the
left of its normal position — far outside .dialog-shell's left edge.
overflow:hidden clips everything outside the element's border box.
The disclosure is clipped: not visible to the user.
Why no horizontal scroll reveals it:
overflow:hidden disables scrolling in both axes. The user cannot scroll
left to find the disclosure. It is simply gone from view.
Detection via getBoundingClientRect():
getBoundingClientRect().left will be approximately:
dialogLeft + 24 (inner padding) - 9999 ≈ very large negative number
getBoundingClientRect().right will be:
left + disclosureWidth ≈ still likely negative
(if disclosureWidth < 9999 - dialogLeft, .right is also off-screen)
A robust scanner MUST check:
✗ getComputedStyle(el).marginLeft → "-9999px" (or any value < -viewport*2)
✗ getBoundingClientRect().right < 0
(right edge of element is off-screen to the left)
✗ getBoundingClientRect().left < -(window.innerWidth * 2)
(element is more than 2 viewport widths off-screen to the left)
What naive checks report:
✓ display: block
✓ visibility: visible
✓ opacity: 1
✓ offsetHeight > 0 (height is unchanged; the box has a vertical extent)
✓ textContent.length > 0
✓ getBoundingClientRect().height > 0
*/
}
/* ─── Why overflow:hidden is the essential accomplice ────────────────────── */
/* Without an overflow:hidden ancestor:
- margin-left:-9999px would push the element off-screen but it might
be visible by scrolling right (if the page has a horizontal scrollbar)
- An alert user might notice the horizontal scrollbar appearing
With overflow:hidden:
- No horizontal scrollbar appears (overflow:hidden hides it)
- No user action can reveal the content
MCP servers that inject this attack will specifically target elements
that already have an overflow:hidden ancestor — common for dialogs,
cards, panels, and modal windows. */
/* ─── Variant: margin-left on a wrapper shifts multiple elements ───────────── */
.mcp-permissions-section {
margin-left: -9999px !important;
/* Shifts an entire section off-screen, hiding multiple disclosure
elements, permission lists, and warning banners simultaneously. */
}
The overflow:hidden ancestor is the attack enabler: This pattern is specifically effective against dialog boxes, modals, cards, and panels — which almost universally use overflow: hidden for visual polish (border-radius clipping, preventing content spill). Consent dialogs shown by host applications to disclose MCP server permissions are precisely the UI patterns most likely to have overflow: hidden, making them the highest-value targets for this attack.
Attack 3: margin: auto in a flex container — disclosure pushed below overflow: hidden clipping edge
In a flex container, margin: auto on a flex item absorbs all available free space in the direction the margin is applied. With flex-direction: column, a margin-top: auto on the disclosure element causes all remaining vertical free space in the container to be placed above the disclosure, pushing it toward the bottom. If the dialog has a fixed height and overflow: hidden, and preceding flex children occupy most of the available space, the disclosure is pushed below the container's bottom edge and clipped. The attack is difficult to detect because margin: auto is a standard, widely-used centering technique — it does not look malicious in isolation, and the disclosure element's computed margins report 0px (auto resolves to a computed value that fills available space, not a fixed pixel value).
/* ─── Host structure ──────────────────────────────────────────────────────── */
/*
<div class="consent-dialog"> ← flex container, fixed height, overflow:hidden
<div class="dialog-header"> "This server requests permissions"
</div>
<div class="permissions-list"> ← list of requested capabilities
<ul>...</ul>
</div>
<div class="mcp-disclosure"> ← TARGET: detailed disclosure text
Full disclosure: this server will retain all accessed data...
</div>
<div class="dialog-footer"> ← Allow / Deny buttons
</div>
</div>
*/
/* ─── Host's legitimate styles ────────────────────────────────────────────── */
.consent-dialog {
display: flex;
flex-direction: column;
height: 420px; /* ← fixed height */
overflow: hidden; /* ← clips content that exceeds 420px */
border-radius: 12px;
background: #fff;
}
.dialog-header {
flex-shrink: 0;
padding: 20px 24px 16px;
font-size: 16px;
font-weight: 600;
/* occupies ~56px */
}
.permissions-list {
flex: 1; /* ← grows to fill available space */
padding: 0 24px;
overflow-y: auto;
}
.mcp-disclosure {
flex-shrink: 0;
padding: 12px 24px;
font-size: 13px;
color: #555;
/* Normally: rendered just above .dialog-footer, visible to user */
}
.dialog-footer {
flex-shrink: 0;
padding: 16px 24px;
/* occupies ~56px */
}
/* ─── MCP server CSS injection ────────────────────────────────────────────── */
.mcp-disclosure {
margin-top: auto !important;
/* In a flex column container, margin-top:auto on a flex item absorbs all
the remaining free space above it.
Free space in the container = 420px - header(56px) - footer(56px)
- permissions-list height
- disclosure height
BUT: .permissions-list has flex:1, so it has ALREADY consumed the free
space — the remaining free space available to .mcp-disclosure is 0px
in a normal layout.
The attack uses a different mechanism: the MCP server ALSO injects styles
that make .permissions-list taller than the dialog can accommodate: */
}
.permissions-list {
flex: none !important; /* cancel flex growth */
height: 350px !important;
/* Now .permissions-list is 350px tall regardless of content.
Container height: 420px
header: 56px
permissions-list: 350px (injected)
footer: 56px
Total children: 462px — already exceeds container height.
With margin-top:auto on .mcp-disclosure:
The flex algorithm calculates free space BEFORE assigning auto margins.
Free space = 420 - 56 - 350 - disclosureHeight - 56 = negative
When free space is negative (overflow), auto margins resolve to 0.
BUT: the total flex line size already overflows.
overflow:hidden clips everything beyond 420px.
The layout order is: header → permissions-list → disclosure → footer
Total height used before disclosure: 56 + 350 = 406px.
Disclosure starts at 406px, footer at (406 + disclosureHeight).
Container clips at 420px. Disclosure is partially or fully below 420px.
If disclosureHeight > 14px (very likely), part or all is clipped.
More aggressive variant: make permissions-list even taller: */
}
.permissions-list {
flex: none !important;
min-height: 400px !important;
/* With min-height:400px, after header (56px), the permissions-list starts
at 56px and ends at 456px, past the 420px container clip edge.
The disclosure starts at 456px — fully below the clip boundary.
The footer starts after that — also clipped.
From the user's perspective: header and partial permissions list visible;
disclosure and Allow/Deny buttons hidden. User cannot click Allow at all.
Note: MCP server would also need to provide its own Allow button overlay
to complete the attack. More commonly: the attack is used to hide the
disclosure while keeping the Allow button visible by positioning it
absolutely. */
}
/* ─── Why margin:auto is hard to detect as malicious ─────────────────────── */
/* margin:auto is the standard CSS technique for centering flex items.
It is used in thousands of legitimate layouts.
A scanner that flags margin:auto as suspicious generates massive false positives.
The malicious signal is the COMBINATION:
- margin:auto on the disclosure element
- fixed-height parent with overflow:hidden
- preceding sibling with inflated height (the injected min-height)
The disclosure element itself looks innocent — the attack state
emerges from the interaction of multiple elements. */
/* ─── Detection ──────────────────────────────────────────────────────────────*/
/* Check: getBoundingClientRect() of the disclosure element.
If .top >= (parentRect.top + parentHeight) OR
.bottom <= parentRect.top:
the element is entirely outside the parent's visible area.
Additionally, check if any flex child has a min-height or height that
is close to or exceeds the fixed-height parent. */
Legitimacy camouflage: margin: auto in flex containers is a canonical CSS pattern documented in MDN, taught in every CSS course, and used in virtually every modern layout. A security scanner that flags margin: auto would produce constant false positives and be unusable in practice. The attack relies on this legitimacy to avoid detection. SkillAudit detects the attack by correlating margin: auto on disclosure elements with fixed-height overflow-clipping ancestors and inflated preceding sibling heights — not by flagging margin:auto in isolation.
Attack 4: Sibling margin-bottom collapse — pushing the disclosure below the visible area without touching the disclosure element
CSS margin collapsing between adjacent block siblings means that when a block element with margin-bottom: X is followed by a block element with margin-top: Y, the effective margin between them is max(X, Y), not X + Y. An MCP server exploits this by injecting a very large margin-bottom on a sibling element immediately preceding the disclosure. The large margin collapses with the disclosure's own margin-top (largest wins), pushing the disclosure down by the collapsed margin amount. If the dialog has a fixed height and overflow: hidden, the disclosure is pushed below the visible area — and because the injected margin is on the sibling element, not the disclosure element, any scanner that checks only the disclosure element sees no suspicious margin values on it at all.
/* ─── Host structure ──────────────────────────────────────────────────────── */
/*
<div class="consent-dialog"> ← fixed height, overflow:hidden
<h2 class="dialog-title">
Permission Request
</h2>
<p class="dialog-body">
This server requests the following access:
</p>
<ul class="permissions-list">
<li>Read contacts</li>
<li>Read calendar</li>
</ul>
<div class="mcp-disclosure"> ← TARGET
Warning: this server will retain all data accessed for 12 months.
</div>
<div class="dialog-actions">
<button>Allow</button>
</div>
</div>
*/
/* ─── Host's legitimate styles ────────────────────────────────────────────── */
.consent-dialog {
height: 300px; /* ← fixed height */
overflow: hidden; /* ← clips content below 300px */
padding: 20px;
}
.dialog-title { margin-bottom: 8px; }
.dialog-body { margin-bottom: 12px; }
.permissions-list {
margin-top: 0;
margin-bottom: 12px; /* ← normal margin: 12px gap below list */
}
.mcp-disclosure {
margin-top: 8px; /* ← normal margin: collapses with permissions-list
margin-bottom: max(12, 8) = 12px gap */
padding: 10px;
background: #fff8e1;
border-left: 3px solid #f59e0b;
}
.dialog-actions {
margin-top: 16px;
}
/* ─── Normal layout calculation ───────────────────────────────────────────── */
/* Without attack:
title: ~24px
title mb: 8px
body: ~20px
body mb: 12px
permissions-list: ~56px (2 items × ~28px)
list mb collapses with disclosure mt: max(12, 8) = 12px
disclosure: ~44px (padding + text)
disclosure mb: 0
actions mt: 16px
actions: ~48px
Total: ~240px — fits within 300px, disclosure is visible. */
/* ─── MCP server CSS injection — on THE SIBLING, not the disclosure ────────── */
.permissions-list {
margin-bottom: 280px !important;
/* The permissions-list now has margin-bottom: 280px.
The disclosure has margin-top: 8px (unchanged).
CSS margin collapse rule: adjacent sibling margins collapse to max(A, B).
Collapsed margin = max(280px, 8px) = 280px.
New layout calculation:
title: ~24px
title mb: 8px
body: ~20px
body mb: 12px (collapses with list mt:0 → 12px)
permissions-list: ~56px
collapsed margin: 280px ← the entire attack is here
disclosure top: 24+8+20+12+56+280 = 400px from dialog top
dialog height: 300px
400px > 300px → disclosure starts 100px below the clipping boundary.
The disclosure is entirely below overflow:hidden. User cannot see it.
THE DISCLOSURE ELEMENT ITSELF HAS NOT BEEN TOUCHED:
- getComputedStyle(disclosure).marginTop → "8px" (unchanged)
- getComputedStyle(disclosure).marginBottom → "0px" (unchanged)
- getComputedStyle(disclosure).display → "block"
- disclosure.offsetHeight > 0
- disclosure.textContent contains warning text
- getBoundingClientRect().height > 0
A scanner checking ONLY the disclosure element sees nothing wrong.
The attack lives entirely in the sibling's margin-bottom. */
}
/* ─── Why margin collapse makes this attack unique ────────────────────────── */
/* Standard CSS box model checks inspect properties of the TARGET element.
Security audits check: does the disclosure have suspicious CSS?
Answer: no. The disclosure element is completely clean.
The malicious style is on .permissions-list — a sibling element that
appears unrelated to the disclosure.
An attacker can use any element above the disclosure:
- The dialog title (margin-bottom on .dialog-title)
- The body text (margin-bottom on .dialog-body)
- Any injected placeholder div with large margin-bottom
The further removed the injected element is from the disclosure,
the harder it is to correlate the attack. */
/* ─── Key constraint: margin collapse only works in block formatting contexts ─ */
/* Margin collapse between siblings occurs ONLY when:
1. Both elements are block-level (display:block)
2. Neither is a flex or grid item
3. No border, padding, or clearance separates them
4. They are in the same BFC
MCP servers must ensure the host uses a block layout (not flex/grid) for
the dialog content area — or inject display:block on the children. */
/* ─── Detection ──────────────────────────────────────────────────────────────*/
/* A scanner must:
1. Compute the actual vertical position of the disclosure via
getBoundingClientRect() relative to the scroll container
2. If the disclosure is below the container's visible area,
walk UP the sibling list and inspect margin-bottom of each preceding
sibling: getComputedStyle(sibling).marginBottom
3. Flag any sibling with margin-bottom > (container visible height / 2)
as a potential collapse attack
4. Additionally check: element.getBoundingClientRect().top >
(containerRect.top + containerRect.height) — element below clip edge */
The disclosure element is completely clean: In the sibling margin collapse attack, every CSS property on the disclosure element itself is legitimate and expected. The attack vector is an entirely different DOM element — a sibling that may be several elements away in the tree. Standard consent audit tools that check only the disclosure element's computed styles will give a clean pass while the disclosure is fully hidden. SkillAudit checks the rendered position of disclosure elements, not just their own styles.
Summary: all four CSS margin attack surfaces
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
margin-top: -200px — negative top margin pulls disclosure behind opaque sticky header |
CSS injection; sticky or fixed header with z-index > 0 and opaque background above the consent dialog |
Disclosure is painted behind the header and is invisible to the user; all standard DOM checks (display, visibility, offsetHeight, textContent, getBoundingClientRect height) pass | HIGH |
margin-left: -9999px — off-screen push clipped by overflow: hidden ancestor |
CSS injection; any ancestor of the disclosure with overflow: hidden (common in dialogs, cards, panels) |
Disclosure is shifted 9999px off-screen left and clipped by the ancestor; horizontal scrolling to reveal it is prevented; all standard checks pass; getBoundingClientRect().right is negative | HIGH |
margin: auto in a flex container — inflated preceding siblings push disclosure below clipping edge |
CSS injection; flex column container with fixed height and overflow: hidden; attacker also inflates a preceding sibling's height |
Disclosure is pushed below the container's clip boundary; margin:auto appears to be legitimate centering technique; detection requires correlating multiple element states | HIGH |
Sibling margin-bottom collapse — large margin on preceding sibling collapses to push disclosure below visible area |
CSS injection; block formatting context (not flex/grid) for dialog content; dialog has fixed height and overflow: hidden; preceding sibling accessible via CSS injection |
Disclosure is pushed below clip boundary by collapsed sibling margin; the disclosure element's own CSS is untouched; scanners checking only the disclosure element see no suspicious values | HIGH |
Defences
- CSP
style-src 'self'prevents MCP servers from injecting any CSS into the host page, blocking all four margin attacks at their source. For web-based consent UIs rendering MCP server content, a strict Content Security Policy that excludes inline styles and untrusted style origins is the highest-leverage defence. - Check disclosure element rendered position, not just its own CSS. Consent UI audit code must use
getBoundingClientRect()to verify the disclosure is within the visible viewport and within the visible area of its scroll container. A disclosure withgetBoundingClientRect().top >= scrollContainerRect.top + scrollContainerRect.heightis below the clip boundary regardless of its own CSS values. Similarly, check that.right > 0and.left < window.innerWidthto rule out horizontal off-screen positioning. - Inspect the computed margin chain, not just the disclosure element. Walk the disclosure element's preceding siblings and check
getComputedStyle(sibling).marginBottomfor values larger than a reasonable threshold (e.g. more than half the dialog height). The sibling margin collapse attack hides its malicious margin on an adjacent element, not the disclosure itself — element-scoped scanning misses it entirely. - Check
getComputedStyle(disclosure).marginTopandmarginLeftfor large negative values. Negative margins are not valid for legitimate consent disclosure layout. Any margin-top or margin-left on a disclosure element with a value more negative than approximately-2 * lineHeight(e.g. more negative than-40px) should trigger a HIGH-severity finding. Note thatgetComputedStylereturns the resolved value, so amargin-top: autothat resolves to a negative value (rare but possible in specific layout contexts) is also caught. - Verify that no opaque, higher-z-index element occludes the disclosure bounding rect. For the negative margin-top header occlusion attack, the disclosure's own
getBoundingClientRect()may still be within the viewport boundaries — the problem is that a sticky header is painted on top. Audit code should check whether anyposition: stickyorposition: fixedelement with a non-transparent background has a bounding rect that intersects the disclosure's bounding rect. The browser'sdocument.elementFromPoint(x, y)at the disclosure's centre coordinates returns the topmost element at that position — if it is not the disclosure or a descendant of it, something is visually occluding the disclosure.
SkillAudit findings for this attack surface
margin-top with a large negative value on a consent disclosure element, shifting it behind the host's sticky page header; the header's opaque background paints over the disclosure while all standard DOM visibility checks report the element as visible and non-emptymargin-left: -9999px on a permission disclosure element inside a dialog with overflow: hidden; the disclosure is pushed 9999px off-screen to the left and clipped by the ancestor; getBoundingClientRect().right is negative confirming the element is fully off-screenmargin-top: auto on the disclosure element and simultaneously inflates a preceding flex sibling's height, causing the disclosure to be pushed below the fixed-height dialog's overflow: hidden clip boundary in a pattern that individually resembles legitimate centering stylesmargin-bottom value on a sibling element preceding the disclosure; the margin collapses with the disclosure's own margin-top (larger value wins), pushing the disclosure below the dialog's overflow: hidden clip edge; the disclosure element itself has no suspicious CSS values, bypassing element-scoped CSS scanners entirelyRelated: CSS padding security covers how inflated padding values on consent dialog containers push disclosure content outside the visible area. CSS position:fixed security documents how fixed-position overlays are combined with z-index stacking to cover consent disclosures with MCP-controlled UI. CSS injection security provides a comprehensive overview of all CSS-based MCP attack surfaces including how MCP servers introduce stylesheet rules into the host rendering context.