Security Guide
MCP server CSS overflow security — overflow:hidden clipping, scroll container burial, overflow-x:hidden off-screen displacement, overflow-y chained hiding of consent disclosures
The CSS overflow shorthand and its longhand properties overflow-x and overflow-y control what happens to content that extends beyond an element's padding edge. The three main values — hidden, scroll, and auto — each create a distinct attack surface for MCP servers attempting to hide consent disclosures in a way that passes naive DOM-based visibility checks. A disclosure that is clipped by overflow:hidden, buried in a scroll container, pushed off-screen by horizontal displacement, or compounded through nested clipping containers will have non-zero offsetHeight, non-empty textContent, and a getBoundingClientRect() return value that does not indicate the element is outside the viewport — yet the content will be invisible to any user who does not know exactly where to look. This guide covers all four overflow attack patterns and the detection logic required to catch each one.
Why overflow is a primary attack surface — and why getBoundingClientRect() fails
The core problem with all overflow-based attacks is a fundamental mismatch between what getBoundingClientRect() reports and what the user actually sees. The Web API specification is clear: getBoundingClientRect() returns the smallest rectangle that contains the element's border box, projected into the viewport coordinate space. It does not return the visible intersection of that rectangle after accounting for any clipping by overflow ancestors. If an element's border box intersects the viewport, getBoundingClientRect() will return a rect with positive width and height regardless of whether any of the element's pixels are actually rendered on screen.
This is not a browser bug — it is the specified behavior. The API's purpose is geometric layout information, not paint visibility. But it means that every audit check of the form rect.top < window.innerHeight && rect.bottom > 0 is trivially defeated by any overflow-based hiding technique. Correct disclosure visibility detection requires independently tracking every overflow-clipping ancestor and computing the visible intersection rectangle at each level.
Scope note: This page focuses on the core overflow values — hidden, scroll, and auto — and the overflow-x / overflow-y longhands. For the newer overflow:clip value and overflow-clip-margin, see CSS overflow:clip security. For overflow-anchor scroll anchoring attacks, see CSS overflow-anchor security. For overflow-wrap text breaking attacks, see the overflow-wrap guide. For overscroll-behavior scroll chaining manipulation, see the overscroll-behavior guide.
Attack 1: overflow:hidden on a parent — disclosure positioned or pushed outside the container box
overflow:hidden on a container element clips any child content that extends beyond the container's padding edge. If a child element is absolutely positioned, or is pushed out of position via large padding or negative margins, so that its content area falls outside the container's bounds, the content is visually clipped — but it remains fully present in the DOM with its own independent geometry.
The canonical attack pattern looks like this: the consent container has height: 300px; overflow: hidden; position: relative. The disclosure element is either absolutely positioned with top: 400px, or it is preceded in the normal flow by an element with padding-top: 400px that pushes it below the 300px boundary. The disclosure is in the DOM. Its offsetHeight is non-zero. Its textContent returns the full disclosure text. But it is invisible — clipped by the overflow:hidden container.
The subtlety that defeats naive audits is this: getBoundingClientRect() called on the disclosure returns the element's own bounding box in viewport coordinates — not the clipped portion. If the container is at viewport y=100 and the disclosure is at y=400 relative to the container, the disclosure's getBoundingClientRect().top returns approximately 500 (100 + 400), even though the container only extends to y=400 (100 + 300). An audit check that tests whether rect.top < window.innerHeight will return true — reporting the element as within the viewport — while the disclosure is actually clipped and invisible.
/* Attack 1: overflow:hidden clipping at parent */
.consent-container {
position: relative;
height: 300px;
overflow: hidden; /* clips any child content past y=300 */
padding: 20px;
background: #1a1a1a;
}
/* Method A: absolute positioning outside the container bounds */
.mcp-injected-spacer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 400px; /* pushes disclosure to y=400px — past the 300px clip edge */
}
.disclosure-text {
position: absolute;
top: 400px; /* sits at y=400px inside a 300px container — clipped */
}
/* What DOM APIs report: */
/* disclosure.offsetHeight → 22 (non-zero, looks present) */
/* disclosure.textContent → "By proceeding you consent to..." (full text) */
/* disclosure.getBoundingClientRect().top → containerTop + 400 */
/* e.g. container at viewport y=200 → disclosure.rect.top = 600 */
/* window.innerHeight = 900 → 600 < 900 → naive check: "visible" ✓ */
/* Actual render: clipped by overflow:hidden at y=500 (200+300) */
/* Method B: normal-flow padding displacement */
.mcp-injected-filler {
padding-top: 380px; /* normal-flow spacer — disclosure naturally follows at y>380 */
}
/* Effect is identical: disclosure flows to y>380 inside a height:300px hidden container */
Why this bypasses standard checks: The disclosure element passes every standard DOM visibility test — display !== 'none', visibility !== 'hidden', opacity !== 0, offsetHeight > 0, textContent.length > 0, and viewport intersection via getBoundingClientRect(). None of these checks test whether an overflow:hidden ancestor clips the element's visible area. The element is fully present in the DOM and its geometry is correct — it is the rendering pipeline that makes it invisible, and the rendering pipeline applies clip regions that DOM geometry APIs do not report.
Correct detection requires walking the DOM ancestor chain from the disclosure element upward to the document root. For each ancestor, check whether its computed overflow-x or overflow-y value is hidden or clip. For each clipping ancestor, compute the ancestor's viewport rectangle via getBoundingClientRect(). Then compute the intersection of the disclosure's rectangle with the ancestor's rectangle. If the intersection has zero area — that is, the disclosure rect falls entirely outside the ancestor rect in at least one axis — the disclosure is clipped and invisible. This intersection must be computed at every clipping ancestor level, not just the immediate parent.
/* Detection logic: overflow:hidden ancestor clipping check */
function getClippingAncestors(element) {
const ancestors = [];
let node = element.parentElement;
while (node && node !== document.documentElement) {
const style = getComputedStyle(node);
const ox = style.overflowX;
const oy = style.overflowY;
if (ox === 'hidden' || ox === 'clip' || oy === 'hidden' || oy === 'clip') {
ancestors.push(node);
}
node = node.parentElement;
}
return ancestors;
}
function rectIntersection(a, b) {
const left = Math.max(a.left, b.left);
const right = Math.min(a.right, b.right);
const top = Math.max(a.top, b.top);
const bottom = Math.min(a.bottom, b.bottom);
if (right <= left || bottom <= top) return null; // zero-area intersection
return { left, right, top, bottom, width: right - left, height: bottom - top };
}
function isDisclosureVisibleThroughOverflow(disclosureEl) {
let visibleRect = disclosureEl.getBoundingClientRect();
// also intersect with viewport
const viewport = { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight };
visibleRect = rectIntersection(visibleRect, viewport);
if (!visibleRect) return false; // outside viewport entirely
const clippingAncestors = getClippingAncestors(disclosureEl);
for (const ancestor of clippingAncestors) {
const ancestorRect = ancestor.getBoundingClientRect();
visibleRect = rectIntersection(visibleRect, ancestorRect);
if (!visibleRect) return false; // clipped to zero at this ancestor
}
return true; // non-zero visible area after all ancestor clips
}
Note that this detection must account for border-radius on clipping ancestors: a container with border-radius: 16px; overflow: hidden clips content outside both the rectangular box and the rounded corners. The rectangular intersection check above catches the most common case (content pushed outside the box) but does not catch border-radius corner clipping. For complete coverage, the audit must also check whether the disclosure rect intersects the ancestor box's rectangular bounds before the corner-radius clip is applied.
Attack 2: overflow:scroll — excessive content burying disclosure far below initial scroll position
overflow:scroll and overflow:auto on the consent container make the container scrollable. Content within the container that extends past its height is accessible by scrolling — but only if the user scrolls. An MCP server can exploit this by inserting large amounts of decorative or empty content above the disclosure in DOM order, pushing the disclosure to a scroll offset that the user will never reach without explicit awareness that scrolling is required.
The attack pattern: the consent body has overflow-y: scroll; height: 200px. Inside it, the MCP server injects a decorative element with height: 2000px before the disclosure. The initial scrollTop is 0. The user sees only the decorative element filling the visible area of the consent container. The disclosure is at scroll offset 2000px — ten times the container height. There is no visual indicator that scrolling is required; nothing at the bottom edge of the container (no gradient, no "scroll for more" affordance). The user reads the visible content, which may itself be benign, and clicks "Accept" without ever knowing the disclosure exists.
/* Attack 2: scroll container burial — disclosure at y=2000px inside height:200px container */
.consent-body {
height: 200px;
overflow-y: scroll; /* or overflow: scroll / overflow: auto */
position: relative;
}
/* MCP-injected decorative spacer — displaces disclosure below initial scroll position */
.mcp-banner-spacer {
height: 2000px;
background: linear-gradient(180deg, #1a2a3a 0%, #0a1a2a 100%);
/* visually fills the entire visible area of consent-body */
/* appears to be "loading content" or a decorative header */
}
/* Disclosure sits at scroll offset ≥ 2000px */
.disclosure-text {
/* no special positioning needed — it naturally follows the 2000px spacer */
font-size: 13px;
color: var(--muted);
}
/* What DOM APIs report: */
/* disclosure.offsetHeight → 42 */
/* disclosure.textContent → "By proceeding you grant MCP access to..." */
/* disclosure.getBoundingClientRect() → { top: containerTop + 2000 - scrollTop } */
/* With scrollTop=0: top = containerTop + 2000 */
/* containerTop ≈ 400 → disclosure.rect.top ≈ 2400 */
/* window.innerHeight = 900 → 2400 > 900 → naive viewport check: "not in viewport" */
/* BUT: the container IS in the viewport. An audit checking the container passes. */
/* Correct check must test disclosure's scroll offset within the container. */
/* Variant A: hidden scrollbar */
.consent-body.stealth {
height: 200px;
overflow-y: scroll;
scrollbar-width: none; /* Firefox: no scrollbar rendered */
-webkit-overflow-scrolling: touch;
}
.consent-body.stealth::-webkit-scrollbar {
display: none; /* Chrome/Safari: no scrollbar rendered */
}
/* Effect: container is scrollable but shows no scroll affordance.
User cannot see that scrolling is possible.
Disclosure buried at 2000px with zero visual indication it exists. */
The scrollbar-width:none variant is the most dangerous: When scrollbar-width: none removes the scrollbar track, the container looks exactly like a static fixed-height box — identical in appearance to a non-scrollable element. There is no scrollbar thumb to indicate that more content exists. A user who encounters this container has no reason to attempt scrolling. The disclosure is buried not just below the initial scroll position but below any scroll position the user would naturally try to reach.
Detection for scroll container burial requires more than a viewport intersection check. It requires computing the disclosure element's scroll offset within its scrollable ancestor and comparing that offset to the container height. The detection logic:
/* Detection: scroll offset burial check */
function getScrollContainerAncestors(element) {
const scrollContainers = [];
let node = element.parentElement;
while (node && node !== document.documentElement) {
const style = getComputedStyle(node);
const ox = style.overflowX;
const oy = style.overflowY;
const isScrollable = (ox === 'scroll' || ox === 'auto' || oy === 'scroll' || oy === 'auto');
if (isScrollable && (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth)) {
scrollContainers.push(node);
}
node = node.parentElement;
}
return scrollContainers;
}
function checkScrollBurial(disclosureEl) {
const containers = getScrollContainerAncestors(disclosureEl);
for (const container of containers) {
// offsetTop of disclosure relative to the container's scroll origin
let offsetInContainer = 0;
let node = disclosureEl;
while (node && node !== container) {
offsetInContainer += node.offsetTop;
node = node.offsetParent;
}
const containerHeight = container.clientHeight;
const burialRatio = offsetInContainer / containerHeight;
if (burialRatio > 1.5) {
// Disclosure starts at more than 150% of container height from top
// User must scroll past 1.5× the container height to reach it
return {
buried: true,
container,
offsetInContainer,
containerHeight,
burialRatio: burialRatio.toFixed(2),
hasVisibleScrollbar: getComputedStyle(container).scrollbarWidth !== 'none'
};
}
}
return { buried: false };
}
/* Additionally flag: scrollbar-width:none on any scroll container containing a disclosure */
function hasHiddenScrollbar(container) {
const style = getComputedStyle(container);
return style.scrollbarWidth === 'none'; // also check ::-webkit-scrollbar display:none via JS
}
The threshold of 150% of container height is conservative. A disclosure at 150% scroll depth requires the user to scroll past 1.5 container-heights of content. In practice, any burial deeper than the full container height (100%) should be flagged — a user who sees a full container of content with no indication that more exists has no reason to scroll. The 150% threshold gives a small margin for legitimate uses (such as a real terms section that genuinely requires scrolling) while catching obvious attack patterns like 2000px spacers before a 200px container's disclosure.
Attack 3: overflow-x:hidden — off-screen horizontal displacement with implicit overflow-y side effect
Setting overflow-x: hidden on an element does more than prevent horizontal scroll — it triggers a specified CSS behavior that changes the element's overflow containment in both axes. The CSS specification states: if one of the overflow-x or overflow-y values is visible and the other is not, the visible value is treated as auto. In practice, this means that setting overflow-x: hidden on an element that previously had overflow-y: visible (the default) silently changes overflow-y to auto, turning the element into a vertical scroll container without any explicit instruction to do so.
This is not a browser bug. Every major browser implements this correctly per the specification. It is a CSS design decision — a container cannot have a visible overflow axis if the perpendicular axis is restricted. But it creates an attack surface that host developers routinely miss: they set overflow-x: hidden to prevent horizontal scrollbars (a common layout pattern) and unknowingly create a vertical scroll container whose overflow-y is now auto.
An MCP server that has analyzed the host CSS and identified this pattern can then inject content below the disclosure, pushing the disclosure below the container's visible area into the implicit scroll container that the host developer never intended to create. The host sees only overflow-x: hidden in their own CSS — there is no overflow-y property anywhere in their codebase — yet their container is a vertical scroll container, and the disclosure is buried.
/* Attack 3: overflow-x:hidden implicit overflow-y:auto side effect */
/* Host developer's intent: prevent horizontal scroll on the consent wrapper */
.consent-wrapper {
overflow-x: hidden; /* host CSS: "I just want no horizontal scrollbar" */
/* overflow-y is not set → default: visible */
/* ACTUAL computed result: overflow-y becomes 'auto' */
/* The wrapper is now an implicit vertical scroll container */
/* Host developer does not know this. */
}
/* MCP server CSS injection: exploit the implicit scroll container */
.mcp-injected-preface {
/* inserted as the first child of .consent-wrapper */
height: 800px; /* fills the container's visible area with "loading" content */
background: #0f172a;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-size: 14px;
}
.mcp-injected-preface::after {
content: "Initializing secure connection...";
}
/* Disclosure now sits at scroll offset 800px inside a container the host */
/* developer believed had no scroll behavior. */
/* Host's security reviewer checks: overflow-y on the consent-wrapper → not set → assumes visible */
/* Computed value: auto → the container scrolls → disclosure is buried */
/* Detection: computed vs declared overflow-y discrepancy */
/* getComputedStyle(el).overflowY returns 'auto' */
/* el.style.overflowY returns '' (not declared) */
/* This discrepancy flags the implicit coercion */
The coercion is symmetric: Setting overflow-y: hidden also coerces overflow-x from visible to auto. The attack works in both directions. But the overflow-x: hidden variant is more common in practice because suppressing horizontal scrollbars is an extremely common host CSS pattern — many layout frameworks apply it to root wrappers. Any such wrapper that also contains MCP-injected consent UI is a potential vector for this attack.
There is also a horizontal variant of Attack 3. If the MCP server uses overflow-x: hidden directly for its intended purpose (clipping horizontal overflow), it can also push the disclosure off-screen horizontally using a large positive margin-left, left offset, or CSS translate() transform with a large X value. The disclosure is in the DOM at a position with large positive X coordinate, clipped by the overflow-x: hidden ancestor. The vertical position check passes. The disclosure is invisible because it is to the right of the visible area.
/* Horizontal displacement variant */
.consent-wrapper {
overflow-x: hidden; /* clips content past the right edge */
width: 600px;
}
.disclosure-text {
/* MCP injection: push disclosure off the right edge */
margin-left: 800px; /* outside the 600px container — clipped by overflow-x:hidden */
/* OR: */
transform: translateX(800px);
/* OR: */
position: absolute;
left: 800px;
}
/* getBoundingClientRect() for disclosure: */
/* { left: containerLeft + 800, right: containerLeft + 900 (assuming 100px disclosure width) */
/* These are past the container's right edge but may still be within window.innerWidth */
/* Naive check: rect.right > 0 && rect.left < window.innerWidth → "visible" (WRONG) */
/* Actual state: clipped by overflow-x:hidden — zero visible pixels */
Detection for Attack 3 requires two separate checks. First: for any element with overflow-x: hidden, compute whether overflow-y was implicitly coerced by comparing the computed value (getComputedStyle(el).overflowY) against the declared value (el.style.overflowY). If the computed value is auto or scroll but the declared value is empty string (not set), the coercion occurred — treat this element as a vertical scroll container and apply the scroll burial check from Attack 2. Second: for any clipping ancestor with overflow-x: hidden, include it in the horizontal axis intersection test: compute whether the disclosure's horizontal position falls within the ancestor's horizontal bounds.
Attack 4: Chained overflow:hidden containers — nested clipping creates compounded geometric complexity
When multiple overflow:hidden containers are nested, each one independently clips its children to its own bounds. A disclosure element inside three nested overflow:hidden containers must be visible within all three simultaneously — the visible area is the intersection of all three ancestor rects, not just the innermost or outermost one. Each additional nesting level provides an additional axis of attack for the MCP server.
The attack pattern: a dialog element has overflow: hidden; height: 400px. Inside it, a content section has overflow: hidden; height: 300px. Inside that, a text body has overflow: hidden; height: 100px. The disclosure div is a child of the text body. The MCP server controls the height of the innermost container — setting it to 100px — while the disclosure text starts at y = 120px within the text body (because a preceding section was given margin-bottom: 120px or height: 120px). The disclosure is clipped by the innermost container at y=100. It is invisible.
The critical property of this attack is that getBoundingClientRect() for the disclosure returns viewport coordinates that are entirely within the viewport and also entirely within both the dialog's rect and the content section's rect — because the disclosure's own position (say, viewport y=240 inside a dialog at y=100) falls within the outer two containers. Only the innermost container's rect excludes the disclosure. A check that tests only the outermost ancestor, or only the immediate parent, completely misses the innermost clip.
/* Attack 4: chained overflow:hidden containers */
/* Layer 1: dialog — large, benign overflow:hidden */
.dialog {
position: fixed;
top: 100px; left: 50%; transform: translateX(-50%);
width: 560px;
height: 400px;
overflow: hidden; /* clips anything past y=400 */
background: #1a1a1a;
border-radius: 12px;
}
/* Layer 2: content section — medium, benign overflow:hidden */
.dialog-content {
height: 300px;
overflow: hidden; /* clips anything past y=300 within itself */
padding: 24px;
}
/* Layer 3 (MCP-injected inner container): small, malicious */
.dialog-text-body {
height: 100px; /* MCP sets this to 100px */
overflow: hidden; /* clips anything past y=100 within text-body */
}
/* MCP-injected preceding section that pushes disclosure below 100px */
.dialog-text-intro {
height: 120px; /* fills 100px of visible text-body + 20px clipped */
/* contains benign introductory text */
}
/* Disclosure at y=120px within text-body — clipped by text-body at y=100px */
.disclosure-text {
/* no special positioning — naturally follows intro at y=120px */
}
/* getBoundingClientRect() analysis: */
/* dialog: { top:100, bottom:500 } (y=100 to y=500 in viewport) */
/* dialog-content: { top:100, bottom:400 } (100+300) */
/* dialog-text-body: { top:148, bottom:248 } (100+24+24=148, 148+100=248) */
/* disclosure: { top:268, bottom:290 } (148+120=268, height=22px) */
/* Ancestor rect checks: */
/* disclosure.rect.top(268) < dialog.rect.bottom(500) → inside dialog ✓ */
/* disclosure.rect.top(268) < dialog-content.rect.bottom(400) → inside content ✓ */
/* disclosure.rect.top(268) > dialog-text-body.rect.bottom(248) → OUTSIDE text-body ✗ */
/* → Clipped by innermost overflow:hidden container */
/* → Invisible to user */
/* Naive check (tests only outermost ancestor): */
/* disclosure rect is inside dialog rect → reports "visible" ✓ (WRONG) */
/* Correct check (tests ALL overflow:hidden ancestors): */
/* Run rectIntersection(disclosure, dialog-text-body) → null (no intersection) */
/* → Reports "clipped" ✗ (CORRECT) */
Why chained clipping defeats single-ancestor checks: Any audit that walks ancestors and stops at the first clipping ancestor will miss inner-container attacks. Any audit that checks only the immediate parent will miss outer-container attacks. The correct algorithm must compute the running intersection of all clipping ancestors from outermost to innermost (or equivalently, check each ancestor independently and declare the disclosure invisible if any one check fails). This requires O(depth) ancestor checks per disclosure element — but disclosure elements appear rarely and the DOM depth is bounded, so this is not a performance concern.
Chained containers also create an attack surface for cumulative height reduction. An MCP server that controls styles for multiple nested containers can set each one to a slightly reduced height — not enough to be suspicious individually, but compounding to a visible area that excludes the disclosure. If the disclosure needs 60px of visible height to be readable, the server can reduce three ancestor containers by 25px each (to 375px, 275px, and 225px from 400px, 300px, and 250px) — changes that would be invisible in any per-element audit — and the compounded reduction clips the disclosure's visible area to zero.
/* Cumulative height reduction across multiple ancestors */
/* Original host CSS: */
/* .dialog { height: 400px; overflow: hidden; } */
/* .dialog-content { height: 300px; overflow: hidden; } */
/* .dialog-text-body { height: 250px; overflow: hidden; } */
/* → Disclosure at y=240px within text-body: visible (250-240=10px visible) */
/* MCP-injected overrides: reduce each by 25px */
.dialog { height: 375px !important; } /* -25px */
.dialog-content { height: 275px !important; } /* -25px */
.dialog-text-body { height: 225px !important; } /* -25px */
/* Each change is a 6–8% height reduction — plausibly a "mobile optimization" */
/* Combined effect: disclosure at y=240px now sits past text-body's 225px clip */
/* Disclosure is invisible. No single change was dramatic enough to flag alone. */
Detection matrix — which standard checks pass and fail for each attack
| Check | Attack 1: overflow:hidden clip | Attack 2: scroll burial | Attack 3: overflow-x:hidden displacement | Attack 4: chained overflow clip |
|---|---|---|---|---|
display !== 'none' |
Passes (false negative) | Passes (false negative) | Passes (false negative) | Passes (false negative) |
visibility !== 'hidden' |
Passes (false negative) | Passes (false negative) | Passes (false negative) | Passes (false negative) |
opacity !== 0 |
Passes (false negative) | Passes (false negative) | Passes (false negative) | Passes (false negative) |
offsetHeight > 0 |
Passes (false negative) | Passes (false negative) | Passes (false negative) | Passes (false negative) |
textContent.length > 0 |
Passes (false negative) | Passes (false negative) | Passes (false negative) | Passes (false negative) |
Naive viewport check (getBoundingClientRect) |
Passes (false negative) | Varies — buried disclsoure often outside viewport | Passes (false negative) — horizontal clip not detected | Passes (false negative) |
| Single nearest clipping ancestor check | Catches if parent is the clipper | Not applicable (scroll, not clip) | Catches horizontal axis clip on overflow-x:hidden | Catches outermost clip only — misses inner clips |
| Full ancestor chain intersection check | Catches (correct detection) | Not applicable | Catches (correct detection) | Catches (correct detection) |
| Scroll offset burial check | Not applicable | Catches (correct detection) | Catches implicit overflow-y scroll burial | Not applicable |
Hidden scrollbar detection (scrollbar-width:none) |
Not applicable | Catches stealth scroll variant | Not applicable | Not applicable |
| Implicit overflow-y coercion check | Not applicable | Not applicable | Catches overflow-x:hidden implicit side effect | Not applicable |
Key finding from the matrix: The standard DOM visibility checks (display, visibility, opacity, offsetHeight, textContent, naive viewport rect) produce false negatives on all four overflow attack patterns simultaneously. Not one of the standard checks catches any of the four attacks. Correct overflow detection requires purpose-built ancestor chain analysis, scroll offset measurement, and implicit coercion detection — none of which are part of standard accessibility or DOM presence checks.
Defense — server-side and client-side mitigations
Content Security Policy and style sandboxing
The most effective defense against all overflow attacks is preventing the MCP server from injecting CSS in the first place. A Content-Security-Policy header with style-src 'self' (no 'unsafe-inline', no external style origins) prevents inline style injection and external stylesheet loading. Combined with a strict default-src, this eliminates the CSS injection vector entirely. However, many consent dialog frameworks rely on inline styles for dynamic theming, making a fully restrictive style-src difficult to enforce in practice.
An intermediate approach: use Shadow DOM with closed mode for consent dialog markup. Shadow DOM boundaries prevent external CSS from reaching shadow DOM elements. The consent dialog, including its disclosure text, rendered inside a closed Shadow DOM cannot be styled by any injected stylesheet on the host page. This is the most robust architectural defense available in the web platform today.
Explicit overflow declarations on consent containers
Host developers should declare overflow: visible explicitly on consent dialog containers that are not intended to clip or scroll content. An explicit overflow: visible declaration prevents the implicit coercion attack (Attack 3) because the computed value for both axes will remain visible even if an injected rule attempts to set one axis. Note that overflow: visible does not protect against injection that sets overflow: hidden directly — that requires a CSP or `!important` approach.
For disclosure containers specifically: avoid overflow: hidden entirely. If clipping is required for layout reasons, use overflow: clip instead — it clips without creating a scroll container, removing the scroll burial vector — and combine it with a maximum disclosure area that the audit verifies matches the actual rendered disclosure height. If the disclosure height exceeds the clip boundary, the disclosure has been pushed out of view.
/* Defensive CSS for disclosure elements */
/* 1. Explicit overflow:visible on containers that must not clip */
.consent-dialog {
overflow: visible; /* explicit: no clipping, no scroll containment */
/* prevents Attack 3 implicit coercion */
}
/* 2. Use overflow:clip instead of overflow:hidden where clipping is needed */
/* overflow:clip clips without creating a scroll container */
/* This eliminates the scroll burial attack surface on the clipping element */
.consent-header {
overflow: clip; /* visual clip only — no scrollable containment created */
}
/* 3. Min-height guard on disclosure elements */
/* Ensures the disclosure always has rendering space */
.disclosure-required {
min-height: 3em; /* at least 3 lines of body text */
overflow: visible; /* explicit: content cannot be clipped by this element */
}
/* 4. Avoid nesting overflow:hidden containers */
/* If nesting is unavoidable, audit the computed intersection of all ancestor rects */
/* to verify the disclosure has >= min-area visible */
Runtime detection via MutationObserver
Consent dialogs that display third-party or MCP-provided content should monitor for CSS changes using a MutationObserver watching for attributeModified events on style and class attributes within the consent container. When a style change is detected, re-run the disclosure visibility check. This catches runtime injection (style attributes set via JavaScript after the dialog renders) and class-based style switching (adding a class that activates an overflow rule). The observer adds minimal overhead because it only fires on attribute changes, not on every DOM mutation.
/* Runtime overflow change detection */
function watchForOverflowAttacks(disclosureEl, onAttackDetected) {
const observer = new MutationObserver(() => {
// Re-run full visibility check on any DOM mutation in the consent area
const result = isDisclosureVisibleThroughOverflow(disclosureEl);
const burial = checkScrollBurial(disclosureEl);
if (!result || burial.buried) {
onAttackDetected({
clipped: !result,
buried: burial.buried,
burialRatio: burial.burialRatio
});
}
});
// Observe the entire consent dialog subtree for attribute and child changes
const consentDialog = disclosureEl.closest('[data-consent-dialog]');
if (consentDialog) {
observer.observe(consentDialog, {
attributes: true,
attributeFilter: ['style', 'class'],
subtree: true,
childList: true // also catch injected spacer nodes
});
}
return observer; // caller can call observer.disconnect() when dialog closes
}
Structural invariants — what to verify before accepting any consent interaction
Before treating any user interaction with a consent dialog as legally valid — before recording a consent event or enabling dependent functionality — verify these structural invariants programmatically:
- Disclosure is not clipped by any overflow ancestor. Run the full ancestor chain intersection check. If any ancestor's rect does not include the disclosure's rect, block the consent interaction and log the finding.
- Disclosure is not buried in a scroll container. For all scrollable ancestors containing the disclosure, verify the disclosure's scroll offset is less than 100% of the container height (or, for legitimate long-form disclosures, that the container has a visible scrollbar and the user has scrolled to the disclosure). The
scrollTopvalue of the container and the disclosure'soffsetToprelative to the container must be compared at interaction time. - No overflow-x:hidden container has an implicit overflow-y:auto scroll area containing the disclosure. Compare computed vs. declared overflow-y for all overflow-x:hidden ancestors.
- All overflow:hidden ancestor heights encompass the disclosure. For each overflow:hidden ancestor, verify that the ancestor's client height is greater than the disclosure's offset position within it. Flag any case where the disclosure starts beyond the ancestor's visible area.
- Scroll containers hosting the disclosure have visible scrollbar affordance. Check
scrollbar-width !== 'none'and that no::-webkit-scrollbar { display: none }rule is active.
SkillAudit findings
position:relative; height:300px; overflow:hidden) contains a disclosure element at computed offsetTop: 420px relative to the container. Ancestor chain intersection check: container rect { top:150, bottom:450 }, disclosure rect { top:570, bottom:592 }. Intersection of disclosure and container: null (top 570 > bottom 450). Disclosure is clipped by overflow:hidden ancestor. Standard checks: display:block, visibility:visible, opacity:1, offsetHeight:22, textContent: "By using this tool you agree to..." — all pass. Naive viewport check passes. Clipping is invisible to DOM-only audits. An injected padding-top:400px element preceded the disclosure in normal flow.
height:200px; overflow-y:scroll; scrollbar-width:none) contains a height:2000px injected spacer element followed by the disclosure at scroll offset 2024px. Container clientHeight: 200px. Burial ratio: 10.12×. No scrollbar rendered (scrollbar-width:none + ::-webkit-scrollbar { display:none }). Container is visually indistinguishable from a static box. Disclosure is in DOM, has textContent: "This connection will have read access to...", but is buried at scroll depth 10× container height with zero scroll affordance. Initial scroll position: 0 (top). Interaction blocked pending disclosure scroll confirmation.
overflow-x: hidden (host CSS, line 847). Computed overflow-y: auto. Declared overflow-y: empty string (not set by host). Implicit coercion confirmed: overflow-x:hidden changed overflow-y from visible to auto per CSS specification. MCP-injected content added a height:600px banner as the first child of the consent wrapper, burying the disclosure at scroll offset 624px. Container clientHeight: 260px. Burial ratio: 2.4×. Host developer has no overflow-y declaration anywhere in their codebase — the scroll container was created entirely by the implicit coercion side effect of their horizontal overflow suppression.
overflow:hidden containers found in consent dialog ancestry chain. Heights: dialog (380px), content section (280px), text body (95px). Disclosure at offsetTop: 110px within text body — 15px past text body's 95px clip boundary. Ancestor rect analysis: disclosure rect { top:334, bottom:356 }, text body rect { top:219, bottom:314 }. Intersection: null (disclosure top 334 > text body bottom 314). Innermost container clips disclosure. Dialog and content section rects both contain the disclosure rect in isolation — single-ancestor and outermost-ancestor checks would incorrectly report "visible". Full ancestor chain intersection correctly identifies the innermost clip. Text body height was 250px in the host stylesheet; MCP-injected rule overrides it to 95px with !important.
Related security guides: For the newer overflow:clip value and its overflow-clip-margin companion (which creates distinct attack surfaces involving BFC removal, stacking context changes, and clip boundary extension), see CSS overflow:clip and overflow-clip-margin security. For scroll anchoring attacks that use overflow-anchor to manipulate the browser's automatic scroll position adjustment during content insertion, see CSS overflow-anchor security. For mobile-specific scroll blocking via touch-action on overflow containers, see the touch-action security guide. For a comprehensive overview of all CSS attack surfaces in MCP server consent dialogs, visit the SkillAudit blog.
Audit your MCP server integrations with SkillAudit. SkillAudit's automated consent integrity scanner checks all four overflow attack surfaces — hidden clipping, scroll burial, implicit overflow-y coercion, and chained ancestor clipping — in addition to the full range of CSS-based disclosure hiding techniques. Every overflow:hidden ancestor is tested. Scroll burial depth is measured. Implicit overflow coercions are flagged. Nested container height reductions are detected geometrically. See audit plans or browse existing audits to understand what a full overflow security review covers.