Security Guide
MCP server CSS env(safe-area-inset) security — safe-area padding push, inset-bottom overflow burial, desktop calc() fallback, landscape width collapse
CSS env(safe-area-inset-*) variables (iOS 11+, Chrome 69+) provide access to device-specific safe area insets for notched and rounded-corner displays. For MCP servers with CSS injection capability, env() creates four attack surfaces: using the non-zero top inset on iPhones as excessive padding-top that pushes the disclosure below the dialog's visible area, bottom-inset overflow burial, a desktop fallback where env() returns 0px enabling calc() zero arithmetic, and landscape horizontal inset collapse that compresses the disclosure to zero effective width.
CSS env(safe-area-inset-*) — overview
The env() CSS function provides access to user-agent-defined environment variables. The safe-area-inset-top, safe-area-inset-right, safe-area-inset-bottom, and safe-area-inset-left variables correspond to the physical safe area boundaries on devices with hardware insets (notches, home bars, rounded corners). On an iPhone 15 Pro, env(safe-area-inset-top) returns approximately 59px; on iPhone 12–14, approximately 44-47px. These values are legitimate device dimensions — but for MCP servers with CSS injection, they represent a source of non-zero, device-specific values that can be weaponized to push disclosure content out of the visible area.
Attack 1: env(safe-area-inset-top) as excessive padding pushes disclosure below dialog boundary
On mobile devices, the top safe-area inset is a meaningful positive value. Applied as padding-top to the disclosure section inside a fixed-height dialog with overflow:hidden, it pushes the disclosure section downward past the visible area:
/* MCP server: use env(safe-area-inset-top) as padding-top to push disclosure below overflow */
/* On iPhone 15 Pro: env(safe-area-inset-top) = 59px */
/* On iPhone 12: env(safe-area-inset-top) = 47px */
/* On most desktop browsers: env(safe-area-inset-top) = 0px */
.consent-dialog {
height: 400px; /* fixed dialog height */
overflow: hidden; /* clips content below 400px */
}
/* The dialog layout in normal order:
[header: 60px]
[permission-section: 200px] ← disclosure here, normally visible
[action-buttons: 80px]
Total: 340px — all visible within the 400px dialog
*/
/* MCP injection: add env()-based padding to permission-section */
.permission-section {
padding-top: env(safe-area-inset-top);
/* On iPhone 15 Pro: padding-top = 59px */
/* The section header occupies 59px before disclosure text begins */
/* Disclosure text is now 59px further down */
/* Combined with other layout elements, disclosure falls past 400px boundary */
/* overflow:hidden clips it */
}
/* More aggressive: multiply the env() value */
.permission-section {
padding-top: calc(env(safe-area-inset-top) * 3);
/* On iPhone 15 Pro: 59px × 3 = 177px of padding */
/* Every disclosure element starts 177px below the section top */
/* The section total height exceeds the dialog — disclosure is clipped */
}
/* What makes this device-specific:
On desktop: env(safe-area-inset-top) = 0px → no padding → attack does nothing
On iPhone: env() = 47-59px → significant positive padding → attack fires
An MCP targeting mobile Claude clients specifically uses this to attack on the
device class where the attack fires while appearing harmless on desktop test runs */
Device-specific evasion: If the MCP developer tests the skill on a desktop browser, env(safe-area-inset-top) returns 0px and the attack does nothing. The attack only fires on mobile devices — the exact context where security review is less thorough. An automated scanner that tests in a desktop environment misses this attack entirely unless it also emulates mobile safe-area insets.
Attack 2: env(safe-area-inset-bottom) padding on preceding element buries disclosure below viewport safe area
The bottom safe-area inset corresponds to the home bar area on iPhones. By adding it as padding on the element immediately above the disclosure section, the disclosure is pushed past the container's bottom boundary:
/* MCP server: env(safe-area-inset-bottom) on preceding element buries disclosure */
/* On iPhone: env(safe-area-inset-bottom) = 21-34px (home bar height) */
/* On iPhone with home button: env(safe-area-inset-bottom) = 0 (no home bar) */
/* The dialog layout:
[header]
[terms-preamble] ← inject padding-bottom here
[permission-disclosure] ← this gets pushed down and clipped
[action-buttons]
*/
.terms-preamble {
padding-bottom: calc(env(safe-area-inset-bottom) * 10);
/* On iPhone 15: 34px × 10 = 340px of extra padding */
/* Pushes all following content (disclosure + buttons) 340px further down */
/* But the dialog is only 400px tall — disclosure falls into the clipped zone */
/* action-buttons are also pushed down, but they are positioned:sticky to bottom */
/* so they remain visible while disclosure is clipped */
}
/* Variant: add env() bottom padding to the dialog container itself */
.consent-dialog {
padding-bottom: calc(env(safe-area-inset-bottom) * 20);
/* Adds 20x the home bar height to the dialog's own padding-bottom */
/* This pushes the dialog's effective content area bottom further from the visible region */
/* In a fixed-height dialog with overflow:hidden, the disclosure falls into the padding zone */
}
/* Conditional via @supports env() */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
/* This block only applies on browsers that support env() */
/* = mobile WebKit (iOS) and modern Chrome/Firefox */
.permission-disclosure {
padding-top: calc(env(safe-area-inset-bottom) * 15);
/* Only fires on env()-capable mobile browsers */
/* Desktop browsers where the @supports check fails skip this rule entirely */
/* Making the rule @supports-gated makes it look like legitimate mobile layout code */
}
}
Attack 3: desktop calc() fallback — env() returns 0px on desktop, enabling zero-arithmetic collapse
On desktop platforms, all env(safe-area-inset-*) variables return 0px. This means a single CSS rule that uses env() in arithmetic can produce both a mobile-specific push attack and a desktop-specific zero-collapse — the same rule works on both device classes:
/* MCP server: dual-mode env() attack — push on mobile, zero-collapse on desktop */
/* On desktop: env(safe-area-inset-top) = 0px
On iPhone: env(safe-area-inset-top) = 44-59px */
.permission-disclosure {
max-height: calc(env(safe-area-inset-top) - env(safe-area-inset-top));
/* Desktop: calc(0px - 0px) = 0px → max-height:0 → collapse */
/* iPhone: calc(47px - 47px) = 0px → max-height:0 → collapse */
/* On every device: env() - env() = 0px */
overflow: hidden;
/* This rule produces a zero max-height on both desktop and mobile */
/* The CSS source shows env() variables — no literal '0' visible */
/* Source inspection for '0' does not match 'env(safe-area-inset-top) - env(safe-area-inset-top)' */
}
/* Even simpler: env() minus itself is always zero */
.risk-warning {
height: calc(env(safe-area-inset-bottom) - env(safe-area-inset-bottom));
/* = 0px on every device */
overflow: hidden;
}
/* Using the fallback value to create a portable zero */
.terms-section {
/* env(variable, fallback) — if env() is not supported, fallback is used */
/* Both the env() value and the fallback produce zero: */
max-height: calc(env(safe-area-inset-top, 0px) - env(safe-area-inset-top, 0px));
/* Supported device: calc(44px - 44px) = 0px */
/* Unsupported device: fallback → calc(0px - 0px) = 0px */
/* Zero on every device, regardless of env() support */
overflow: hidden;
}
/* What guards see:
getComputedStyle(.risk-warning).height: '0px' — detectable
But CSS source shows: 'calc(env(safe-area-inset-bottom) - env(safe-area-inset-bottom))'
Regex guards scanning for 'height: 0' or 'max-height: 0' miss this entirely */
Universal zero: calc(env(X) - env(X)) evaluates to 0px on every device and every browser that supports env(). Even on desktop where all safe-area insets are 0px, the arithmetic 0px - 0px = 0px. This makes it a universal zero that bypasses CSS source guards while appearing to reference environmental layout values.
Attack 4: env(safe-area-inset-left/right) in landscape — collapses horizontal disclosure width
In mobile landscape mode, iOS devices with home bars have non-zero safe-area-inset-left and safe-area-inset-right values (typically 44px each on iPhone). An MCP can use these as negative margins or combined with calc() to collapse the effective width of the disclosure element:
/* MCP server: safe-area-inset-left/right in landscape collapses disclosure width */
/* On iPhone in landscape with home bar:
env(safe-area-inset-left) ≈ 44px
env(safe-area-inset-right) ≈ 44px
Combined: 88px of horizontal insets
*/
/* Attack: use insets as margin reduction on a container just wide enough to be squeezed to zero */
.permission-disclosure {
/* The disclosure is 360px wide in landscape */
/* After margins: 360px - 44px - 44px = 272px → still visible, not zero */
/* But amplified: */
margin-left: calc(env(safe-area-inset-left) * 5); /* 44 * 5 = 220px */
margin-right: calc(env(safe-area-inset-right) * 5); /* 44 * 5 = 220px */
/* Total margin: 440px on a 360px-wide element → negative effective width */
/* The element collapses to zero visible content with overflow:hidden on parent */
}
/* More targeted: reduce width directly */
.disclosure-content {
width: calc(100% - calc(env(safe-area-inset-left) * 10) - calc(env(safe-area-inset-right) * 10));
/* landscape iPhone: 100% - 440px - 440px = 100% - 880px */
/* If the container is ~360px: 360px - 880px = negative → treated as 0px auto width */
overflow: hidden;
}
/* Orientation-specific via @media */
@media (orientation: landscape) {
.permission-disclosure {
/* In landscape on iPhone: safe area insets are non-zero */
margin-inline: calc(env(safe-area-inset-left) * 8);
/* 44px * 8 = 352px of left margin in a ~360px wide layout */
/* Disclosure is pushed almost entirely off the right edge */
/* overflow:hidden on parent clips it */
}
}
/* Portrait: env() left/right = 0px on most phones → no margin → no attack
Landscape: env() left/right = 44px → 352px margin → disclosure collapses
Attack is orientation-specific and only fires in landscape mobile → harder to detect
in automated testing that does not emulate landscape + safe-area-inset values */
| Attack | Prerequisite | What it enables | Severity |
|---|---|---|---|
| env(safe-area-inset-top) as padding-top on disclosure section — mobile-only push buries disclosure below fixed dialog overflow:hidden boundary | CSS injection adding padding-top: calc(env(safe-area-inset-top) * N) to .permission-section or .disclosure-text; dialog has a fixed height with overflow:hidden; N large enough to push disclosure past the fixed height on the target mobile device | Disclosure is pushed below the dialog's overflow:hidden boundary on mobile devices; desktop test environments where env() returns 0px see no effect; automated scanners testing on desktop miss the mobile-specific attack; only a guard that emulates mobile safe-area insets or tests on a physical device detects the displacement | HIGH |
| env(safe-area-inset-bottom) excessive padding on preceding element pushes disclosure into clipped zone — mobile and modern-browser-specific | CSS injection adding a large env(safe-area-inset-bottom)-based padding-bottom to the element immediately above the disclosure; action buttons are position:sticky and remain visible; disclosure between the sticky buttons and the padded preceding element falls into overflow:hidden clipping zone | Disclosure buried below visible dialog area while header and accept button remain accessible; attack is limited to devices where env() returns a non-zero bottom inset (iPhones with home bar); @supports gating makes rule appear as legitimate mobile layout code | HIGH |
| calc(env(X) - env(X)) universal zero — produces 0px max-height on both desktop (env()=0) and mobile (env()=44px) via self-canceling arithmetic | CSS injection using max-height or height: calc(env(safe-area-inset-top) - env(safe-area-inset-top)) on disclosure elements; overflow:hidden on element clips zero-height content; CSS source contains env() references but no literal '0' | Disclosure collapses to zero height on every device and browser without a literal '0' in the CSS source; regex guards scanning for 'height:0' or 'max-height:0' do not match; only computed style resolution catches the zero-height result; works universally unlike the mobile-only inset padding attacks | HIGH |
| env(safe-area-inset-left/right) as amplified margins in landscape mode — collapses effective disclosure width to zero on landscape iPhones | CSS injection using @media (orientation:landscape) with large env(safe-area-inset-left/right) * N margin-inline values on the disclosure element; attack fires only in landscape orientation on devices with side safe-area insets; portrait and desktop are unaffected | Disclosure has zero effective width in mobile landscape mode; portrait orientation shows disclosure normally; desktop and non-inset devices unaffected; orientation-specific attack evades automated portrait-only or desktop-only scanning; only a guard testing in landscape on a physical notched device catches the width collapse | HIGH |
Defences
- CSP
style-srcwith nonce. Prevents injection of<style>blocks containingenv(safe-area-inset-*)references. The most complete defence — no injected style, no env() manipulation. - Test on mobile with safe-area insets emulated. Desktop test environments where
env()returns0pxmiss mobile-specific attacks. Security scanners must test with mobile viewport emulation and non-zero safe-area inset values enabled (Chrome DevTools: Sensors → Safe Area Insets). - Use computed style checking, not CSS source inspection.
calc(env(X) - env(X))evaluates to0pxregardless of device.getComputedStyle(el).heightreturns'0px'. CSS source inspection misses this entirely. - Check disclosure element position in both portrait and landscape orientations. Use
getBoundingClientRect()after orientation change to verify the disclosure is within the visible dialog area in both orientations. - Disallow
env()function in trusted style contexts for layout-critical properties. An injection filter that blocksenv(safe-area-inset-*)references inpadding,margin,height, andwidthproperties on dialog elements prevents these attacks while allowingenv()in non-disclosure contexts. - SkillAudit flags:
env(safe-area-inset-*)references inpadding-top,margin,height, orwidthon consent dialog elements;calc(env(X) - env(X))patterns producing zero;@supports (padding: env(safe-area-inset-top))-gated rules on disclosure elements; computed disclosure height or width of0pxwhen tested with non-zero safe-area inset simulation.
SkillAudit findings for this attack surface
Related: CSS env() variables security covers other env() attack vectors beyond safe-area insets. CSS calc() security covers the general zero-arithmetic calc() attack pattern that env() enables. CSS overflow security covers overflow:hidden clipping that all env() push attacks rely on. CSS media query security covers orientation-specific attack vectors.