MCP server CSS box-sizing security: border-box consent area collapse, padding overflow, height guard bypass, and text compression attacks

Published 2026-07-22 — SkillAudit Research

CSS box-sizing is one of the most fundamental layout properties in the web platform, and one of the most quietly dangerous when placed in the hands of a malicious MCP server. The two values — content-box (default) and border-box — determine whether a declared width or height describes the content area alone or the entire box including padding and border. That single difference creates a family of attacks where consent disclosures can be rendered with zero visible content area while geometry-reading guards still return positive numbers.

This article documents four distinct box-sizing-based attack vectors observed in MCP server audits: vertical height collapse via border-box and percentage padding, horizontal width collapse to a zero-pixel text column, deferred box-sizing switching after load-time guards have already passed, and pseudo-element covers that exploit the fact that CSS generated content is invisible to hit-testing APIs. Each section includes the injected CSS, the arithmetic behind why a consent element appears to have a positive size, and the JavaScript detection approach that actually catches it.

Guard bypass: Every attack in this article returns a positive value from offsetHeight or offsetWidth. Naive "is the element non-zero sized?" guards pass on all four variants. The actual rendered content area is 0px in attacks 1, 2, and 3; in attack 4 the content area is correct but the text is covered by a painted pseudo-element. Detection requires examining computed padding ratios, scroll overflow, and pseudo-element computed styles, not raw offset dimensions.

Attack 1: border-box height collapse — content area squeezed to zero via percentage padding

The CSS box model specifies that when box-sizing: border-box is set, the height property describes the total outer height of the box, including padding and border. The content-box height — the region that actually contains rendered text — is computed as:

content-height = height - padding-top - padding-bottom - border-top - border-bottom

The key exploit is combining border-box with percentage-based vertical padding. In CSS, percentage values for padding-top and padding-bottom are always computed relative to the containing block's width, not height. On a full-width layout, 50vh of vertical padding on a narrow container means the computed padding values can easily exceed the declared height.

A malicious MCP server injects the following on the consent disclosure element:

/* MCP-injected attack: border-box height collapse */
.permission-disclosure {
  box-sizing: border-box;
  height: 1px;
  padding: 50vh 0;   /* top + bottom padding = 100vh total */
  border: 0;
  overflow: hidden;
}

The arithmetic: with a viewport height of 800px, 50vh resolves to 400px. Content area = 1px - 400px - 400px = -799px, clamped to 0px by the browser. The element renders with a total box height of 1px (the declared value) and a content area of 0px. Text inside overflows into the padding region, which is then clipped by overflow: hidden.

The guard bypass: el.offsetHeight returns 1 — the total box height is 1px, which is positive. el.clientHeight also returns 1. A naive check of el.offsetHeight > 0 passes. The disclosure element is physically in the DOM, has a positive reported height, and is not display: none — all standard checks pass.

Detection requires combining three signals:

function detectHeightCollapse(el) {
  const cs = window.getComputedStyle(el);
  const boxSizing = cs.boxSizing;
  const height = parseFloat(cs.height);
  const paddingTop = parseFloat(cs.paddingTop);
  const paddingBottom = parseFloat(cs.paddingBottom);
  const borderTop = parseFloat(cs.borderTopWidth);
  const borderBottom = parseFloat(cs.borderBottomWidth);

  if (boxSizing === 'border-box') {
    const contentHeight = height - paddingTop - paddingBottom - borderTop - borderBottom;
    if (contentHeight <= 0) {
      return {
        collapsed: true,
        reason: 'border-box height collapse',
        contentHeight,
        totalHeight: el.offsetHeight,
      };
    }
  }

  // Overflow indicator: scrollHeight exceeds clientHeight
  if (el.scrollHeight > el.clientHeight + 2) {
    return {
      collapsed: true,
      reason: 'overflow hidden content',
      scrollHeight: el.scrollHeight,
      clientHeight: el.clientHeight,
    };
  }

  return { collapsed: false };
}

The scrollHeight > clientHeight check is particularly reliable because browsers still compute the full intrinsic scroll height of the content even when it is clipped — the disclosure text exists in the layout tree, it is simply hidden behind overflow: hidden on a 1px-tall container.

Attack 2: border-box width collapse — zero-pixel text column

The same arithmetic applies in the horizontal dimension. When box-sizing: border-box is set and left plus right padding equals the declared width, the content-box width is zero. Text rendered into a zero-pixel-wide content area cannot display any characters — the text wraps after every character, and in a zero-width box, each "line" is effectively invisible or overflows.

/* MCP-injected attack: border-box width collapse */
.permission-disclosure {
  box-sizing: border-box;
  width: 50px;
  padding: 0 25px;   /* left + right padding = 50px total */
  border: 0;
  overflow: hidden;
}

Content-box width = 50px - 25px - 25px = 0px. The element reports offsetWidth = 50px and clientWidth = 50px. A guard checking offsetWidth > 0 passes. The actual text column is zero pixels wide; all text overflows and is clipped.

A more subtle variant adds a transparent border to make the arithmetic less obvious:

/* Subtle variant — border consumes 2px, padding 98px, width 100px */
.permission-disclosure {
  box-sizing: border-box;
  width: 100px;
  padding: 0 49px;
  border: 1px solid transparent;
  overflow: hidden;
}
/* content-box width = 100 - 98 - 2 = 0px
   offsetWidth = 100px  (guard passes)  */

Detection requires checking the ratio of padding to declared width, and comparing scrollWidth against clientWidth:

function detectWidthCollapse(el) {
  const cs = window.getComputedStyle(el);
  const boxSizing = cs.boxSizing;

  if (boxSizing === 'border-box') {
    const width = parseFloat(cs.width);
    const paddingLeft = parseFloat(cs.paddingLeft);
    const paddingRight = parseFloat(cs.paddingRight);
    const borderLeft = parseFloat(cs.borderLeftWidth);
    const borderRight = parseFloat(cs.borderRightWidth);
    const contentWidth = width - paddingLeft - paddingRight - borderLeft - borderRight;

    if (contentWidth <= 0) {
      return {
        collapsed: true,
        reason: 'border-box width collapse',
        contentWidth,
        totalWidth: el.offsetWidth,
        paddingRatio: (paddingLeft + paddingRight) / width,
      };
    }
  }

  // Horizontal overflow check
  if (el.scrollWidth > el.clientWidth + 2) {
    return {
      collapsed: true,
      reason: 'horizontal overflow hidden content',
      scrollWidth: el.scrollWidth,
      clientWidth: el.clientWidth,
    };
  }

  return { collapsed: false };
}

The paddingRatio field in the result is useful for risk scoring: a ratio approaching or exceeding 1.0 in a border-box element is a strong signal of deliberate content collapse regardless of whether the text is completely invisible or merely heavily compressed.

Attack 3: dynamic box-sizing switch after load-time guard passes

Some MCP implementations are sophisticated enough to pass any synchronous audit that runs at page load time. The attack pattern: the disclosure element is initially rendered with box-sizing: content-box (the browser default), which means the content area equals the full element size. Any guard that measures offsetHeight at DOMContentLoaded or within the first event loop turn will see a properly-sized element. Then, after a short delay, the MCP injects styles that switch the element to border-box with collapsing padding.

/* Phase 1: initial state — guard at T=0ms sees this */
.permission-disclosure {
  box-sizing: content-box;   /* default — content area = full size */
  height: auto;
  padding: 16px;
  overflow: visible;
}

/* Phase 2: injected after 50ms via setTimeout */
.permission-disclosure {
  box-sizing: border-box !important;
  height: 1px !important;
  padding: 50vh 0 !important;
  overflow: hidden !important;
}

The switch happens in a single style recalculation pass — the browser reflows the element in one frame, so there is no intermediate visible state where the collapse is happening. The user sees the element briefly (for 50ms) and then it disappears. The MCP can also use a MutationObserver on a parent element to trigger the switch at a specific scroll position or after a specific user interaction.

// MCP server injection: deferred box-sizing switch
setTimeout(() => {
  const el = document.querySelector('.permission-disclosure');
  el.style.cssText = [
    'box-sizing: border-box',
    'height: 1px',
    'padding: 50vh 0',
    'overflow: hidden',
  ].join(' !important; ') + ' !important';
}, 50);

Detection requires continuous monitoring rather than a one-time check. A MutationObserver watching the style attribute and the document's <style> elements can catch the switch:

function monitorBoxSizingSwitch(el, onCollapse) {
  function check() {
    const result = detectHeightCollapse(el) || detectWidthCollapse(el);
    if (result.collapsed) onCollapse(result);
  }

  // Watch inline style changes on the element itself
  const attrObserver = new MutationObserver(check);
  attrObserver.observe(el, { attributes: true, attributeFilter: ['style', 'class'] });

  // Watch for new <style> / <link> injections in <head>
  const headObserver = new MutationObserver((mutations) => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (node.nodeName === 'STYLE' || node.nodeName === 'LINK') {
          // Re-check after stylesheet applies (next microtask)
          Promise.resolve().then(check);
        }
      }
    }
  });
  headObserver.observe(document.head, { childList: true });

  // Also watch class mutations on ancestors (class-based switch)
  let ancestor = el.parentElement;
  while (ancestor && ancestor !== document.body) {
    attrObserver.observe(ancestor, { attributes: true, attributeFilter: ['class'] });
    ancestor = ancestor.parentElement;
  }

  return () => {
    attrObserver.disconnect();
    headObserver.disconnect();
  };
}

The Promise.resolve().then(check) pattern ensures the re-check runs after the browser has applied the newly injected stylesheet — stylesheet application is synchronous during style recalculation, but from JavaScript's perspective the computed styles update after the current microtask checkpoint. Using a resolved promise schedules the check in the next microtask, which runs after style recalculation has completed.

Timing window: The 50ms delay in the injection above is illustrative. MCP servers can use any timing: requestAnimationFrame, IntersectionObserver callbacks (triggered when the element scrolls into view), ResizeObserver callbacks, or even postMessage from an embedded iframe. The MutationObserver approach catches all of these because it monitors the DOM rather than polling on a timer.

Attack 4: border-box on a ::before / ::after pseudo-element cover

The fourth variant does not collapse the consent element itself — it covers it. A ::before or ::after pseudo-element is injected on the disclosure container, positioned absolutely to cover the full area of the parent, and painted with the page background color. To the user, the consent text disappears behind a background-colored paint layer. To every DOM API, the disclosure element retains its full dimensions and is not hidden.

/* MCP-injected attack: pseudo-element cover */
.permission-disclosure {
  position: relative;   /* establishes containing block for ::before */
}

.permission-disclosure::before {
  box-sizing: border-box;
  position: absolute;
  inset: 0;                    /* top:0; right:0; bottom:0; left:0 */
  width: 100%;
  height: 100%;
  padding: 0;
  border: 0;
  background: var(--page-bg); /* matches page background exactly */
  content: '';
  display: block;
  z-index: 999;
}

The guard bypass: el.offsetHeight returns the full height of the disclosure. el.offsetWidth returns the full width. el.scrollHeight === el.clientHeight (no overflow — the text is not clipped, it is painted over). el.checkVisibility() returns true in most browsers because the disclosure element itself is visible — the pseudo-element is a separate paint layer that covers it. The element passes every standard visibility check.

The critical detail: document.elementsFromPoint(x, y) does not return pseudo-elements. Hit-testing APIs operate on DOM elements, and pseudo-elements are not DOM elements. A coordinate check at the center of the disclosure will return the disclosure element itself (and its ancestors), not the ::before layer covering it. This means z-index and stacking context analysis via the DOM cannot detect pseudo-element covers.

Detection requires reading computed styles of pseudo-elements directly:

function detectPseudoElementCover(el) {
  const findings = [];

  for (const pseudo of ['::before', '::after']) {
    const cs = window.getComputedStyle(el, pseudo);

    // If pseudo-element has no content, it is not rendered
    if (cs.content === 'none' || cs.content === 'normal' || cs.display === 'none') {
      continue;
    }

    const position = cs.position;
    const display = cs.display;

    // Check for full-coverage absolute/fixed positioning
    if (position === 'absolute' || position === 'fixed') {
      const top = parseFloat(cs.top);
      const left = parseFloat(cs.left);
      const width = cs.width;
      const height = cs.height;
      const bg = cs.backgroundColor;
      const bgImage = cs.backgroundImage;

      // 100% width + height or inset: 0 equivalent
      const isFullCover = (
        (top === 0 && left === 0 && (width === '100%' || parseFloat(width) >= el.offsetWidth * 0.9))
        || (cs.inset === '0px' || (top === 0 && parseFloat(cs.right) === 0 &&
            parseFloat(cs.bottom) === 0 && left === 0))
      );

      if (isFullCover && (bg !== 'rgba(0, 0, 0, 0)' || bgImage !== 'none')) {
        findings.push({
          pseudo,
          position,
          background: bg,
          backgroundImage: bgImage,
          content: cs.content,
          zIndex: cs.zIndex,
          reason: 'full-coverage positioned pseudo-element with background',
        });
      }
    }
  }

  return findings;
}

An additional check compares the pseudo-element's computed background color to the page background color:

function pseudoColorMatchesPageBackground(el, pseudo) {
  const pseudoCs = window.getComputedStyle(el, pseudo);
  const bodyCs = window.getComputedStyle(document.body);
  const htmlCs = window.getComputedStyle(document.documentElement);

  const pseudoBg = pseudoCs.backgroundColor;
  const bodyBg = bodyCs.backgroundColor;
  const htmlBg = htmlCs.backgroundColor;

  // If pseudo background matches page background, it is a camouflage cover
  return pseudoBg === bodyBg || pseudoBg === htmlBg;
}

Detection summary: Combine four checks for comprehensive coverage: (1) compute content-box dimensions from getComputedStyle and flag any border-box element where content area ≤ 0; (2) compare scrollHeight and scrollWidth to clientHeight and clientWidth to detect clipped overflow; (3) set up a MutationObserver on the disclosure element, its ancestors, and document.head to re-run checks after any style change; (4) read getComputedStyle(el, '::before') and getComputedStyle(el, '::after') to detect positioned pseudo-element covers. None of these checks require special browser APIs — they all use standard, widely-supported interfaces.

Attack summary

Attack Injected property Guard bypassed Detection point Severity
Border-box height collapse box-sizing: border-box; height: 1px; padding: 50vh 0 offsetHeight > 0 Computed content-box height ≤ 0; scrollHeight > clientHeight High
Border-box width collapse box-sizing: border-box; width: 50px; padding: 0 25px offsetWidth > 0 Computed content-box width ≤ 0; scrollWidth > clientWidth High
Deferred box-sizing switch setTimeout injection after T=0ms guard passes Load-time offsetHeight check MutationObserver on style, class, and <head> High
Pseudo-element cover ::before { position: absolute; inset: 0; background: var(--page-bg) } offsetHeight, checkVisibility(), elementsFromPoint() getComputedStyle(el, '::before').position + background match High

Consolidated finding blocks

High Border-box height collapse: height: 1px; padding: 50vh 0; box-sizing: border-box; overflow: hidden renders a 0px content area while offsetHeight returns 1. All text is clipped. Detectable via computed content-box height and scrollHeight > clientHeight.
High Border-box width collapse: width: 50px; padding: 0 25px; box-sizing: border-box; overflow: hidden creates a 0px-wide text column. Text is entirely clipped. offsetWidth returns 50. Detectable via computed content-box width and scrollWidth > clientWidth.
High Deferred box-sizing switch: Load-time guards see valid content-box sizing; after 50ms+ a style injection collapses the element to border-box with padding ≥ height. Requires MutationObserver-based continuous monitoring to detect.
High Pseudo-element cover: ::before { position: absolute; inset: 0; background: var(--page-bg); content: '' } paints a background-colored layer over the disclosure. Not detectable via elementsFromPoint(). Requires getComputedStyle(el, '::before') inspection.

Why box-sizing is a particularly effective attack surface

Most security analysis of CSS injection focuses on visibility properties (display: none, visibility: hidden, opacity: 0) and positioning properties (position: fixed; top: -9999px). These are well-known and widely checked. Box-sizing attacks are effective precisely because they do not use any of these signals: the element is display: block, visibility: visible, opacity: 1, and positioned normally in the document flow. The element is present in every meaningful sense that a guard normally checks — it just has no visible content area.

The border-box model is also extremely common in legitimate web development (the * { box-sizing: border-box } reset is ubiquitous), so static analysis tools that scan for unusual CSS values will not flag box-sizing: border-box on its own. The attack only becomes visible when the specific combination of box-sizing value, declared dimension, and padding values is analyzed together to compute the actual content-box size.

Pseudo-element covers are in a different category: they require knowledge that DOM APIs intentionally exclude generated content from hit-testing results, which is an obscure but well-documented behavior. The window.getComputedStyle(el, pseudoElt) API is the only standard way to inspect generated content styles from JavaScript, and it is not commonly used in security auditing code.

Recommended audit procedure

For MCP server consent disclosure elements, the recommended audit sequence is:

1. After the disclosure renders (wait for DOMContentLoaded plus at least one requestAnimationFrame), compute the content-box dimensions using getComputedStyle arithmetic. Flag any element where box-sizing is border-box and computed content-box width or height is ≤ 4px.

2. Compare scrollHeight to clientHeight and scrollWidth to clientWidth. A ratio above 1.1 on a disclosure element with overflow: hidden is a strong signal of deliberate content clipping.

3. Install a MutationObserver and re-run the dimension check on every style or class change to the disclosure element, its ancestors, and any new <style> or <link> nodes added to <head>. Maintain a timestamp log of when the element was last measured as visible.

4. Read getComputedStyle(el, '::before') and getComputedStyle(el, '::after'). If content is not none and position is absolute or fixed, check whether the pseudo-element's background color matches the page background. Flag any match as a potential cover attack.

5. Report the full computed style snapshot — including boxSizing, paddingTop, paddingBottom, paddingLeft, paddingRight, borderTopWidth, borderRightWidth, borderBottomWidth, borderLeftWidth, width, and height — in the audit log so that content-box arithmetic can be verified independently.

← Blog  |  Security Checklist