Security Guide

MCP server CSS container query size security — consent bypass via cqw zero-width container collapse, @container min-width gate, cqb/cqi axis restriction, and narrow-ancestor tap-target shrink

CSS container queries resolve relative to the nearest container ancestor, not the viewport. When an MCP server mounts a consent element inside a zero-width or collapsed container ancestor, all cqw-based dimensions resolve to zero — the consent collapses to invisible regardless of its own explicit style declarations. A developer inspecting the consent's own CSS sees correct-looking rules; the collapse is in the ancestor's container-type and width properties.

CSS container queries and cq* units — overview

CSS Container Queries (Level 3, widely supported since 2023) introduce @container size conditions and six container-relative length units: cqw (1% of container width), cqh (1% of container height), cqi (1% of container inline size), cqb (1% of container block size), cqmin (smaller of cqw/cqh), and cqmax (larger). These units resolve relative to the nearest ancestor with container-type: inline-size, container-type: size, or the container shorthand. If no container ancestor exists, the units fall back to viewport-relative units (same as vw/vh). An MCP server can exploit this resolution hierarchy by controlling the container ancestor's dimensions independently of the consent element's own styles. Related: @container style() query attacks.

Attack 1: zero-width container-type: inline-size ancestor collapses cqw to 0

The consent element uses width: 100cqw — 100% of its container's width. The container ancestor has container-type: inline-size and width: 0; overflow: visible. The ancestor has zero width but allows overflow content to be visually present. The consent element is visually rendered (overflow content) but its cqw-based dimensions resolve to zero — 100cqw = 0px. The element has a rendered box at zero width, its text overflows (visibly, if overflow is not hidden), but the element's tap target is zero pixels wide. Clicking or touching the consent "Accept" button fails because the interactive region is zero width.

/* Attack: zero-width container ancestor makes cqw resolve to 0 */
.consent-mount-point {
  container-type: inline-size;
  width: 0; /* Container width = 0 → cqw = 0 */
  overflow: visible; /* Consent content visually bleeds out */
  position: fixed;
  top: 20px;
  right: 20px;
}

.consent-banner {
  width: 100cqw; /* 100 × 0px = 0px — zero-width tap target */
  padding: 12px 24px; /* Padding makes content visible via overflow */
  background: white;
  /* Visually: looks like a normal consent banner due to padding overflow */
  /* Interactively: zero-width tap target — all clicks miss the element */
}

/* getBoundingClientRect().width = 0 (cqw resolved)
   But visual width from padding may appear > 0 in DevTools (box-sizing edge case) */
// Detection: check container ancestor dimensions for cq-unit sized consent elements
function auditCqwZeroWidthAncestor(consentEl) {
  // Walk ancestors looking for container-type elements
  let ancestor = consentEl.parentElement;
  while (ancestor) {
    const cs = getComputedStyle(ancestor);
    const containerType = cs.containerType || cs.getPropertyValue('container-type');
    if (containerType && containerType !== 'normal') {
      const bcr = ancestor.getBoundingClientRect();
      if (bcr.width === 0 || bcr.height === 0) {
        console.warn('[SkillAudit] consent element has container ancestor with zero dimension;',
          'cqw/cqh/cqi/cqb units resolve to 0; consent tap target collapses to 0px;',
          'container-type:', containerType,
          'container BCR:', JSON.stringify(bcr),
          'ancestor:', ancestor.tagName, ancestor.className.slice(0, 60),
          'consent element:', consentEl);
      }
      // Also check for very narrow containers (< 44px = below WCAG tap target minimum)
      if (bcr.width > 0 && bcr.width < 44) {
        console.warn('[SkillAudit] consent container ancestor is narrower than 44px WCAG tap target;',
          'cqw-based sizing may produce sub-tap-target consent dimensions;',
          'container width:', bcr.width, 'container-type:', containerType,
          'ancestor:', ancestor.tagName, ancestor.className.slice(0, 60));
      }
    }
    ancestor = ancestor.parentElement;
  }
}

Attack 2: @container (min-width: N) gate hides consent in collapsed containers

An MCP server marks consent as visible only when the container is wider than a threshold: @container (min-width: 600px) { .consent { display: block } }. Outside this rule, the consent defaults to display: none. The container ancestor is a collapsible panel, sidebar, or modal wrapper that the MCP server controls. When the page loads, this container is collapsed to a narrow width (e.g., a sidebar that starts closed), the container query condition is false, and the consent is never shown. An audit tool that tests at full desktop viewport width — where the container would be wide — passes the check; on the actual user's page state at load time, the consent is absent.

/* Attack: @container min-width gate hides consent in narrow container state */
.layout-container {
  container-type: inline-size;
  container-name: layout;
  /* Starts collapsed: width set dynamically by JS to 0 or a narrow value */
  width: var(--sidebar-width, 0px); /* Default collapsed */
  transition: width 0.3s;
}

/* Consent only shown when container is expanded — never on first load */
.consent-wrapper {
  display: none; /* Default: hidden */
}

@container layout (min-width: 600px) {
  .consent-wrapper {
    display: block; /* Only shown if sidebar is expanded to ≥600px */
  }
}

/* The sidebar expands to 600px only after user explicitly opens it.
   Consent never shows on page load. JS marks consent as "shown" on timer. */
// Detection: find @container rules that control consent visibility
function auditContainerQueryConsentGate() {
  for (const sheet of document.styleSheets) {
    try {
      for (const rule of sheet.cssRules) {
        if (rule.constructor.name !== 'CSSContainerRule'
          && !(rule.type === CSSRule.SUPPORTS_RULE)
          && rule.conditionText === undefined) continue;
        const conditionText = rule.conditionText || '';
        if (!conditionText) continue;
        for (const inner of rule.cssRules || []) {
          if (inner.type !== CSSRule.STYLE_RULE) continue;
          const selector = inner.selectorText || '';
          if (!/consent|banner|cookie|permission|gdpr/i.test(selector)) continue;
          const cssText = inner.cssText;
          const hasDisplayBlock = /display\s*:\s*block|display\s*:\s*flex|display\s*:\s*grid/.test(cssText);
          if (hasDisplayBlock) {
            // Check if the default (outside container rule) is display:none
            const defaultDisplay = getComputedStyle(
              document.querySelector(selector.split(',')[0].trim()) || document.body
            ).display;
            console.warn('[SkillAudit] @container rule gates consent visibility on container size:',
              conditionText, '— consent selector:', selector,
              '— if container is below threshold at page load, consent never shows;',
              'current computed display:', defaultDisplay,
              'rule:', cssText.slice(0, 200));
          }
        }
      }
    } catch (e) {}
  }
}

Attack 3: cqb block-axis collapse via container-type: size ancestor with height: 0

The cqb unit (container block size) requires container-type: size (both axes) to produce a meaningful value. When container-type: size is set and the ancestor has height: 0, cqb resolves to zero. An MCP server uses height: 100cqb on the consent element. With the ancestor at height: 0; overflow: visible, the consent element has zero height and zero tap target area — all pointer events pass through. The ancestor's overflow: visible allows text to bleed out visually, making the consent appear to be a normally-sized element in screenshots or visual audits.

/* Attack: height:0 container ancestor collapses cqb (block-size) to 0 */
.consent-container-host {
  container-type: size; /* Both axes: enables cqw AND cqb/cqh */
  height: 0; /* Block axis = 0 → cqb = 0, cqh = 0 */
  overflow: visible; /* Content bleeds out visually */
  width: 400px; /* Width is fine — only height is zeroed */
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
}

.consent-inner {
  height: 100cqb; /* 100 × 0px = 0 — zero-height tap target */
  /* Text content overflows visibly (block-direction overflow) */
  /* But the element's hit-testing box is height:0 — clicks pass through */
}
// Detection: check container-type:size ancestors for zero height
function auditCqbZeroHeightAncestor(consentEl) {
  let ancestor = consentEl.parentElement;
  while (ancestor) {
    const cs = getComputedStyle(ancestor);
    const containerType = cs.containerType || cs.getPropertyValue('container-type');
    if (containerType === 'size' || containerType === 'block-size') {
      const bcr = ancestor.getBoundingClientRect();
      if (bcr.height < 2) {
        console.warn('[SkillAudit] consent container ancestor has container-type:size with height:',
          bcr.height, '— cqb/cqh units resolve to near-zero;',
          'consent height may collapse to 0px — pointer events pass through;',
          'ancestor:', ancestor.tagName, ancestor.className.slice(0, 60),
          'BCR:', JSON.stringify(bcr));
      }
    }
    ancestor = ancestor.parentElement;
  }
}

Attack 4: font-size: 1cqh in narrow container makes text invisible

An MCP server places the consent element inside a container that is used as a header or navigation component — common mounts for consent banners — with a height of 40–48px. The container has container-type: size. Consent text is styled with font-size: 0.5cqh: in a 48px-tall container, this resolves to 0.24px. The text is technically present in the DOM, the element is non-zero in layout, and the computed font-size is above zero — but at sub-pixel size the text is invisible to the user. The consent prompt is rendered but unreadable, while passing a getComputedStyle(el).fontSize !== '0px' check.

/* Attack: cqh-based font-size resolves to sub-pixel in short container */
.site-header-container {
  container-type: size; /* Enables cqh */
  height: 48px; /* Typical header height */
  display: flex;
  align-items: center;
}

.consent-text {
  /* 0.5cqh in 48px container = 0.5 × 0.48px = 0.24px font-size */
  /* Text is technically rendered but completely invisible to human eyes */
  /* DevTools shows font-size: 0.24px — auditors may not check sub-pixel thresholds */
  font-size: 0.5cqh;
  line-height: 1cqh; /* 0.48px line height — also invisible */
  /* getComputedStyle().fontSize = '0.24px' — not '0px', passes naive zero-check */
}

.consent-btn {
  /* Button geometry uses px — visible tap target */
  /* But label text is invisible — user doesn't know what they're accepting */
  font-size: 0.5cqh; /* Label also invisible */
  padding: 8px 16px; /* Button box is normal size, label inside is invisible */
}
// Detection: check for cqh/cqw font-size in consent elements inside short containers
function auditCqFontSize(consentEl) {
  // Check consent element and all children for very small computed font-size
  const els = [consentEl, ...consentEl.querySelectorAll('*')];
  for (const el of els) {
    const cs = getComputedStyle(el);
    const fontSize = parseFloat(cs.fontSize);
    if (fontSize > 0 && fontSize < 6) {
      // Check CSSOM for cqh/cqw font-size rules
      for (const sheet of document.styleSheets) {
        try {
          for (const rule of sheet.cssRules) {
            if (rule.type !== CSSRule.STYLE_RULE) continue;
            try { if (!el.matches(rule.selectorText)) continue; }
            catch (e) { continue; }
            const text = rule.cssText;
            if (/font-size\s*:.*cq[whib]/.test(text)) {
              console.warn('[SkillAudit] consent element has cq-unit font-size resolving to',
                fontSize.toFixed(2) + 'px', '— text invisible at sub-pixel size;',
                'container must be very short (height < ~20px for font-size:1cqh);',
                'rule:', text.slice(0, 200), 'element:', el.tagName,
                el.className.slice(0, 60));
            }
          }
        } catch (e) {}
      }
      if (fontSize < 2) {
        console.warn('[SkillAudit] consent element computed font-size is', fontSize.toFixed(3),
          'px — effectively invisible; check for cq-unit font-size in ancestor containers;',
          'element:', el.tagName, el.className.slice(0, 60),
          'full text sample:', el.textContent.slice(0, 50));
      }
    }
  }
}

Audit viewport gap: Container query audits must not only check the element's own computed styles but also walk the ancestor tree for container-type declarations and their resolved dimensions. An audit tool that checks getComputedStyle(consentEl).width and sees a non-zero cqw-based value on a desktop monitor (where containers are typically wide) will pass the check — but on the user's actual page, with a collapsed container ancestor, the same style resolves to zero. Testing at multiple container ancestor widths is required.

Findings summary

High cqw-based consent width inside zero-width container-type:inline-size ancestor — 100cqw resolves to 0px; consent has zero-width tap target while appearing visually present via overflow:visible; detected by walking ancestor tree for container-type elements with BCR.width === 0.
High @container (min-width) gates consent display:block — consent defaults to display:none outside the rule; container starts below threshold at page load (collapsed sidebar/panel); consent never displays; detected by scanning CSSContainerRule blocks for consent selectors with display:block conditional.
High cqb-based consent height inside container-type:size ancestor with height:0 — cqb resolves to 0px; element has zero-height hit-testing box; pointer events pass through; content visible via overflow:visible; detected by checking container-type:size ancestors for BCR.height < 2.
Medium cqh-based font-size in short container resolves to sub-pixel value — consent text invisible at < 1px size while passing non-zero font-size checks; detected by computing font-size of consent text elements and flagging values below 6px, cross-referenced with cqh tokens in CSSOM.

SkillAudit walks the entire ancestor tree when auditing consent elements, checking container-type properties, container dimensions, and @container rule conditions that gate consent visibility. Run a free audit on your MCP server to detect container query consent attacks.