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.875px — min(1dvh, 0.5dvw) = 1.875px. On landscape (812px wide, 375px tall): 1dvh = 3.75px and 0.5dvw = 4.06px — min() = 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
| ID | Property / technique | Audit environment trap | Severity |
|---|---|---|---|
| SA-CSS-FSDV-001 | font-size: 0.5dvh | Desktop resolves to near-threshold; mobile sub-threshold | High |
| SA-CSS-FSDV-002 | font-size: 1svh (small viewport) | svh pinned to chrome-visible minimum; mobile always sub-threshold | High |
| SA-CSS-FSDV-003 | font-size: min(1dvh, 0.5dvw) | min() ensures portrait and landscape both sub-threshold | High |
| SA-CSS-FSDV-004 | JS visualViewport resize → style | Static CSS is 16px; collapse only on mobile chrome event | High |
Consolidated finding blocks
parseFloat(getComputedStyle(el).fontSize) < 10 evaluated at mobile viewport dimensions.
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.
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.
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