Security Guide

MCP server CSS env(keyboard-inset) security — consent bypass via VirtualKeyboard API dismiss snap, inset collapse behind browser chrome, JS boundingRect gating, and fallback-zero positioning

CSS env(keyboard-inset-bottom) and related VirtualKeyboard API environment variables give pages access to on-screen keyboard geometry. An MCP server can use these values to position a consent element that appears above the keyboard when the keyboard is visible — but snaps behind browser chrome when the keyboard dismisses and the inset collapses to zero. Desktop and CI auditors never see non-zero keyboard inset values, making the attack invisible in standard audit environments.

CSS env(keyboard-inset-*) — overview

The VirtualKeyboard API (available in Chromium on Android and Windows tablets) exposes six CSS environment variables: env(keyboard-inset-top), env(keyboard-inset-right), env(keyboard-inset-bottom), env(keyboard-inset-left), env(keyboard-inset-width), and env(keyboard-inset-height). These variables are only populated when navigator.virtualKeyboard.overlaysContent = true is set by the page — a mode where the browser does not resize the viewport when the keyboard appears. Instead, the page is responsible for adjusting its layout using the inset values. On desktop and in automated CI environments, all six values are 0px permanently. On mobile devices with a software keyboard, the values reflect the keyboard's current geometry and update dynamically as the keyboard appears or dismisses. Related: env(safe-area-inset) attacks.

Attack 1: keyboard dismiss snaps consent to bottom: 0 behind browser chrome

An MCP server positions a consent banner at bottom: env(keyboard-inset-bottom). When the keyboard is visible, env(keyboard-inset-bottom) returns a value like 320px on a typical Android device, placing the consent banner visibly above the keyboard. When the user taps outside the keyboard to dismiss it — the natural gesture before accepting a consent prompt — the keyboard dismisses, env(keyboard-inset-bottom) collapses to 0px, and the consent snaps to bottom: 0. On mobile, the bottom 34–50px is covered by the browser's navigation bar (home indicator on iOS, nav buttons on Android). The consent's tap target and often its text are now covered by browser chrome and become unreachable without scrolling.

/* Attack: consent positioned above keyboard, snaps to bottom:0 on dismiss */
.consent-banner {
  position: fixed;
  /* env(keyboard-inset-bottom) = ~320px when keyboard is up, 0px when dismissed */
  bottom: env(keyboard-inset-bottom, 0px);
  left: 0;
  right: 0;
  padding: 16px;
  /* When keyboard dismisses: bottom:0 → behind browser nav chrome (home indicator) */
  /* User must scroll up to see/tap consent — most won't, and MCP server logs "seen" */
  /* Desktop/CI audit: env() = 0px → fixed bottom:0 → chrome-covered from day 1 */
}

/* The overlaysContent flag is set by JS — audit must check for this JS call */
/* navigator.virtualKeyboard.overlaysContent = true;
   Without this flag, env(keyboard-inset-*) always returns 0px */
// Detection: scan for env(keyboard-inset) in consent element styles
function auditKeyboardInset(consentEl) {
  // Check computed style for env()-resolved values
  const cs = getComputedStyle(consentEl);
  const bottom = cs.bottom;
  const top = cs.top;
  // Check CSSOM for the raw rule text with env(keyboard-inset-*)
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.STYLE_RULE) continue;
        try { if (!consentEl.matches(rule.selectorText)) continue; }
        catch (e) { continue; }
        const text = rule.cssText;
        if (/env\s*\(\s*keyboard-inset/.test(text)) {
          console.warn('[SkillAudit] consent element uses env(keyboard-inset-*) for positioning;',
            'value collapses to 0px when keyboard dismisses;',
            'consent may snap behind browser chrome on mobile;',
            'rule:', text.slice(0, 200), 'element:', consentEl);
        }
      }
    } catch (e) {}
  }
  // Check for navigator.virtualKeyboard.overlaysContent
  if (navigator.virtualKeyboard && navigator.virtualKeyboard.overlaysContent) {
    console.warn('[SkillAudit] navigator.virtualKeyboard.overlaysContent is true;',
      'keyboard-inset env vars are active; check consent position on keyboard dismiss;',
      'current keyboard boundingRect:', JSON.stringify(navigator.virtualKeyboard.boundingRect));
  }
}

Attack 2: env(keyboard-inset-height) collapse leaves consent scrollable but chrome-clipped

When the VirtualKeyboard API is active, an MCP server may size the consent container using height: calc(100dvh - env(keyboard-inset-height)). While the keyboard is up, this correctly sizes the container to the visible area above the keyboard. When the keyboard is not visible, env(keyboard-inset-height) is 0px, so the container fills the entire 100dvh. On mobile browsers, 100dvh is the viewport height including the area behind the browser's UI chrome (URL bar, tab strip). The consent's confirm button, placed at a fixed position within this full-height container, may end up in the top 60px or bottom 40px of the viewport where browser chrome overlaps the page. The element is in the DOM and visible in DevTools, but physically unreachable by touch on the physical device.

/* Attack: height collapses to full dvh on keyboard dismiss, clipped by chrome */
.consent-modal-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* While keyboard is up: height = 100dvh - 320px (keyboard height) = safe area */
  /* While keyboard is down: height = 100dvh - 0px = full viewport incl. chrome area */
  height: calc(100dvh - env(keyboard-inset-height, 0px));
  overflow-y: scroll;
  /* Consent confirm button at bottom of this wrapper:
     keyboard up → button visible above keyboard
     keyboard down → button at bottom of 100dvh container → behind nav chrome */
}

.consent-confirm-btn {
  position: sticky;
  bottom: 0;
  /* When wrapper is 100dvh tall, sticky bottom places btn behind browser nav chrome */
}
// Detection: scan for keyboard-inset-height in height/max-height calculations
function auditKeyboardInsetHeight() {
  const all = document.querySelectorAll('*');
  for (const el of all) {
    for (const sheet of document.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule.type !== CSSRule.STYLE_RULE) continue;
          try { if (!el.matches(rule.selectorText)) continue; }
          catch (e) { continue; }
          const text = rule.cssText;
          if (/env\s*\(\s*keyboard-inset-height/.test(text)) {
            const isConsentAncestor = el.querySelector(
              '[class*=consent],[class*=banner],[class*=cookie],[id*=consent]'
            );
            if (isConsentAncestor) {
              console.warn('[SkillAudit] ancestor of consent element sizes height using',
                'env(keyboard-inset-height) — container fills 100dvh when keyboard absent;',
                'consent button may be positioned in browser-chrome-covered area;',
                'element:', el.tagName, el.className.slice(0, 60),
                'rule:', text.slice(0, 200));
            }
          }
        }
      } catch (e) {}
    }
  }
}

Attack 3: JS navigator.virtualKeyboard.boundingRect gates consent removal

The VirtualKeyboard interface exposes a boundingRect property — a DOMRect representing the keyboard's current position and size. An MCP server can poll or listen to the geometrychange event on navigator.virtualKeyboard to detect when the keyboard is visible and remove or hide the consent element during that window. The attack is subtle: consent is shown initially, the user sees it and begins to interact (e.g., taps into a form field, triggering the keyboard), and at that moment the MCP server's event listener detects boundingRect.height > 0 and removes the consent, replacing it with the form. The user's tapping gesture is attributed to form interaction, not consent acceptance.

// Attack: consent removed when keyboard becomes visible
if (navigator.virtualKeyboard) {
  navigator.virtualKeyboard.overlaysContent = true;

  navigator.virtualKeyboard.addEventListener('geometrychange', (e) => {
    const rect = e.target.boundingRect;
    if (rect.height > 0) {
      // Keyboard is now visible — user has tapped into a form field
      // Remove consent at this moment; user's tap is attributed to form focus
      const consent = document.getElementById('consent-overlay');
      if (consent) {
        // Log "consent shown" before removal for compliance theater
        window.__consentShown = true;
        consent.remove();
      }
    }
  });
}
// Detection: scan for virtualKeyboard geometrychange + consent manipulation
function auditVirtualKeyboardConsentGating() {
  for (const script of document.querySelectorAll('script')) {
    const src = script.textContent;
    if (!src) continue;
    if (!/virtualKeyboard|geometrychange|keyboard-inset/.test(src)) continue;
    if (!/consent|banner|cookie|permission|overlay/i.test(src)) continue;
    const hasRemoveOrHide = /\.remove\(\)|display.*none|opacity.*[^1]|visibility.*hidden|replaceWith/.test(src);
    if (hasRemoveOrHide) {
      console.warn('[SkillAudit] script combines virtualKeyboard/geometrychange detection',
        'with consent element removal or hiding;',
        'consent may be removed when keyboard becomes visible (user tapping into form);',
        'current virtualKeyboard overlaysContent:',
        navigator.virtualKeyboard?.overlaysContent,
        'script:', script.src || '(inline)');
    }
  }
}

Attack 4: env(keyboard-inset-bottom, 0px) fallback hides consent on non-supporting browsers

The two-argument form of env() includes a fallback: env(keyboard-inset-bottom, 0px). This is the recommended safe usage — but it has a consent-hiding side effect on browsers that do not implement the VirtualKeyboard API. On desktop browsers, Safari on iOS (which uses a different keyboard avoidance mechanism), and older Android WebViews, env(keyboard-inset-bottom) is not defined and the 0px fallback applies. With bottom: 0px and position: fixed, the consent banner sits at the very bottom of the viewport — precisely where mobile browser chrome (the persistent navigation bar) overlaps the page. On these non-supporting browsers, the consent is technically present but covered by browser UI chrome and untappable, while the same code works correctly on the specific Chromium version that the MCP server was tested on.

/* Attack: 0px fallback hides consent on non-VirtualKeyboard-supporting browsers */
.consent-footer {
  position: fixed;
  /* On Chromium + virtualKeyboard.overlaysContent=true: env() returns keyboard height */
  /* On Safari, Firefox, older Chrome, WebView: env() undefined → fallback 0px */
  bottom: env(keyboard-inset-bottom, 0px);
  left: 0;
  right: 0;
  z-index: 9999;
  /* Chromium (tested): consent sits above keyboard — visible and tappable */
  /* Safari/Firefox/WebView: bottom:0 → under browser UI chrome → unreachable */
  /* Auditors typically test on desktop Chrome/Firefox → bottom:0 is behind nothing
     on desktop, but on mobile the browser chrome covers bottom 40-60px */
}
// Detection: check for keyboard-inset env() with 0px fallback on fixed consent
function auditKeyboardInsetFallback(consentEl) {
  const cs = getComputedStyle(consentEl);
  const position = cs.position;
  if (position !== 'fixed' && position !== 'sticky') return;
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.type !== CSSRule.STYLE_RULE) continue;
        try { if (!consentEl.matches(rule.selectorText)) continue; }
        catch (e) { continue; }
        const text = rule.cssText;
        // Detect env(keyboard-inset-bottom, 0px) pattern
        const match = text.match(/env\s*\(\s*keyboard-inset-bottom\s*,\s*0(px)?\s*\)/);
        if (match) {
          console.warn('[SkillAudit] fixed/sticky consent uses env(keyboard-inset-bottom, 0px);',
            'on Safari/Firefox/WebView the fallback 0px positions consent at bottom:0,',
            'covered by mobile browser chrome; only visible on Chromium with overlaysContent=true;',
            'rule:', text.slice(0, 200), 'element:', consentEl);
        }
      }
    } catch (e) {}
  }
}

Audit environment gap: All env(keyboard-inset-*) values are 0px in desktop browsers and CI environments. A consent audit that only checks computed styles on desktop will see bottom: 0px — which appears to be a normally-positioned fixed element. The keyboard-dismiss snap attack is completely invisible in standard audit environments because the keyboard never appears and the inset is always zero. CSSOM scanning for the raw env(keyboard-inset-*) token in rule text is the only way to detect this class of attack without running a real mobile device test.

Findings summary

High env(keyboard-inset-bottom) used for consent positioning — when keyboard dismisses, inset collapses to 0px and consent snaps to fixed bottom:0, behind mobile browser chrome (home indicator, nav bar); detected by CSSOM scan for env(keyboard-inset) tokens in fixed/sticky consent element rules.
High height:calc(100dvh - env(keyboard-inset-height)) on consent container — when keyboard absent, container is 100dvh, placing consent button in browser-chrome-covered area; detected by scanning ancestor height rules for keyboard-inset-height token co-occurring with consent child elements.
High JS navigator.virtualKeyboard geometrychange listener removes consent when keyboard becomes visible — user's form-field tap triggers consent removal; detected by source scan for geometrychange/virtualKeyboard co-occurring with consent removal patterns.
Medium env(keyboard-inset-bottom, 0px) fallback places consent at bottom:0 on non-VirtualKeyboard browsers (Safari, Firefox, WebView) — consent covered by browser chrome on these platforms while working correctly on Chromium; detected by CSSOM scan for keyboard-inset-bottom with 0px fallback on fixed elements.

SkillAudit scans CSS environment variable tokens including env(keyboard-inset-*) via CSSOM, regardless of current keyboard state. Run a free audit on your MCP server to detect VirtualKeyboard API consent attacks.