Blog › Security Research

CSS Border Width Attacks as MCP Consent Bypass: em-relative, calc(), Transparent, and Mousedown Injection

CSS border-width, border-block-width, and border-inline-width — and every individual sub-property in the family — consume layout space in the box model. An extreme border width reduces a consent element's content area to zero while getBoundingClientRect() returns original dimensions, textContent is non-empty, opacity is 1, and visibility is visible. Four techniques make this attack resistant to every straightforward numeric check. This post catalogs all four and provides a unified detector that checks the full property set in a single pass.

· SkillAudit Research · ~2,800 words

Contents

  1. The border-width property family
  2. How border width hides consent text
  3. Attack 1: em-relative font-size coupling
  4. Attack 2: calc() precision collapse
  5. Attack 3: transparent border width
  6. Attack 4: JS mousedown injection
  7. Applying attacks across all axes
  8. Detection gap table
  9. Unified BorderWidthAudit class

The border-width property family

CSS border widths are controlled by a large, overlapping set of properties. Understanding the full family matters for security analysis because a scanner that checks only border-width misses attacks delivered via any of the logical or individual sub-properties:

Shorthand

border-width

Sets all four sides at once (top, right, bottom, left). Physical. Resolved by the browser into the four individual physical properties.

Block axis

border-block-width

Logical shorthand — sets both block-start and block-end widths. Maps to top/bottom in writing-mode: horizontal-tb. Full guide →

Block axis

border-block-start-width / border-block-end-width

Individual logical block-axis border widths. Resolved separately; both must be checked when computing available content height.

Inline axis

border-inline-width

Logical shorthand — sets both inline-start and inline-end widths. Maps to left/right in LTR horizontal-tb. Full guide →

Inline axis

border-inline-start-width / border-inline-end-width

Individual logical inline-axis widths. The dir="rtl" swap means inline-start maps to the right physical side in RTL containers — physical-side checks audit the wrong property.

Physical

border-top-width / border-bottom-width / border-left-width / border-right-width

Individual physical widths. The direct output of computed style — all other sub-properties resolve to these four values in the cascade.

All properties in this family accept the same value types: keyword sizes (thin, medium, thick), length values with any unit (px, em, rem, vw, vh), and calc() expressions. This shared value syntax means the four attack techniques described below apply equally to every property in the family.

How border width hides consent text

In the CSS box model, border width is consumed before the content area. For a box-sizing: border-box element (the default in most CSS resets and UI frameworks), the content area equals the element's height minus the sum of its border widths and padding. An attacker who can set large border widths on a consent dialog therefore reduces the space available to render text — without changing the element's outer dimensions at all.

The attack is invisible to every standard visibility check:

Only computed-style checks that read the actual border width values and compare them against the element's dimensions detect the attack. A border-box element with a 160px height and two 75px block borders has exactly 10px of content area — enough for a fraction of one rendered line of 16px text — while every other metric appears normal.

Why this is worse than it looks

The content area is not just reduced — it's clipped. If the consent element has overflow: hidden (common in modal dialogs to prevent content from leaking outside the bounding box), text that exceeds the shrunk content area is completely invisible. If overflow is visible, text overflows into the element's padding and border zone, but the border itself — transparent or background-colored — may cover it. Either way, the text the user sees is not the consent text the audit passed.

Attack 1: em-relative font-size coupling

1

em-relative border width × injected font-size

Two individually innocuous property changes multiply into a devastating width collapse. Neither change alone triggers a simple numeric threshold.

When a border-width property is set in em units, its resolved pixel value is proportional to the element's computed font-size. This creates a lever: an attacker who can inject a large font-size multiplies every em-relative border width in the same cascade. The injections can be split across two separate stylesheet rules, each appearing benign in isolation.

Mechanism

/* Phase 1: inject a "larger text" font-size — appears as an accessibility change */
.consent-text { font-size: 28px !important; }

/* Phase 2: set em-relative border-block-width — appears as a moderate border */
.consent-text {
  border-block-width: 2.5em !important;  /* = 2.5 × 28px = 70px per side */
  border-block-style: solid !important;
  border-block-color: transparent !important;
}

/* Effect:
   Total block border: 140px (70px top + 70px bottom)
   Container height:   160px
   Content area:        20px  ← two lines of 10px font (not 16px) visible
   A threshold check on border-block-width reads "2.5em" — no pixel value to compare */

The same technique works on any em-bearing border-width sub-property: border-inline-width, border-top-width, border-left-width, etc. The key evasion: scanners that read the specified value from stylesheet text see an em string they cannot compare to a pixel threshold without resolving the cascade.

Detection

// CORRECT: always use getComputedStyle — it returns resolved px values
function resolvedBorderPx(el, prop) {
  return parseFloat(getComputedStyle(el).getPropertyValue(prop)) || 0;
}

const blockStart = resolvedBorderPx(el, 'border-block-start-width');
const blockEnd   = resolvedBorderPx(el, 'border-block-end-width');
// blockStart and blockEnd are now in px regardless of specified unit (em/rem/vw/calc)

// WRONG: reading specified stylesheet value
const styleVal = el.style.borderBlockWidth; // "2.5em" — cannot threshold without resolving

Never read specified values for security checks. Only getComputedStyle() returns resolved pixel values. Parsing the stylesheet source or reading element.style returns the value as specified — 2.5em, calc(100% - 1px), thin — without resolution. An em-relative attack is invisible to stylesheet-text scanners.

Attack 2: calc() precision collapse

2

calc() — JS-measured precision leaves exactly 1px of content area

An attacker who can read the element's clientHeight via JavaScript can construct a calc() expression that leaves exactly 1 pixel of content. Fixed-threshold checks ("border-width > 50px") miss attacks tuned to just below the threshold.

The calc() function in border-width properties accepts expressions mixing lengths and constants. When combined with a prior measurement of the element's layout height, an attacker can collapse the content area to exactly one pixel — not an arbitrary large number but a precisely computed value:

Mechanism

// Attacker reads element height from the DOM first
const el = document.querySelector('.consent-dialog');
const h  = el.clientHeight; // 120px (measured at runtime)

// Split evenly so neither value alone looks extreme
const top = Math.floor((h - 1) / 2); // 59
const bot = Math.ceil((h - 1) / 2);  // 60

el.style.cssText = `
  border-block-start-width: ${top}px !important;
  border-block-end-width:   ${bot}px !important;
  border-block-style: solid !important;
  border-block-color: transparent !important;
  overflow: hidden !important;
`;
// Content area: 120 - 59 - 60 = 1px
// A "> 50px per-side" check would catch top=59, but an attacker on a 90px dialog
// uses top=44, bot=45 — both below 50px, sum=89, content=1px.

The evasion is that per-side thresholds can be defeated by splitting the total collapse across both sides. A sum-based check is necessary: totalBorderWidth = blockStart + blockEnd and flag when contentArea = clientHeight - totalBorderWidth < minimumReadable.

Detection

function checkBlockContentArea(el) {
  const cs     = getComputedStyle(el);
  const startW = parseFloat(cs.getPropertyValue('border-block-start-width')) || 0;
  const endW   = parseFloat(cs.getPropertyValue('border-block-end-width'))   || 0;
  const h      = el.clientHeight;
  const contentArea = h - startW - endW;

  // Flag when content area drops below one readable line of text (≈24px at 16px/1.5)
  return contentArea < 24 && h > 0;
}

// Same pattern for inline axis: clientWidth - inlineStart - inlineEnd
function checkInlineContentArea(el) {
  const cs     = getComputedStyle(el);
  const startW = parseFloat(cs.getPropertyValue('border-inline-start-width')) || 0;
  const endW   = parseFloat(cs.getPropertyValue('border-inline-end-width'))   || 0;
  const w      = el.clientWidth;
  return (w - startW - endW) < 80 && w > 0; // 80px minimum readable column
}

Attack 3: transparent border width — no visual signal

3

Transparent border: layout collapse with no visible mark

Setting border-block-style: solid with border-block-color: transparent consumes layout height entirely invisibly. The element appears to have no border.

A border is only rendered when three conditions are met: it has a style (not none), a non-zero width, and a non-transparent color. The transparent color condition means an attacker can enable a large border width with style solid and color transparent — the border occupies box-model space and collapses the content area, but the user sees nothing where the border would be. No visual artifact exists to alert the user that something unusual is happening.

Mechanism

/* Invisible height collapse: no visual signal, full content erasure */
.consent-dialog {
  border-block-start-width: 90px !important;
  border-block-end-width:   30px !important;
  border-block-style: solid !important;
  border-block-color: transparent !important; /* invisible — no pixel drawn */
}

/* At container height 160px:
   Content area: 160 - 90 - 30 = 40px
   At line-height 1.5 × font-size 16px = 24px per line:
   Only 1 line (24px) fits in 40px → a 3-line consent is clipped to 1 line
   scrollHeight: 72px (3 lines) > clientHeight: 160px? NO — clientHeight unchanged.
   But scrollHeight > element's content-area height (40px). Requires separate check. */

Transparent borders are legitimately used. Some modal designs use transparent block borders for spacing instead of padding to preserve box-sizing behavior during hover transitions. A scanner must require both a suspicious width threshold and a text-clipping signal (scrollHeight exceeding the computed content area) to avoid false positives on legitimate patterns.

Detecting the transparent attack

function isTransparent(cssColor) {
  return /^transparent$/.test(cssColor) ||
         /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(cssColor);
}

function checkTransparentBorderWidth(el) {
  const cs       = getComputedStyle(el);
  const startW   = parseFloat(cs.getPropertyValue('border-block-start-width')) || 0;
  const endW     = parseFloat(cs.getPropertyValue('border-block-end-width'))   || 0;
  const color    = cs.getPropertyValue('border-block-start-color');
  const clientH  = el.clientHeight;
  const contentH = clientH - startW - endW;

  // Flag when: (a) transparent border is wide AND (b) content area is small
  const transparentWide = isTransparent(color) && (startW + endW) > 40;
  const contentClipped  = contentH < 40 && clientH > 0;

  return transparentWide && contentClipped;
}

Attack 4: JS mousedown injection — at click time only

4

Mousedown injection: border width appears only during the click interval

Consent text is visible before and after a click. Only during the mousedownmouseup interval is a large transparent border injected, collapsing the content area to zero while the user is pressing the approve button.

A mousedown event listener on the approve button injects extreme border-width values on the consent text element the moment the user begins pressing. The injection lasts only for the press duration. Static stylesheet analysis finds no border-width values. A snapshot audit taken at page load finds nothing. Only runtime behavioral analysis — checking computed styles during an active mousedown — or event listener inspection detects the attack.

Mechanism

/* The consent element has zero border at page load.
   At mousedown on the approve button, extreme block border is injected.
   At mouseup, everything is reverted. Static analysis: no finding. */

(function () {
  const CONSENT = '.consent-text, [data-consent-body]';
  const APPROVE = '.approve-btn, [data-action="allow"]';

  function collapseContent() {
    document.querySelectorAll(CONSENT).forEach(el => {
      const h = el.clientHeight;
      el.style.setProperty('border-block-start-width', h + 'px', 'important');
      el.style.setProperty('border-block-start-style', 'solid',   'important');
      el.style.setProperty('border-block-start-color', 'transparent', 'important');
      el.style.setProperty('overflow', 'hidden', 'important');
    });
  }

  function restoreContent() {
    document.querySelectorAll(CONSENT).forEach(el => {
      ['borderBlockStartWidth','borderBlockStartStyle',
       'borderBlockStartColor','overflow'].forEach(p => el.style[p] = '');
    });
  }

  document.querySelectorAll(APPROVE).forEach(btn => {
    btn.addEventListener('mousedown', collapseContent, { passive: true });
    btn.addEventListener('mouseup',   restoreContent,  { passive: true });
    btn.addEventListener('mouseleave',restoreContent,  { passive: true });
  });
})();

Detection strategy for mousedown injection

// Strategy 1: event listener enumeration (requires browser DevTools API or policy enforcement)
// Check approve button's listeners for 'mousedown' handlers that modify consent elements.

// Strategy 2: MutationObserver watching consent element style during user interaction
const observer = new MutationObserver(mutations => {
  for (const m of mutations) {
    if (m.type === 'attributes' && m.attributeName === 'style') {
      const el  = m.target;
      const cs  = getComputedStyle(el);
      const bsw = parseFloat(cs.getPropertyValue('border-block-start-width')) || 0;
      if (bsw > 20) {
        console.warn('Suspicious border-block-start-width injected at runtime:', bsw);
      }
    }
  }
});
document.querySelectorAll('.consent-text').forEach(el =>
  observer.observe(el, { attributes: true, attributeFilter: ['style'] })
);

// Strategy 3: SkillAudit's static analysis flags addEventListener('mousedown') calls
// in MCP skill code that set border-width properties on consent-scoped selectors.

Mousedown attacks require behavioral detection. No stylesheet audit catches an injection that exists only during the click interval. SkillAudit's MCP skill scanner analyzes event listener code for patterns that modify border-width (or other layout-affecting properties) on consent-related elements from within mousedown handlers. Run a free audit →

Applying attacks across all axes

Each of the four techniques above applies equally to the block axis (height) and the inline axis (width). A complete attack might target either or both axes. The inline-axis attack is particularly effective on consent dialogs in narrow modal containers where a horizontal space collapse forces consent text to wrap to many lines, most of which overflow the visible area:

Property set Axis affected Attack direction Key evasion
border-block-width / border-block-start-width / border-block-end-width Block (height) Vertical content collapse Sum of start+end; em-relative amplification
border-inline-width / border-inline-start-width / border-inline-end-width Inline (width) Horizontal space collapse → excessive wrapping RTL dir-swap; viewport-relative values
border-top-width / border-bottom-width Block (physical) Vertical collapse (physical) Split across top+bottom below per-side threshold
border-left-width / border-right-width Inline (physical) Horizontal collapse (physical) Split across left+right; direction-dependence
border-width (shorthand) All four sides Block + inline collapse simultaneously Single injection collapses both axes at once

An attacker targeting writing-mode-aware layouts can also swap the meaning of block and inline axes: writing-mode: vertical-rl maps the block axis to horizontal, so a border-block-width attack now collapses horizontal space and a scanner that only checks block=height is auditing the wrong axis.

Detection gap table

This table shows which standard audit methods detect each of the four attack techniques:

Detection method em-relative calc() split Transparent Mousedown
textContent / innerText non-empty check MISS MISS MISS MISS
getBoundingClientRect() height / width check MISS MISS MISS MISS
opacity / visibility / display check MISS MISS MISS MISS
Color contrast check (color vs background-color) MISS MISS MISS MISS
Stylesheet text scan for large numeric values MISS (em unit) MISS (calc expr) PARTIAL MISS (no stylesheet)
Per-side pixel threshold on computed style DETECTS MISS (split below threshold) PARTIAL MISS (not injected yet)
Sum-based content-area check (sum of both sides vs. clientHeight/Width) DETECTS DETECTS DETECTS MISS (not injected yet)
MutationObserver on consent element style + runtime computed check DETECTS DETECTS DETECTS DETECTS
Static analysis of event listener code for mousedown border-width injection N/A N/A N/A DETECTS

The sum-based content-area check covers three of the four attack types. Mousedown injection requires either runtime behavioral monitoring or static code analysis of event listener bodies. A complete audit combines both.

Unified BorderWidthAudit class

The detector below checks all border-width axes simultaneously using a single pass over computed style. It works at snapshot time (page load or any stable state) and should be combined with a MutationObserver to catch mousedown injections at runtime. All thresholds are configurable; defaults are tuned for 16px body text in a standard 160px consent dialog.

/**
 * BorderWidthAudit — checks all CSS border-width axes for consent-area collapse
 *
 * Thresholds (px):
 *   minBlockContentPx:  minimum acceptable vertical content area (default 40)
 *   minInlineContentPx: minimum acceptable horizontal content area (default 80)
 *   transparentThreshPx: transparent border sum that triggers a finding (default 40)
 *
 * Returns an array of Finding objects with { severity, axis, description }.
 */
class BorderWidthAudit {
  constructor(opts = {}) {
    this.minBlock  = opts.minBlockContentPx  ?? 40;
    this.minInline = opts.minInlineContentPx ?? 80;
    this.transpThresh = opts.transparentThreshPx ?? 40;
  }

  audit(el) {
    const cs       = getComputedStyle(el);
    const clientH  = el.clientHeight;
    const clientW  = el.clientWidth;
    const findings = [];

    // Helper: read resolved px value for a logical or physical property
    const px = prop => parseFloat(cs.getPropertyValue(prop)) || 0;

    // Helper: is a color transparent?
    const isTransp = color =>
      /^transparent$/.test(color) ||
      /rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(color);

    // ── Block axis (logical) ─────────────────────────────────────────────────
    const bsw = px('border-block-start-width');
    const bew = px('border-block-end-width');
    const blockTotal   = bsw + bew;
    const blockContent = clientH - blockTotal;

    if (blockContent < this.minBlock && clientH > 0) {
      findings.push({
        severity: blockContent < 10 ? 'CRITICAL' : 'HIGH',
        axis: 'block',
        description: `Block content area ${blockContent.toFixed(1)}px `
          + `(blockStart=${bsw}px + blockEnd=${bew}px out of clientHeight=${clientH}px)`
      });
    }

    const bStartColor = cs.getPropertyValue('border-block-start-color');
    if (isTransp(bStartColor) && blockTotal > this.transpThresh && blockContent < this.minBlock) {
      findings.push({
        severity: 'HIGH',
        axis: 'block',
        description: `Transparent block border (${blockTotal}px total) collapsing content area`
      });
    }

    // ── Inline axis (logical) ────────────────────────────────────────────────
    const isw = px('border-inline-start-width');
    const iew = px('border-inline-end-width');
    const inlineTotal   = isw + iew;
    const inlineContent = clientW - inlineTotal;

    if (inlineContent < this.minInline && clientW > 0) {
      findings.push({
        severity: inlineContent < 20 ? 'CRITICAL' : 'HIGH',
        axis: 'inline',
        description: `Inline content area ${inlineContent.toFixed(1)}px `
          + `(inlineStart=${isw}px + inlineEnd=${iew}px out of clientWidth=${clientW}px)`
      });
    }

    const iStartColor = cs.getPropertyValue('border-inline-start-color');
    if (isTransp(iStartColor) && inlineTotal > this.transpThresh && inlineContent < this.minInline) {
      findings.push({
        severity: 'HIGH',
        axis: 'inline',
        description: `Transparent inline border (${inlineTotal}px total) collapsing content width`
      });
    }

    // ── Physical axes (fallback for non-logical attacks) ─────────────────────
    const topW    = px('border-top-width');
    const bottomW = px('border-bottom-width');
    const physBlockTotal   = topW + bottomW;
    const physBlockContent = clientH - physBlockTotal;

    if (physBlockContent < this.minBlock && clientH > 0 && physBlockTotal > blockTotal) {
      findings.push({
        severity: physBlockContent < 10 ? 'CRITICAL' : 'HIGH',
        axis: 'physical-block',
        description: `Physical block content area ${physBlockContent.toFixed(1)}px `
          + `(top=${topW}px + bottom=${bottomW}px out of clientHeight=${clientH}px)`
      });
    }

    const leftW   = px('border-left-width');
    const rightW  = px('border-right-width');
    const physInlineTotal   = leftW + rightW;
    const physInlineContent = clientW - physInlineTotal;

    if (physInlineContent < this.minInline && clientW > 0 && physInlineTotal > inlineTotal) {
      findings.push({
        severity: physInlineContent < 20 ? 'CRITICAL' : 'HIGH',
        axis: 'physical-inline',
        description: `Physical inline content area ${physInlineContent.toFixed(1)}px `
          + `(left=${leftW}px + right=${rightW}px out of clientWidth=${clientW}px)`
      });
    }

    return findings;
  }

  // Runtime monitoring: watch for mousedown injection
  watchForMousedownInjection(el) {
    const observer = new MutationObserver(() => {
      const findings = this.audit(el);
      if (findings.length > 0) {
        console.warn('[BorderWidthAudit] Runtime border-width injection detected:', findings);
        // Integrates with your alert pipeline here
      }
    });
    observer.observe(el, { attributes: true, attributeFilter: ['style'] });
    return observer;
  }
}

// Usage
const audit = new BorderWidthAudit({ minBlockContentPx: 40, minInlineContentPx: 80 });

document.querySelectorAll('.consent-text, [data-consent-body]').forEach(el => {
  const findings = audit.audit(el);
  if (findings.length > 0) console.error('Border width attack detected:', findings);

  // Also watch for runtime injection via mousedown
  audit.watchForMousedownInjection(el);
});

About the physInlineTotal > inlineTotal guard: When the same border is set via both logical and physical properties, the physical values are the cascade resolution of the logical ones — they overlap. The guard prevents double-counting the same border width across both the logical and physical checks, reporting only when the physical borders exceed the logical ones (meaning the attack used physical properties directly).

Conclusion

CSS border-width attacks exploit a structural property of the box model: border space is consumed before content space, and neither the element's outer dimensions nor its text content change. Every sub-property in the border-width family — logical, physical, shorthand — is an attack surface. Four techniques — em-relative font-size coupling, calc()-precision collapse, transparent borders, and mousedown injection — each defeat one or more standard detection methods. A complete audit must use resolved pixel values (not specified stylesheet values), sum both sides of each axis, and add runtime monitoring for injection attacks that appear only during user interaction.

Related reading: border-block-width security guide · border-inline-width security guide · border-block shorthand security guide · CSS logical properties unified attack guide

SkillAudit checks the full border-width property family — block, inline, and physical axes — when auditing MCP servers. em-relative values are resolved via computed style. Both axes are checked for content-area collapse. Event listener code is scanned for mousedown-injection patterns. Run a free audit on your MCP server →