MCP server CSS dynamic viewport units security: dvh consent modal collapses on mobile browser chrome, svh font invisible, lvh content behind toolbar, 100dvw layout shift
Published 2026-07-29 — SkillAudit Research
CSS dynamic viewport units (dvh, dvw, dvi, dvb, dvmin, dvmax) update in real time as the mobile browser's UI chrome (address bar, navigation toolbar, virtual keyboard) appears and disappears. The small viewport units (svh, svw) measure the viewport when the browser chrome is fully expanded (maximum chrome visible, minimum web content area). The large viewport units (lvh, lvw) measure the viewport when the browser chrome is fully retracted (maximum web content area, minimum chrome). On iOS Safari and Chrome for Android, these three viewport heights can differ by 80–120px — a significant portion of the screen on a 844px-tall iPhone 13. MCP servers exploit these differences to create consent modals that appear correctly sized in the developer's test environment (desktop or static mobile screenshot) but collapse, overflow, or become occluded by browser chrome on real mobile devices during user interaction.
Dynamic viewport units change during user interaction: dvh is not a snapshot — it updates whenever the browser chrome changes. A consent modal using height: 100dvh that is 844px tall when first shown may shrink to 732px when the user taps a text field (triggering the virtual keyboard), collapsing the consent modal and hiding the text below the fold. These attacks only manifest during active user interaction and cannot be detected in static page screenshots.
Attack 1: height:100dvh consent modal shrinks to near-zero when mobile browser address bar appears
On iOS Safari and Chrome for Android, the browser address bar hides when the user scrolls down and reappears when the user scrolls back to the top. The dynamic viewport (dvh) reflects the current available height, minus the visible browser chrome. A consent modal with height: 100dvh fills the screen when the address bar is hidden — but when the user scrolls up (or when the page triggers a programmatic scroll), the address bar reappears, shrinking the dynamic viewport by 60–100px. The modal height updates to match. If the consent modal has overflow: hidden and no internal scrolling, the consent content that was visible near the bottom of the modal is now clipped below the new modal bottom edge. The text "I agree to allow SkillMCP to collect all browsing history..." may have been fully visible before the scroll event and is now clipped to a partial sentence.
/* MCP attack — dvh modal that shrinks when browser chrome appears: */
.consent-modal {
position: fixed;
inset: 0;
height: 100dvh; /* updates when browser chrome appears/disappears */
overflow: hidden; /* no internal scroll — clipped when modal shrinks */
display: flex;
flex-direction: column;
}
.consent-modal .consent-body {
flex: 1; /* takes remaining height after header */
overflow: hidden; /* clips when modal height shrinks */
/* When user scrolls up → address bar appears → dvh decreases by ~80px
→ modal height decreases by 80px → consent body shrinks by 80px
→ bottom 80px of consent text is clipped.
Attack timing: MCP shows consent modal, waits for user to scroll
(natural behavior on mobile), records that consent was displayed,
marks bottom of consent (most restrictive terms) as "never loaded".
The user agrees to visible content; MCP enforces full terms. */
}
/* Safe alternative would use min-height and internal scroll: */
.consent-modal-safe {
height: 100dvh;
overflow: auto; /* internal scroll — content accessible despite size change */
}
// Detection: check for dvh-based fixed-height elements with overflow:hidden
function detectDvhModalCollapse() {
const fixedEls = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]');
const allEls = document.querySelectorAll('*');
allEls.forEach(el => {
const style = window.getComputedStyle(el);
if (style.position !== 'fixed' && style.position !== 'sticky') return;
const overflow = style.overflow;
const overflowY = style.overflowY;
const height = style.height;
// Check if height uses dynamic viewport units
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule && el.matches(rule.selectorText)) {
const h = rule.style.height || rule.style.maxHeight;
if (h && (h.includes('dvh') || h.includes('dvmin') || h.includes('dvmax'))) {
const hasClipOverflow = overflow === 'hidden' || overflowY === 'hidden';
if (hasClipOverflow) {
// Check if this is a consent-related element
const inConsent = el.closest(
'[class*="consent"], [class*="modal"], [class*="dialog"], [role="dialog"]'
);
if (inConsent || el.matches('[class*="consent"], [class*="modal"]')) {
console.error('SECURITY: Consent modal uses dvh height with overflow:hidden — may collapse when mobile browser chrome appears:', {
el, cssHeight: h, overflow
});
}
}
}
}
}
} catch (e) { /* cross-origin */ }
}
});
}
Attack 2: font-size:3svh makes consent text sub-pixel on iOS Safari when toolbar is visible
The small viewport height (svh) is fixed at the minimum viewport height — the height when the browser chrome is fully expanded (address bar + tabs + bottom toolbar all visible). On a iPhone 13 (844px screen), the small viewport height is approximately 548px. 3svh = 0.03 × 548 = 16.4px — a normal readable font size. But on an older iPhone SE (667px screen) with the browser toolbar taking 120px, the small viewport is ~547px, giving the same result. This seems harmless — until the MCP uses a much smaller coefficient: 0.5svh. On the same iPhone SE: 0.5svh = 0.005 × 547 = 2.7px — sub-pixel font size, invisible to most users. The value 0.5svh looks plausible in a stylesheet (it might be intended as a responsive spacing value, not a font size), but applied to consent text, it produces a ~2px font size on small-viewport mobile devices.
/* MCP attack — svh-based font-size that collapses on small viewports: */
.consent-text {
font-size: 0.5svh;
/* On a 548px small viewport: 0.5svh = 2.74px → sub-pixel, invisible
On a 800px large viewport: 0.5svh = 4px → still too small
The value looks like a spacing/padding value in code review,
not obviously a font-size. The keyword "svh" is relatively new and
may not be recognized as a font-size source by less experienced reviewers. */
}
/* Slightly more plausible attack: */
.consent-terms-text {
font-size: max(0.8svh, 1px); /* max() makes it look like a safe fallback */
/* On a 548px svh: 0.8 × 548 = 4.38px — still invisible to most users.
The max(, 1px) makes the value look safety-conscious — "at least 1px".
1px is itself invisible. The max() call with 1px does not actually
prevent sub-pixel text. */
}
/* The large viewport variant — uses lvh and is fine most of the time,
but collapses when the page is in a small frame/embed context: */
.consent-body-text {
font-size: 1.5lvh;
/* On a normal 800px large viewport: 1.5lvh = 12px — below comfortable reading.
In an iframe with 200px height: 1.5lvh = 3px → invisible. */
}
// Detection: resolve svh/dvh/lvh font-size values and check readability
function detectDynamicViewportFontCollapse() {
const consentEls = document.querySelectorAll(
'[class*="consent"] *, [class*="disclosure"] *, [data-consent] *'
);
consentEls.forEach(el => {
const style = window.getComputedStyle(el);
const fontSize = parseFloat(style.fontSize);
if (fontSize < 9) {
console.error('SECURITY: Consent-area element has sub-readable computed font-size:', {
el, fontSize
});
// Source check: look for viewport-unit-based font-size
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule && el.matches(rule.selectorText)) {
const fs = rule.style.fontSize;
const hasViewportUnit = fs && (
fs.includes('svh') || fs.includes('dvh') || fs.includes('lvh') ||
fs.includes('svw') || fs.includes('dvw') || fs.includes('lvw') ||
fs.includes('dvmin') || fs.includes('dvmax')
);
if (hasViewportUnit) {
console.error('SECURITY: Sub-readable font-size uses dynamic/small viewport unit:', {
selector: rule.selectorText, cssValue: fs, resolvedPx: fontSize
});
}
}
}
} catch (e) { /* cross-origin */ }
}
}
});
}
Attack 3: height:100lvh consent section extends behind browser toolbar — content is occluded but technically rendered
The large viewport height (lvh) measures the viewport assuming the browser chrome is fully retracted — the maximum possible content area. On iOS Safari with the toolbar visible, 100lvh > 100dvh: the large viewport is taller than the currently available dynamic viewport. A consent section with height: 100lvh extends below the visible browser window, into the area occluded by the browser toolbar. The content is rendered (it is in the DOM and its layout box intersects the larger viewport), but it is physically hidden behind the browser's navigation toolbar. The user cannot see it, cannot scroll to it (the browser toolbar blocks that area), and cannot interact with it. The MCP can claim consent was "rendered" and "available" because the content's layout box is within the large viewport — but the user cannot actually see or read it.
/* MCP attack — lvh consent section that extends under browser chrome: */
.consent-section {
height: 100lvh; /* extends to the LARGE viewport — behind toolbar on mobile */
overflow: hidden; /* no internal scrolling */
}
/* The consent section's layout includes the area behind the browser toolbar.
On iOS Safari with toolbar visible (100lvh - 100dvh ≈ 83px of toolbar space):
- The bottom 83px of the consent section is behind the browser toolbar
- Consent content placed at the bottom of the section (e.g., the signature
line "Your consent is binding under applicable law") is not visible
- getBoundingClientRect() may report the element as fully visible
(its rect is within window.innerHeight × lvh values) but the
browser toolbar physically occludes the bottom portion on device */
/* Variant: fixed positioning with bottom:0 and height using lvh: */
.consent-sticky-footer {
position: fixed;
bottom: 0;
height: 8lvh; /* 8% of large viewport height */
/* On mobile when the browser toolbar (address bar) is visible,
the consent sticky footer is positioned behind the toolbar.
The submit/decline buttons inside it are not tappable because
the browser chrome receives the touch events first. */
}
// Detection: check if consent elements are occluded by browser chrome
function detectLvhOcclusion() {
// Compare dvh vs lvh by checking element positions against window.innerHeight
// window.innerHeight ≈ dvh (current dynamic viewport)
const consentSections = document.querySelectorAll(
'[class*="consent"], [class*="disclosure"], [data-consent]'
);
consentSections.forEach(el => {
const rect = el.getBoundingClientRect();
const windowHeight = window.innerHeight; // ≈ dynamic viewport
if (rect.bottom > windowHeight) {
// Element extends below the visible viewport
const overlapPx = rect.bottom - windowHeight;
if (overlapPx > 20) { // more than 20px below visible area
console.error('SECURITY: Consent element extends below visible viewport — may be occluded by browser chrome:', {
el, rectBottom: rect.bottom, windowHeight, overlapPx
});
// Check if this is due to lvh-based height
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule && el.matches(rule.selectorText)) {
const h = rule.style.height || rule.style.maxHeight || rule.style.minHeight;
if (h && (h.includes('lvh') || h.includes('lvw') || h.includes('lvi') || h.includes('lvb'))) {
console.error('SECURITY: Viewport occlusion caused by lvh-based sizing:', {
selector: rule.selectorText, cssHeight: h, overlapPx
});
}
}
}
} catch (e) { /* cross-origin */ }
}
}
}
});
}
Attack 4: width:100dvw causes consent layout shift and clipping when browser scrollbar appears
On desktop browsers, 100dvw (100% of the dynamic viewport width) includes the scrollbar width on systems where scrollbars are overlaid (macOS, iOS). But on Windows with classic scrollbars (typically 17px wide), 100dvw includes the scrollbar width — it equals the full window width including the scrollbar. This means a consent dialog with width: 100dvw on a page that subsequently gains a scrollbar (because consent content makes the page taller) will be 17px wider than the visible content area. The dialog overflows the visible area by 17px on the right side — the right-edge content of the consent dialog is clipped. If the consent dialog contains right-aligned action buttons ("Decline" / "Agree" arranged right-to-left), the "Decline" button may be partially or fully clipped by the 17px overflow. The layout shift also happens dynamically when the scrollbar appears, potentially causing jarring layout reflow during the consent interaction.
/* MCP attack — 100dvw width that overflows when scrollbar appears (desktop): */
.consent-dialog {
width: 100dvw; /* full dynamic viewport width INCLUDING scrollbar area */
overflow: hidden; /* clip the 17px right-edge overflow */
position: fixed;
left: 0;
/* On Windows with 17px scrollbar:
content area = window.innerWidth - 17 = e.g. 983px
100dvw = 1000px (full window width)
Dialog is 1000px wide, but content area is 983px.
Right 17px of dialog is behind/under the scrollbar.
Consent content in right 17px is clipped or scrollbar-occluded. */
}
.consent-dialog .consent-actions {
display: flex;
justify-content: flex-end; /* buttons right-aligned */
gap: 12px;
padding-right: 20px;
}
/* The "Decline" button (rightmost) may be partially behind the scrollbar.
Its visible area is reduced; it may not receive click events in the
scrollbar region (the scrollbar receives them instead).
When the scrollbar appears (consent content makes page taller):
→ 100dvw resolves to the SAME width (scrollbar is included in dvw)
→ But content area shrinks by 17px
→ Dialog content shifts left by 17px relative to the new content area
→ Buttons that were right-aligned at content-area-right are now
17px further left than expected, appearing to shift during interaction */
/* Detection: compare 100dvw resolution to window.innerWidth */
function detectDvwScrollbarOverflow() {
// Create a test element to resolve dvw
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;top:-9999px;left:0;width:100dvw;visibility:hidden';
document.body.appendChild(probe);
const dvwResolved = probe.offsetWidth;
const contentWidth = window.innerWidth; // excludes scrollbar on standard browsers
document.body.removeChild(probe);
const scrollbarWidth = dvwResolved - contentWidth;
if (scrollbarWidth > 5) { // significant scrollbar detected
// Check if consent dialogs use 100dvw
const consentDialogs = document.querySelectorAll(
'[class*="consent"], [class*="dialog"], [role="dialog"], [class*="modal"]'
);
consentDialogs.forEach(dialog => {
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
if (rule instanceof CSSStyleRule && dialog.matches(rule.selectorText)) {
const w = rule.style.width || rule.style.maxWidth;
if (w && w.includes('dvw') && parseFloat(w) >= 100) {
console.error('SECURITY: Consent dialog uses 100dvw on a page with scrollbar — may clip right-edge content (scrollbar width: ' + scrollbarWidth + 'px):', {
dialog, cssWidth: w, dvwResolved, contentWidth, scrollbarWidth
});
}
}
}
} catch (e) { /* cross-origin */ }
}
});
}
}
Dynamic viewport unit attacks are device- and interaction-dependent: These attacks do not manifest on desktop browsers (which have a stable viewport) or in static mobile screenshots (which capture one frozen viewport state). They activate during real mobile user interaction: scrolling (triggers address bar hide/show), tapping a form field (triggers virtual keyboard), or navigating back (restores browser chrome). Automated headless browser testing in a fixed viewport will not catch them. SkillAudit's mobile interaction tests use a real device or mobile emulation with real scroll simulation to capture dvh/svh/lvh behavior during interaction.
Attack summary
| Attack | Unit used | Trigger condition | Severity |
|---|---|---|---|
| Modal height collapse | height: 100dvh + overflow: hidden |
User scrolls up → browser address bar appears → modal shrinks → bottom content clipped | High |
| SVH font collapse | font-size: 0.5svh |
Small viewport on older phones — font resolves to ~2px, invisible | High |
| LVH toolbar occlusion | height: 100lvh |
Browser chrome visible — bottom of consent section extends under toolbar | Medium |
| DVW scrollbar overflow | width: 100dvw |
Scrollbar appears — right 17px of consent dialog clipped/occluded | Medium |
Consolidated findings
height: 100dvh on a fixed-position consent modal with no internal scroll. When the browser address bar reappears (triggered by upward scroll during consent reading), the dynamic viewport height decreases by 60–100px. The modal shrinks to match. With overflow: hidden, the bottom content — often the most restrictive consent terms — is clipped and invisible. Detection requires rendering the modal in a mobile emulation environment and simulating a scroll event to trigger address bar reappearance, then checking whether all consent text remains visible after the viewport change.
height: 100lvh on a consent section. On mobile with browser chrome visible, 100lvh exceeds the visible dynamic viewport by 80–120px. Content in the bottom portion of the section is physically behind the browser toolbar — rendered but not visible. getBoundingClientRect() may report the element as "visible" (within the lvh measurement), but the element is occluded by browser chrome. Detection requires comparing rect.bottom to window.innerHeight (which reflects the dynamic viewport, not the large viewport) and flagging elements that extend beyond the dynamic viewport.
← Blog | CSS viewport unit attacks | CSS overscroll-behavior attacks | Security Checklist