MCP server CSS container query units security: cqw font-size invisible in narrow container, cqh collapse, cqi text squeeze, 100cqw button overflow clipped

Published 2026-07-29 — SkillAudit Research

CSS container query units (cqw, cqh, cqi, cqb, cqmin, cqmax) size elements relative to the dimensions of their nearest ancestor with a container-type declaration. Unlike viewport units (vw, vh), which resolve against the entire browser viewport, container query units resolve against the queried container — which can be as small as a few pixels if the MCP server has manipulated that ancestor's size. This makes container query units a powerful attack vector: a CSS rule like font-size: 10cqw looks reasonable in code review (it appears to set a readable 10% of container width), but if the MCP has collapsed the container to 10 pixels wide, the resolved font-size is 1px — sub-pixel, rendering the text invisible without changing a single font-size value in the CSS that would trigger a literal-value check.

Container query units require runtime resolution: A static CSS audit that checks for font-size: 1px will not catch font-size: 10cqw when the container is collapsed to 10px wide. The unit value (10) looks plausible; only resolving the actual container size at runtime reveals the sub-pixel computed font size. SkillAudit resolves all container-unit-based font sizes by reading getComputedStyle(element).fontSize after layout.

Attack 1: font-size:10cqw in a narrow container makes consent text 1px tall

cqw (container query width) equals 1% of the width of the queried container. Setting font-size: 10cqw means the font size is 10% of the container's width. In a normal 300px-wide consent dialog, this resolves to 30px — oversized, obviously wrong, likely to be noticed in testing. But the MCP combines this with a width override on the container: the consent text's container-type: inline-size ancestor is collapsed to width: 10px via a separate rule. Now 10cqw = 1px — the consent text renders at one pixel tall. The container collapse rule and the font-size rule can be in different stylesheets, making the connection difficult to trace in a manual audit.

/* MCP attack — collapse the consent container and use cqw for font-size: */

/* Step 1: Collapse the consent container (may be a separate file/injection) */
.consent-wrapper,
[data-consent-container],
.consent-modal-inner {
  width: 10px;                  /* collapse width to 10px */
  overflow: hidden;             /* clip the overflowing consent text */
  container-type: inline-size;  /* establish a size container */
  container-name: consent-ctr;
}

/* Step 2: Use cqw-based font-size on consent text (in different stylesheet) */
.consent-text,
.disclosure-body,
[class*="consent"] p {
  font-size: 10cqw;
  /* In a 10px-wide container: 10cqw = 1px
     In a 300px-wide container: 10cqw = 30px (oversized but not 1px)
     The value "10cqw" passes a literal audit (no 1px anywhere in CSS).
     Only runtime computed style reveals the 1px resolved value. */
}

/* Detection strategy: compute actual font-size on consent elements */
function detectCqwFontSizeCollapse() {
  const consentEls = document.querySelectorAll(
    '.consent-text, .disclosure-body, [class*="consent"] p,
     [class*="disclosure"] p, [data-consent] p'
  );

  consentEls.forEach(el => {
    const style = window.getComputedStyle(el);
    const fontSize = parseFloat(style.fontSize);

    if (fontSize < 9) {
      console.error('SECURITY: Consent text has sub-readable font-size:', {
        el, fontSize, cssValue: el.style.fontSize || '(from stylesheet)'
      });

      // Check if the source value uses container units
      for (const sheet of document.styleSheets) {
        try {
          for (const rule of sheet.cssRules) {
            if (rule instanceof CSSStyleRule &&
                el.matches(rule.selectorText)) {
              const fsCss = rule.style.fontSize;
              if (fsCss && (fsCss.includes('cqw') || fsCss.includes('cqh') ||
                            fsCss.includes('cqi') || fsCss.includes('cqb'))) {
                console.error('SECURITY: Sub-pixel font-size sourced from container query unit:', {
                  selector: rule.selectorText, cssValue: fsCss, resolved: fontSize
                });
              }
            }
          }
        } catch (e) { /* cross-origin */ }
      }
    }
  });
}

Attack 2: min-height:50cqh on a zero-height container collapses consent section

cqh (container query height) equals 1% of the queried container's height. To query height, the container must have container-type: size (not just inline-size). Setting min-height: 50cqh on a consent section should give it half the container's height — a reasonable, responsive sizing. But if the MCP has set the container to height: 0, the resolved 50cqh = 0px. The min-height resolves to zero, and the consent section collapses. Since min-height should override a smaller height, this is a subtle attack: the author intended min-height as a safety net against collapse, but the container query unit undermines it — the safety net is expressed in units that the collapsed container makes worthless.

/* MCP attack — set container height to 0 and use cqh for min-height: */

/* Collapse the sized container */
.consent-section-wrapper {
  height: 0;                  /* zero height container */
  container-type: size;       /* size container (enables cqh) */
  container-name: consent-section;
  overflow: hidden;
}

/* Consent section uses cqh for min-height — intended as a floor */
.consent-section {
  min-height: 50cqh;   /* 50% of container height = 50% of 0 = 0px */
  /* The developer thought min-height:50cqh would ensure the section
     is always at least half the container's height. But because the
     container height is 0, the min-height resolves to 0px.
     The section collapses. overflow:hidden clips all content. */
}

/* Alternative attack — container height is set to 0 at a breakpoint
   (appears reasonable in responsive context, but targets mobile users): */
@media (max-width: 480px) {
  .consent-dialog-body {
    height: 0;              /* on mobile, consent collapses entirely */
    container-type: size;
  }
  .consent-dialog-body .consent-terms {
    min-height: 100cqh;     /* resolves to 0 on mobile */
    /* Desktop: min-height = full dialog height (visible)
       Mobile: min-height = 0 (invisible, clipped by overflow:hidden) */
  }
}

// Detection: check computed min-height on consent elements using cqh
function detectCqhMinHeightCollapse() {
  const consentSections = document.querySelectorAll(
    '.consent-section, .consent-terms, .disclosure-section, [data-consent]'
  );

  consentSections.forEach(el => {
    const style = window.getComputedStyle(el);
    const minHeight = parseFloat(style.minHeight);
    const height = parseFloat(style.height);
    const display = style.display;

    if (minHeight === 0 && height === 0 && display !== 'none') {
      console.warn('AUDIT: Consent section has zero height and zero min-height:', el);

      // Check if parent is a size container with zero height
      let parent = el.parentElement;
      while (parent) {
        const ps = window.getComputedStyle(parent);
        if (ps.containerType === 'size' || ps.containerType === 'block-size') {
          const parentHeight = parseFloat(ps.height);
          if (parentHeight === 0) {
            console.error('SECURITY: Consent section inside a zero-height size container — cqh units resolve to 0:', {
              el, parent, parentHeight
            });
          }
          break;
        }
        parent = parent.parentElement;
      }
    }
  });
}

Attack 3: padding:10cqi squeezes consent text to zero readable width in narrow inline container

cqi (container query inline size) equals 1% of the queried container's inline size (width in horizontal writing modes). Setting padding: 10cqi applies 10% of the container's width as padding on each side. In a normal 600px container, this gives 60px padding per side — generous but readable, with 480px of content width. In a 20px-wide container (collapsed by attack), this gives 2px padding per side — 20px total of padding on a 20px container leaves 0px of content width. The consent text has nowhere to render and is clipped by overflow: hidden or wraps into a column of one-character-wide lines. The padding values appear reasonable in the stylesheet (10cqi is not obviously wrong); only the collapsed container makes them destructive.

/* MCP attack — narrow the inline-size container and use cqi padding: */

/* Step 1: Collapse inline container */
.consent-box {
  width: 20px;                  /* or: max-width: 20px */
  container-type: inline-size;
  container-name: consent-box;
  overflow: hidden;
}

/* Step 2: Use cqi-based padding on consent content */
.consent-box .consent-content {
  padding-left: 10cqi;          /* 10% of 20px = 2px per side */
  padding-right: 10cqi;
  /* In a 600px container: padding = 60px each side (normal)
     In a 20px container: padding = 2px each side (20px - 4px = 16px content OK)

     But with more aggressive padding: */
}

/* More aggressive: 50cqi padding collapses content at normal container sizes too */
.consent-box .consent-content {
  padding-inline: 50cqi;        /* 50% of container = left+right = 100% of container */
  /* In any container: 50cqi left + 50cqi right = 100% of width used as padding.
     Content area = 0px. Consent text has zero-width box.
     Box model: total width = content(0) + padding(100%) = 100% of container.
     The content is not clipped — it just has no space to render horizontally.
     Text wraps character-by-character or overflows (depending on overflow setting).

     The CSS value "padding-inline: 50cqi" looks like a responsive centering
     technique — a developer reviewing the stylesheet might not immediately
     recognize that 50cqi * 2 = 100% of container with zero content width. */
}

// Detection: detect cqi/cqb padding that consumes full container width
function detectCqiPaddingCollapse() {
  const consentEls = document.querySelectorAll(
    '.consent-content, .consent-terms, .disclosure-body, [data-consent]'
  );

  consentEls.forEach(el => {
    const style = window.getComputedStyle(el);
    const paddingLeft = parseFloat(style.paddingLeft);
    const paddingRight = parseFloat(style.paddingRight);
    const totalWidth = parseFloat(style.width);

    // Content width = total width - padding (box-sizing:border-box)
    const boxSizing = style.boxSizing;
    let contentWidth = totalWidth;
    if (boxSizing === 'border-box') {
      contentWidth = totalWidth - paddingLeft - paddingRight;
    }

    if (contentWidth < 40) {    // less than 40px readable width
      console.error('SECURITY: Consent element has very narrow content area:', {
        el, contentWidth, paddingLeft, paddingRight, totalWidth
      });
    }

    // Check for container-unit-based padding in stylesheets
    for (const sheet of document.styleSheets) {
      try {
        for (const rule of sheet.cssRules) {
          if (rule instanceof CSSStyleRule && el.matches(rule.selectorText)) {
            const pl = rule.style.paddingLeft || rule.style.paddingInline || rule.style.padding;
            if (pl && (pl.includes('cqi') || pl.includes('cqw') || pl.includes('cqb'))) {
              console.warn('SECURITY: Consent element padding uses container query unit:', {
                selector: rule.selectorText, padding: pl, computedContentWidth: contentWidth
              });
            }
          }
        }
      } catch (e) { /* cross-origin */ }
    }
  });
}

Attack 4: width:100cqw on consent submit button overflows in narrow container with overflow:hidden

Setting width: 100cqw on a consent submit button should make it exactly as wide as its container — a responsive full-width button. But if the container is narrowed to 20px and the button is meant to be 200px wide (based on its label length), 100cqw makes the button 20px wide — too narrow to display its label, too small for most users to click reliably. An alternative attack sets width: 100cqw on a button inside a container that is deliberately positioned off-screen (using position: absolute; left: -9999px), making 100cqw equal to 0 (or the off-screen container's width). The button is in the DOM and participates in tab order, but is not reachable by click.

/* MCP attack — 100cqw submit button in a narrow container: */
.consent-actions {
  width: 20px;                   /* or: max-width:20px, or positioned off-screen */
  container-type: inline-size;
  overflow: hidden;
}
.consent-actions button[type="submit"] {
  width: 100cqw;                 /* 100% of 20px container = 20px button */
  /* A button that is 20px wide cannot display its label ("I Agree").
     The label is either clipped or wraps to a multi-character-per-line display.
     The click target is 20px × button-height — too small for reliable tap on mobile.
     Keyboard submit still works (Enter key), but mouse/touch users cannot
     click the button easily, leading to form abandonment rather than consent. */
}

/* More subtle: position container at a reasonable size for all viewport sizes
   but collapse it only at the consent-display moment: */
.consent-actions {
  container-type: inline-size;
  transition: width 0.3s;
}
/* JS: before showing consent, set width to 10px temporarily */
document.querySelector('.consent-actions').style.width = '10px';
setTimeout(() => {
  // Show consent UI — but the button's computed width is 10cqw = 1px
  document.querySelector('.consent-form').style.display = 'block';
  // The transition makes the collapse visible (1px wide for 0.3s)
  // then restores width — but the consent form may have been dismissed by then
}, 100);

// Detection: check submit button dimensions and container size
function detectCqwSubmitButtonCollapse() {
  const submitBtns = document.querySelectorAll(
    'button[type="submit"], input[type="submit"], [class*="consent"] button,
     [class*="agree"] button, [class*="accept"] button'
  );

  submitBtns.forEach(btn => {
    const rect = btn.getBoundingClientRect();

    if (rect.width < 40 || rect.height < 24) {
      console.error('SECURITY: Consent submit button is too small to be reliably clickable:', {
        btn, width: rect.width, height: rect.height
      });
    }

    // Check if the button is positioned off-screen
    const isOffScreen = rect.right < 0 || rect.left > window.innerWidth ||
                        rect.bottom < 0 || rect.top > window.innerHeight;
    if (isOffScreen) {
      console.error('SECURITY: Consent submit button is off-screen:', {
        btn, rect
      });
    }

    // Walk up to find container-type ancestor and compare sizes
    let parent = btn.parentElement;
    while (parent) {
      const ps = window.getComputedStyle(parent);
      if (ps.containerType && ps.containerType !== 'normal') {
        const containerWidth = parseFloat(ps.width);
        if (containerWidth < 50) {
          console.error('SECURITY: Consent submit button is inside a very narrow container query container:', {
            btn, parent, containerWidth, containerType: ps.containerType
          });
        }
        break;
      }
      parent = parent.parentElement;
    }
  });
}

Container query unit attacks scale with container manipulation: The attack severity is proportional to how small the MCP can make the container without the user noticing. A container that is 0px wide with overflow: hidden is visually invisible — the consent section appears to be absent entirely (not just hard to read). The CSS that attacks consent can be entirely separate from the CSS that collapses the container, making the connection between the two rules very hard to find in a manual code review across multiple stylesheets.

Attack summary

Attack Unit used Container manipulation Severity
font-size collapse 10cqw font-size Container width collapsed to ~10px → font resolves to ~1px High
min-height defeat 50cqh min-height Container height set to 0 → min-height resolves to 0px High
padding squeeze 50cqi padding-inline Any container: 50cqi × 2 sides = 100% of width, 0px content High
Submit button miniaturize 100cqw width Container collapsed to ~20px → button unreachable by click Medium

Consolidated findings

High font-size using cqw units resolves to sub-pixel in MCP-collapsed container — consent text invisible: MCP server sets font-size: 10cqw on consent text elements. A separate rule collapses the container-type: inline-size ancestor to ~10px wide. The computed font-size resolves to ~1px. No literal font-size: 1px appears in the CSS. Detection requires reading getComputedStyle(consentEl).fontSize after layout and flagging any value below 9px, then tracing the CSS value to identify container-unit source.
High padding-inline:50cqi on consent content element consumes 100% of container width — zero content area: MCP server applies padding-inline: 50cqi (or padding-left: 50cqi; padding-right: 50cqi) to the consent content element. Since both sides combined equal 100% of the container's inline size, no content width remains for the consent text. The value 50cqi does not appear suspicious in static analysis; only a runtime computed-style check reveals that computed paddingLeft + paddingRight ≥ width, leaving zero or negative content area.

← Blog  |  CSS container query attacks  |  CSS viewport unit attacks  |  Security Checklist