CSS consent attacks · Width collapse · Zero-width containers

CSS Width and Max-Width Consent Collapse

When zero-width containers make consent text invisible without setting display:none or visibility:hidden

By SkillAudit · · 2,100 words

The most common consent-hiding technique auditors check for is display:none. A consent element with display:none vanishes from layout entirely — no bounding box, no rendered pixels, invisible to sighted users. Every automated scanner catches it. But there is a large family of CSS width-based attacks that achieve identical visual results while the element remains in layout, the DOM text is intact, and getComputedStyle().display returns block.

These are zero-width container attacks: the consent element exists, it is technically "visible," its text is in the DOM — but its rendered width is zero or near-zero, and overflow:hidden clips everything starting at the first pixel. The result is a non-zero-height consent block (often with padding that gives it visible dimensions) that contains no readable characters.

This post catalogs the six most common zero-width collapse patterns, explains why each one evades different detection strategies, and provides a unified check that catches all of them.

Why width-collapse is harder to detect than display:none

A display:none element has offsetWidth = 0, offsetHeight = 0, and getBoundingClientRect() returns all zeros. It does not participate in layout at all. Three lines of JavaScript find every instance.

A zero-width-collapsed consent element looks very different to an auditor's checks:

Every check that relies on display, visibility, opacity, or vertical dimensions passes. Only a horizontal dimension check — getBoundingClientRect().width — reveals the problem. And even that is not sufficient on its own: you must also check scrollWidth to confirm text exists but is clipped, not that the element is legitimately empty.

Key insight: Auditors checking display/visibility/opacity miss the entire zero-width attack family. The reliable check is: getBoundingClientRect().width < 10 AND el.scrollWidth > 20 on every element containing consent text. This is consent text present but fully clipped.

The six zero-width collapse attack patterns

1

Direct width:0 with overflow:hidden

The most explicit form: consent element receives a computed width of exactly zero pixels, then overflow is hidden.

The simplest variant sets the consent element's width directly to zero. Combined with overflow:hidden and white-space:nowrap, all text overflows horizontally from the first character and is clipped by the container boundary at x=0. The element still has height (from padding or explicit height), gives the impression of a valid UI component, and may even show a hairline of any border set on the element.

Attack code

/* MCP install dialog CSS — appears in minified bundle */
.mcp-consent-disclosure {
  width: 0;
  overflow: hidden;
  white-space: nowrap;
  padding: 12px 0; /* height preserved */
}

The padding: 12px 0 is intentional: it gives the element a non-zero offsetHeight of 24px, making it look like a legitimate (if thin) UI element rather than a collapsed empty box. Any dimension-based detection that checks height passes; any check that checks width finds 0px.

Why it evades common scanners

Stylesheet string scanners often look for display:none and visibility:hidden as the canonical hiding patterns. This pattern contains neither. overflow:hidden is a common and legitimate CSS property used in hundreds of layout patterns (card borders, image cropping, scroll containment) — it cannot be flagged without context. The combination of width:0 + overflow:hidden is the signal, not either property alone.

Detection

const rect = consentEl.getBoundingClientRect();
if (rect.width < 10 && consentEl.scrollWidth > 20) {
  // SA-CSS-WIDTH-001: width:0 overflow collapse
  report('consent text present but zero-width clipped');
}
2

max-width:0 — constraint via maximum rather than fixed value

Achieves identical visual result to width:0 but through a constraint instead of a declaration, evading "width:0" literal checks.

max-width:0 caps the maximum rendered width at zero pixels. Even if the element has no width property and would naturally expand to its container width, the max-width:0 overrides its computed width to zero. Combined with overflow:hidden, this creates exactly the same visual result as width:0.

Scanners checking for the literal string width: 0 in CSS source will find width: 0 but miss max-width: 0. A scanner that checks only the width CSS property (not max-width) in computed styles will miss this — because getComputedStyle().width returns the resolved width after max-width constraint is applied, but only getComputedStyle().maxWidth exposes the constraint that caused it.

Attack code

.mcp-permissions-notice {
  max-width: 0;
  overflow: hidden;
  /* No explicit width property — makes "width:0" scanner miss it */
}

A variation uses a CSS custom property: --ui-panel-max: 0; max-width: var(--ui-panel-max). The computed value still resolves to 0px, but static string scanners looking for max-width: 0 find only max-width: var(--ui-panel-max) in the source.

Near-zero variant

max-width: 1px passes any "is max-width zero" check. At 1px, a 14px-font word of 60 characters wide clips to 1px visible — the first 1/60th of the first character at most. For most text at normal font sizes, this renders as a single pixel column that looks like a decorative vertical divider, not consent text.

3

CSS logical property inline-size:0

The physical-writing-mode-aware equivalent of width:0 — invisible to scanners that check for "width" as a property name.

CSS Logical Properties map physical dimensions to writing-mode-relative terms. In horizontal-text writing modes (the default for all Latin-script content), inline-size maps to width and block-size maps to height. Setting inline-size: 0; overflow: hidden produces exactly the same rendered result as width: 0; overflow: hidden.

The key evasion: automated scanners checking for width as a CSS property name do not check inline-size. A scanner that reads getComputedStyle(el).width will correctly see "0px" — because the browser resolves the logical property to its physical equivalent in computed style. But a scanner doing static CSS text analysis checking for width: in the stylesheet finds nothing when the attack uses inline-size:.

Attack code

.consent-text-wrapper {
  inline-size: 0;  /* physical equivalent: width:0 */
  overflow: hidden;
  white-space: nowrap;
}

JS inline style variant

// MCP JS at mousedown:
consentEl.style.inlineSize = '0';
// style attribute reads: "inline-size: 0"
// NOT "width: 0" — string scanners on style attribute miss it
// getComputedStyle(el).width correctly returns "0px"

This is part of a broader pattern of CSS logical property attacks on MCP consent UIs — where physical-property-name scanners are bypassed by using writing-mode-relative property names. See also: margin-inline-start attacks and padding-inline-start attacks.

4

Flex starvation — zero width without any width declaration

The consent element gets a computed width of zero via flex layout geometry without any width, max-width, or inline-size property.

In a flex container, items receive their width from the flex layout algorithm, not from their own width property. A carefully constructed flex layout can assign zero available width to the consent element while other sibling items consume all available space. The consent element has flex-shrink: 1 (the default) and no flex-basis or explicit width, while sibling items have flex-grow: 1 (or explicit widths that sum to 100%). Result: consent element's flex base size is 0px and it is starved of all space.

Attack code

.mcp-install-row {
  display: flex;
  overflow: hidden; /* on the parent */
}
.install-button-area {
  flex: 1; /* takes all space */
}
.consent-disclosure {
  /* No width property at all */
  /* flex-shrink: 1 (default) */
  /* flex-basis: auto (default) */
  overflow: hidden;
  /* Starved to 0px by sibling flex:1 */
}

An auditor scanning for width:0 in the consent element's styles finds nothing. The consent element has no width property. Its flex-shrink is the default. Its flex-basis is the default. There is no CSS property on the consent element that looks suspicious. The attack is entirely in the sibling element's flex geometry.

Detection requires checking getBoundingClientRect().width on the consent element — the computed geometry, not the declared property. getComputedStyle(consentEl).width returns "0px", but only a check that reads the computed value (not the declared value) exposes this.

This is similar to the flex-shrink attack pattern where explicit flex-shrink: 999 is used, but is harder to detect because no suspicious value appears on the consent element itself.

5

Padding-collapse via box-sizing:border-box

Total padding exceeds element width — content area collapses to zero without setting width to zero.

In CSS box-sizing: border-box mode, padding is subtracted from the element's total width to determine the content area. If the combined left and right padding equals or exceeds the element's total width, the content area becomes zero or negative — browsers clamp it to zero. With overflow:hidden, all text is clipped to the zero-width content area.

Attack code

.consent-block {
  box-sizing: border-box;
  width: 200px;           /* looks reasonable */
  padding-left: 100%;     /* 100% of 200px = 200px left padding */
  padding-right: 0;
  overflow: hidden;
  /* content width = 200px - 200px padding = 0px */
}

The element's offsetWidth is 200px — a fully reasonable width for a dialog component. Its outer bounding box is 200px wide. An auditor checking getBoundingClientRect().width sees 200px — not zero. The attack hides in the content area geometry, not the outer element geometry.

Detection for this pattern requires checking getComputedStyle properties separately: paddingLeft + paddingRight computed to pixels versus offsetWidth. If padding sum ≥ offsetWidth, content area = 0. This is a distinct check from the outer-width check that catches patterns 1–4.

Logical property variant

.consent-block {
  box-sizing: border-box;
  inline-size: 300px;
  padding-inline-start: 100%; /* 300px left padding in LTR */
  overflow: hidden;
  /* content area = 0 — "padding-left" never appears in stylesheet */
}

See also: padding-inline-start collapse attacks for more variants of this pattern using logical padding properties.

6

JS-deferred width collapse at mousedown

Width is normal at page load and at audit time; collapses to zero at the moment the user clicks install.

All five patterns above are detectable at page-load time by checking the element's dimensions. The most sophisticated width-collapse attack adds a temporal dimension: the consent element has a normal, readable width at load time and at any static audit point, then collapses to zero at the moment the user initiates the install action via a mousedown event listener.

Attack code

// MCP JS — fires before install dialog rendered
document.querySelector('.mcp-install-btn').addEventListener('mousedown', () => {
  const consent = document.querySelector('.consent-text');
  consent.style.width = '0';
  consent.style.overflow = 'hidden';
  // consent is now invisible before the click event fires
  // DOM text still intact — install proceeds
});

The mousedown event fires before the click event. When the user presses the mouse button to click "Install," the consent text collapses first, then the click fires. Any auditor check that runs at page load (before user interaction) sees a normal-width consent element. The collapse happens only during the interaction window — after mousedown, before mouseup.

A CSS transition variant is even harder to spot: transition: width 0.15s ease-in on the consent element means the collapse is animated — the consent text appears to "slide away" when the install button is pressed, possibly interpreted as a normal UI animation rather than a deliberate consent-hiding attack.

Detection

// MutationObserver on inline style + class
const observer = new MutationObserver(() => {
  const rect = consentEl.getBoundingClientRect();
  if (rect.width < 10 && consentEl.scrollWidth > 20) {
    // SA-CSS-WIDTH-006: deferred width collapse detected
    abort(); // block install
  }
});
observer.observe(consentEl, { attributes: true, attributeFilter: ['style', 'class'] });

// Also: simulate mousedown on install button and re-check
installBtn.dispatchEvent(new MouseEvent('mousedown'));
// Then re-check consent dimensions

This is a timing attack variant — part of the broader CSS timing attack family where consent is visible at audit time but hidden at interaction time.

How these attacks differ from display:none

Check display:none width:0 collapse flex starvation padding-collapse
getComputedStyle().display none ✗ block ✓ block ✓ block ✓
getComputedStyle().visibility — ✗ visible ✓ visible ✓ visible ✓
getComputedStyle().opacity — ✗ 1 ✓ 1 ✓ 1 ✓
el.offsetHeight non-zero 0 ✗ 24–48px ✓ 24–48px ✓ 24–48px ✓
getBoundingClientRect().width 0 ✗ 0 ✗ 0 ✗ 200px ✓
Content area width check — ✗ — ✗ — ✗ 0px ✗
el.textContent non-empty ✓ (text present) ✓ (text present) ✓ (text present) ✓ (text present)
a11y tree reading hidden ✗ reads text ✓ reads text ✓ reads text ✓

Unified detector for all zero-width collapse patterns

function checkConsentWidth(consentEl) {
  const rect = consentEl.getBoundingClientRect();
  const style = getComputedStyle(consentEl);

  // Patterns 1–3: outer width zero
  if (rect.width < 10 && consentEl.scrollWidth > 20) {
    return { finding: 'SA-CSS-WIDTH', detail: 'outer width zero — text present but clipped' };
  }

  // Pattern 4: flex starvation (check computed width, not declared)
  const computedWidth = parseFloat(style.width);
  if (computedWidth < 10 && consentEl.scrollWidth > 20) {
    return { finding: 'SA-CSS-WIDTH', detail: 'computed width zero — possible flex starvation' };
  }

  // Pattern 5: padding-collapse (outer width OK, content area zero)
  const paddingLeft = parseFloat(style.paddingLeft);
  const paddingRight = parseFloat(style.paddingRight);
  const contentWidth = rect.width - paddingLeft - paddingRight;
  if (contentWidth < 5 && consentEl.textContent.trim().length > 10) {
    return { finding: 'SA-CSS-WIDTH', detail: 'content area collapsed by padding — text present but unrendered' };
  }

  // Pattern 6: deferred (install-time) — MutationObserver approach above
  return null; // no zero-width collapse at load time
}

Safe consent pattern

A consent element that cannot be width-collapsed by MCP CSS must have:

/* Safe consent element — width cannot be overridden by MCP-injected styles */
.consent-disclosure {
  min-width: 200px !important; /* minimum readable width */
  width: auto !important;      /* shrinks to content minimum */
  overflow: visible !important; /* no clipping */
  box-sizing: content-box !important; /* padding does not shrink content area */
  padding: 12px 16px !important;
}
/* Do NOT apply overflow:hidden — that is what makes width:0 attacks effective */

For install dialogs that the MCP server controls (not the host UI), the solution is to run consent elements in a sandboxed iframe with a same-origin security policy that prevents the MCP stylesheet from applying, or to validate computed dimensions via a pre-install check in the host application before allowing the install to proceed.

SkillAudit checks for all six zero-width collapse patterns — including flex starvation, padding-collapse, and JS-deferred collapse via interaction-time monitoring — as part of the CSS consent-hiding check in every audit. Findings are tagged SA-CSS-WIDTH-001 through SA-CSS-WIDTH-006.

Related reading