MCP server CSS font-size dynamic viewport units security: dvh, dvw, svh, svmin font-size attacks collapse consent text on mobile browser chrome events

Published 2026-08-07 — SkillAudit Research

CSS introduced four families of viewport units in 2022: large (lvh, lvw), small (svh, svw), dynamic (dvh, dvw), and unit-derived (dvmin, dvmax). The dv* units track the viewport as the browser chrome (address bar, bottom navigation strip) appears and disappears. When used as font-size values rather than sizing values, they create consent text whose legibility changes dynamically: readable when the browser chrome is hidden, illegible when it appears — without any JavaScript involvement. This attack class is distinct from viewport-units-security (which covers width/height sizing) and from font-size-security (which covers fixed sub-pixel px values).

The attack surface is mobile-specific. On a standard 812px-tall iPhone, 1dvh resolves to 8.12px when the browser chrome is hidden (full viewport) and falls to approximately 7.3px or below when the iOS Safari address bar expands. A font-size of 0.8dvh resolves to 6.5px at full viewport — already below the 10px legibility floor — and shrinks further when chrome appears. A desktop or automated audit running at 1440px viewport height sees 0.8dvh = 11.5px (above threshold) and reports no issue. The mobile user at interaction time gets sub-threshold text.

Detection gap: Standard font-size checks read the declared property (el.style.fontSize) and see a viewport-relative value, not a pixel value. getComputedStyle(el).fontSize resolves to the current pixel value — but only at the current viewport dimensions. Audits must run at 375px-wide, 812px-tall mobile viewport with both chrome-hidden and chrome-visible visual viewport states.

Attack 1: font-size:0.5dvh — sub-threshold on standard mobile viewport (SA-CSS-FSDV-001)

On a standard 812px-tall iPhone viewport, font-size: 0.5dvh resolves to 4.06px — well below the 10px legibility floor. The attack is invisible at desktop viewport: at 900px height, 0.5dvh = 4.5px (still sub-threshold), but at 1080px height, 0.5dvh = 5.4px, and at 1440px, 0.5dvh = 7.2px — still below 10px but approaching. An automated audit at 1920px sees 9.6px, nearly at threshold. The declared CSS value 0.5dvh does not look suspicious — it's a common unit for responsive design.

/* MCP attack: */
.consent-disclosure {
  font-size: 0.5dvh;
  /* Desktop 1440px: 7.2px — still sub-threshold
     Mobile 812px:   4.06px — hairline dot rows
     Mobile 667px:   3.35px — sub-pixel, rounds to 3px
     When browser chrome appears on mobile:
       dvh shrinks → font-size shrinks further below already-illegible value */
}

// Detection: always resolve to pixels at multiple viewport sizes
function checkDynamicViewportFontSize(el) {
  const computed = parseFloat(window.getComputedStyle(el).fontSize);
  if (computed < 10) {
    console.error('SA-CSS-FSDV-001: font-size below legibility threshold', {
      el,
      computedPx: computed,
      viewportHeight: window.innerHeight
    });
  }
  // Also check declared value for viewport unit usage
  const declared = el.style.fontSize || window.getComputedStyle(el).getPropertyValue('font-size');
  if (/dvh|dvw|svh|svw|lvh|lvw|dvmin|dvmax/i.test(declared)) {
    console.warn('SA-CSS-FSDV-001: font-size uses dynamic viewport unit — may collapse on mobile:', declared);
  }
}

Attack 2: font-size:1svh — small viewport unit pins to chrome-visible minimum (SA-CSS-FSDV-002)

The small viewport unit (svh) is permanently sized to the smallest possible viewport — the state with browser chrome fully visible. On iOS Safari, this is approximately 548px tall on a 812px device (264px consumed by chrome). 1svh = 5.48px — sub-threshold. Unlike dvh, svh does not change when chrome appears or disappears; it is permanently at the minimum. The attack manifests on mobile regardless of chrome state. On a 900px desktop, 1svh also resolves to the small viewport which is equal to the window inner height — typically the full viewport — so the desktop audit may see 1svh = 900px × 1% = 9px, borderline sub-threshold, or = 900px × 1% = 9px still below 10px. Distinct from dvh in that the value does not change at runtime; the attack is purely environment-dependent.

/* MCP attack: */
.consent-terms {
  font-size: 1svh;
  /* svh = small viewport height (chrome-visible size)
     iOS Safari 812px device: svh ≈ 548px, so 1svh ≈ 5.5px
     This value is constant — does NOT change at runtime
     Desktop may see larger computed value if UA does not distinguish sv/dv */
}

/* CSS custom property obfuscation: */
:root {
  --ui-small-unit: 1svh;        /* looks like a design token */
}
.consent-terms {
  font-size: var(--ui-small-unit);   /* stylesheet shows only var() reference */
}

// Detection: check computed pixel size regardless of declared unit
function detectSvhFontSize(el) {
  const px = parseFloat(getComputedStyle(el).fontSize);
  if (px < 10) {
    const declared = getComputedStyle(el).fontSize;
    console.error('SA-CSS-FSDV-002: sub-threshold font-size (svh unit)', { el, px, declared });
  }
}

Attack 3: font-size:min(1dvh, 0.5dvw) — CSS min() ensures smallest dynamic value (SA-CSS-FSDV-003)

CSS min() applied to two dynamic viewport units always picks the smallest. On a portrait phone (375px wide, 812px tall), 1dvh = 8.12px and 0.5dvw = 1.875pxmin(1dvh, 0.5dvw) = 1.875px. On landscape (812px wide, 375px tall): 1dvh = 3.75px and 0.5dvw = 4.06pxmin() = 3.75px. Both orientations produce sub-threshold text. The declaration looks like a legitimate responsive font-size pattern. A scanner that checks for viewport unit strings (but not the resolved pixel value) sees a min() expression with what appear to be reasonable-looking unit values, not an explicit 0.5px.

/* MCP attack: */
.consent-disclosure {
  font-size: min(1dvh, 0.5dvw);
  /* Portrait 375×812: min(8.12px, 1.875px) = 1.875px — sub-pixel
     Landscape 812×375: min(3.75px, 4.06px) = 3.75px — sub-pixel
     Desktop 1440×900:  min(9px, 7.2px) = 7.2px — still sub-threshold
     Desktop 1920×1080: min(10.8px, 9.6px) = 9.6px — borderline */
}

/* With custom property indirection: */
:root {
  --mcp-body-size-v: 1dvh;
  --mcp-body-size-h: 0.5dvw;
}
.consent-disclosure {
  font-size: min(var(--mcp-body-size-v), var(--mcp-body-size-h));
}

// Detection: parse computed value (getComputedStyle resolves min() automatically)
function detectDynamicMinFontSize(el) {
  const px = parseFloat(getComputedStyle(el).fontSize);
  if (px < 10) {
    console.error('SA-CSS-FSDV-003: font-size min() with dynamic units collapses below threshold', { el, px });
  }
}

Attack 4: JS visualViewport resize → font-size collapse at browser chrome show (SA-CSS-FSDV-004)

The window.visualViewport API fires a resize event when the browser chrome appears or disappears (address bar expand/collapse). An MCP server listens for this event and sets the consent element's font-size to 0px when the visual viewport height drops (chrome appeared), reverting to 16px when it expands again (chrome hidden). The baseline CSS has a legitimate font-size: 16px — all static and load-time audits pass. The collapse only occurs on actual mobile user interaction when the install dialog is being used (user scrolls, taps, triggering chrome show). No dvh is required — the attack is pure JavaScript.

/* MCP attack — no suspicious CSS: */
.consent-disclosure {
  font-size: 16px;    /* looks completely legitimate */
  transition: font-size 0.1s ease;
}

// MCP JS — visual viewport resize listener:
if (window.visualViewport) {
  let lastHeight = window.visualViewport.height;
  window.visualViewport.addEventListener('resize', () => {
    const currentHeight = window.visualViewport.height;
    const delta = lastHeight - currentHeight;
    lastHeight = currentHeight;
    if (delta > 50) {
      // Browser chrome appeared (viewport shrank by >50px)
      document.querySelectorAll('.consent-disclosure').forEach(el => {
        el.style.fontSize = '0px';   // text collapses at chrome show
      });
    } else if (delta < -50) {
      // Browser chrome hidden (viewport grew)
      document.querySelectorAll('.consent-disclosure').forEach(el => {
        el.style.fontSize = '';      // restore default
      });
    }
  });
}

// Detection: MutationObserver on style attribute + visualViewport simulation
function detectVisualViewportFontCollapse() {
  document.querySelectorAll('.consent-disclosure, [data-consent], #consent-panel').forEach(el => {
    const observer = new MutationObserver(mutations => {
      mutations.forEach(m => {
        if (m.attributeName === 'style') {
          const fs = parseFloat(getComputedStyle(el).fontSize);
          if (fs < 10) console.error('SA-CSS-FSDV-004: font-size collapsed via visualViewport resize', { el, fs });
        }
      });
    });
    observer.observe(el, { attributes: true, attributeFilter: ['style'] });
  });
  // Simulate chrome-show: dispatch fake visualViewport resize
  if (window.visualViewport) {
    Object.defineProperty(window.visualViewport, 'height', {
      get: () => window.innerHeight - 80,   // simulate 80px chrome bar
      configurable: true
    });
    window.visualViewport.dispatchEvent(new Event('resize'));
  }
}

All four attacks share one root cause: font-size is evaluated in a desktop environment where dynamic viewport units resolve to large values. The canonical detection is parseFloat(getComputedStyle(el).fontSize) < 10 — which resolves any CSS function, unit, or var() chain to the actual pixel size. The audit must run at 375×812px mobile viewport. Fixed sub-pixel font-size attacks are also caught by the same threshold check.

Attack summary

IDProperty / techniqueAudit environment trapSeverity
SA-CSS-FSDV-001font-size: 0.5dvhDesktop resolves to near-threshold; mobile sub-thresholdHigh
SA-CSS-FSDV-002font-size: 1svh (small viewport)svh pinned to chrome-visible minimum; mobile always sub-thresholdHigh
SA-CSS-FSDV-003font-size: min(1dvh, 0.5dvw)min() ensures portrait and landscape both sub-thresholdHigh
SA-CSS-FSDV-004JS visualViewport resize → styleStatic CSS is 16px; collapse only on mobile chrome eventHigh

Consolidated finding blocks

High CSS font-size:0.5dvh collapses consent text to 4px on mobile — desktop audit passes: MCP server sets consent element font-size to a dynamic viewport height fraction that resolves below the 10px legibility floor on any mobile viewport. Desktop audits at 1440px+ see a larger computed value; the same CSS produces illegible text at 375×812px. Detection requires parseFloat(getComputedStyle(el).fontSize) < 10 evaluated at mobile viewport dimensions.
High CSS font-size:1svh (small viewport unit) permanently pins to chrome-visible minimum — sub-threshold on all mobile: The svh unit permanently resolves to the smallest possible viewport height (with browser chrome fully visible). On iOS Safari 812px devices this is approximately 548px, so 1svh ≈ 5.5px. Unlike dvh, this value does not change at runtime — consent text is permanently sub-threshold on mobile regardless of chrome state.
High CSS font-size:min(dvh, dvw) guarantees sub-threshold in both portrait and landscape: CSS min() with two dynamic viewport units picks the minimum, ensuring that regardless of device orientation the resolved font-size is below the legibility floor. The declaration superficially resembles a legitimate responsive design pattern. Only computed-pixel evaluation catches this attack.
High JS visualViewport resize collapses consent font-size at browser chrome show: MCP server listens for window.visualViewport resize events triggered by browser chrome show/hide transitions. When the viewport shrinks (chrome appears), the consent element receives font-size: 0px via inline style. The baseline CSS is 16px — all static and load-time audits pass. Collapse occurs only on mobile during real user interaction with the install dialog.

CSS font-size fixed sub-pixel attacks  |  CSS viewport units sizing attacks  |  Security Checklist